Test

①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳✕✓✔✖

本节介绍 Spring Security 提供的测试支持。

要使用 Spring Security 测试支持,您必须包含 spring-security-test-5.6.2.jar 作为项目的依赖项。

[TOC]

1. Method Security

本节演示如何使用 Spring Security 的测试支持来测试基于方法的安全性。我们首先介绍一个 MessageService,它要求用户通过身份验证才能访问它。

public class HelloMessageService implements MessageService {
@PreAuthorize("authenticated")
public String getMessage() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
return "Hello " + authentication;
}
}

getMessage 的结果是一个对当前 Spring Security Authentication 说“Hello”的字符串。下面显示了一个输出示例。

Hello org.springframework.security.authentication.UsernamePasswordAuthenticationToken@ca25360: Principal: org.springframework.security.core.userdetails.User@36ebcb: Username: user; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_USER; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER

1.1 Security Test 设置

在我们可以使用 Spring Security Test 支持之前,我们必须执行一些设置。一个例子可以在下面看到:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
public class WithMockUserTests {

这是一个如何设置 Spring Security Test 的基本示例。亮点是:

@RunWith指示 spring-test 模块它应该创建一个ApplicationContext。这与使用现有的 Spring Test 支持没有什么不同。有关其他信息,请参阅 Spring Reference

@ContextConfiguration 指示 spring-test 用于创建 ApplicationContext 的配置。由于未指定配置,因此将尝试默认配置位置。这与使用现有的 Spring Test 支持没有什么不同。

备注:

  • Spring Security 使用 WithSecurityContextTestExecutionListener 挂钩到 Spring Test 支持,这将确保我们的测试由正确的用户运行。
  • 它通过在运行我们的测试之前填充 SecurityContextHolder 来做到这一点。
  • 如果您使用反应式方法安全性,您还需要填充 ReactiveSecurityContextHolder 的 ReactorContextTestExecutionListener。
  • 测试完成后,会清空 SecurityContextHolder。如果只需要 Spring Security 相关支持,可以将@ContextConfiguration 替换为@SecurityTestExecutionListeners。

请记住,我们向 HelloMessageService 添加了 @PreAuthorize 注释,因此它需要经过身份验证的用户才能调用它。如果我们运行以下测试,我们预计以下测试将通过:

@Test(expected = AuthenticationCredentialsNotFoundException.class)
public void getMessageUnauthenticated() {
messageService.getMessage();
}

1.2 @WithMockUser

问题是“我们如何才能最轻松地以特定用户身份运行测试?”答案是使用@WithMockUser。以下测试将以用户名“user”、密码“password”和角色“ROLE_USER”的用户身份运行。

Test
@WithMockUser
public void getMessageWithMockUser() {
String message = messageService.getMessage();
...
}

具体来说,以下是正确的:

  • 用户名“user”的用户不必存在,因为我们正在模拟用户
  • SecurityContext 中填充的 Authentication 类型为 UsernamePasswordAuthenticationToken
  • AuthenticationprincipalSpring Security 的 User 对象
  • 用户将拥有用户名“user”,密码“password”,并使用名为“ROLE_USER”的单个 GrantedAuthority。

我们的示例很好,因为我们能够利用很多默认值。如果我们想用不同的用户名运行测试怎么办?以下测试将使用用户名“customUser”运行。用户不需要实际存在。

@Test
@WithMockUser("customUsername")
public void getMessageWithMockUserCustomUsername() {
String message = messageService.getMessage();
...
}

我们还可以轻松自定义角色。例如,将使用用户名“admin”以及角色“ROLE_USER”和“ROLE_ADMIN”调用此测试。

@Test
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public void getMessageWithMockUserCustomUser() {
String message = messageService.getMessage();
...
}

如果我们不希望值自动以 ROLE_ 为前缀,我们可以利用 authority 属性。例如,将使用用户名“admin”和权限“USER”和“ADMIN”调用此测试。

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
String message = messageService.getMessage();
...
}

当然,将注释放在每个测试方法上可能有点乏味。我们可以将注释放在类级别,每个测试都将使用指定的用户。例如,以下将使用用户名“admin”、密码“password”以及角色“ROLE_USER”和“ROLE_ADMIN”的用户运行每个测试。

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {

如果您使用 JUnit 5 的 @Nested 测试支持,您还可以将注释放在封闭类上以应用于所有嵌套类。例如,以下将使用用户名“admin”、密码“password”以及两种测试方法的角色“ROLE_USER”和“ROLE_ADMIN”的用户运行每个测试。

@ExtendWith(SpringExtension.class)
@ContextConfiguration
@WithMockUser(username="admin",roles={"USER","ADMIN"})
public class WithMockUserTests {
@Nested
public class TestSuite1 {
// ... all test methods use admin user
}
@Nested
public class TestSuite2 {
// ... all test methods use admin user
}
}

默认情况下,SecurityContext 是在 TestExecutionListener.beforeTestMethod 事件期间设置的。这相当于发生在 JUnit 的 @Before 之前。您可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在 JUnit 的 @Before 之后但在调用测试方法之前。

@WithMockUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

1.3 @WithAnonymousUser

使用 @WithAnonymousUser 允许以匿名用户身份运行。当您希望以特定用户运行大部分测试,但又想以匿名用户身份运行一些测试时,这特别方便。例如,以下将使用 @WithMockUser 和以匿名用户身份匿名运行 withMockUser1 和 withMockUser2。

@RunWith(SpringJUnit4ClassRunner.class)
@WithMockUser
public class WithUserClassLevelAuthenticationTests {
@Test
public void withMockUser1() {
}
@Test
public void withMockUser2() {
}
@Test
@WithAnonymousUser
public void anonymous() throws Exception {
// override default to run as anonymous user
}
}

默认情况下,SecurityContext 是在 TestExecutionListener.beforeTestMethod 事件期间设置的。这相当于发生在JUnit@Before 之前。您可以将其更改为在TestExecutionListener.beforeTestExecution事件期间发生,该事件在 JUnit@Before 之后但在调用测试方法之前。

@WithAnonymousUser(setupBefore = TestExecutionEvent.TEST_EXECUTION)

1.4 @WithUserDetails

虽然 @WithMockUser是一种非常方便的入门方式,但它可能不适用于所有情况。例如,应用程序通常期望Authentication主体是特定类型。这样做是为了让应用程序可以将主体引用为自定义类型并减少 Spring Security 的耦合。

自定义主体通常由自定义 UserDetailsService 返回,该自定义 UserDetailsService 返回实现 UserDetails 和自定义类型的对象。对于这种情况,使用自定义 UserDetailsService 创建测试用户很有用。这正是@WithUserDetails 所做的。

假设我们有一个作为 bean 公开的 UserDetailsService,将使用 UsernamePasswordAuthenticationToken 类型的 Authentication 和从 UserDetailsService 返回的用户名为“user”的主体调用以下测试。

@Test
@WithUserDetails
public void getMessageWithUserDetails() {
String message = messageService.getMessage();
...
}

我们还可以自定义用于从 UserDetailsService 中查找用户的用户名。例如,此测试将使用从 UserDetailsService 返回且用户名为“customUsername”的主体运行。

@Test
@WithUserDetails
public void getMessageWithUserDetails() {
String message = messageService.getMessage();
...
}

我们还可以提供一个明确的 bean 名称来查找 UserDetailsService。例如,此测试将使用从 UserDetailsService 返回且用户名为“customUsername”的主体运行。

@Test
@WithUserDetails(value="customUsername", userDetailsServiceBeanName="myUserDetailsService")
public void getMessageWithUserDetailsServiceBeanName() {
String message = messageService.getMessage();
...
}

与@WithMockUser 一样,我们也可以将注释放在类级别,以便每个测试都使用相同的用户。然而,与@WithMockUser 不同的是,@WithUserDetails 要求用户存在。

默认情况下,SecurityContext 是在 TestExecutionListener.beforeTestMethod 事件期间设置的。这相当于发生在 JUnit 的 @Before 之前。您可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在 JUnit 的 @Before 之后但在调用测试方法之前。

@WithUserDetails(setupBefore = TestExecutionEvent.TEST_EXECUTION)

1.5 @WithSecurityContext

我们已经看到,如果我们不使用自定义身份验证主体,@WithMockUser 是一个很好的选择。接下来我们发现@WithUserDetails 将允许我们使用自定义 UserDetailsService 来创建我们的 Authentication principal,但需要用户存在。我们现在将看到一个允许最大灵活性的选项。

我们可以创建自己的注解,使用 @WithSecurityContext 来创建我们想要的任何 SecurityContext。例如,我们可能会创建一个名为 @WithMockCustomUser 的注解,如下所示:

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "rob";
String name() default "Rob Winch";
}

您可以看到@WithMockCustomUser 使用@WithSecurityContext 注释进行了注释。这是向 Spring Security Test 支持发出的信号,表明我们打算为测试创建一个 SecurityContext@WithSecurityContext 注释要求我们指定一个 ecurityContextFactory,它将根据我们的 @WithMockCustomUser 注释创建一个新的 SecurityContext。您可以在下面找到我们的 WithMockCustomUserSecurityContextFactory 实现:

public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails principal =
new CustomUserDetails(customUser.name(), customUser.username());
Authentication auth =
new UsernamePasswordAuthenticationToken(principal, "password", principal.getAuthorities());
context.setAuthentication(auth);
return context;
}
}

我们现在可以用我们的新注解来注解一个测试类或一个测试方法,Spring Security 的 WithSecurityContextTestExecutionListener 将确保我们的 SecurityContext 被适当地填充。

在创建自己的 WithSecurityContextFactory 实现时,很高兴知道它们可以使用标准 Spring 注释进行注释。例如,WithUserDetailsSecurityContextFactory 使用 @Autowired 注解来获取 UserDetailsService:

final class WithUserDetailsSecurityContextFactory
implements WithSecurityContextFactory<WithUserDetails> {
private UserDetailsService userDetailsService;
@Autowired
public WithUserDetailsSecurityContextFactory(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
public SecurityContext createSecurityContext(WithUserDetails withUser) {
String username = withUser.value();
Assert.hasLength(username, "value() must be non-empty String");
UserDetails principal = userDetailsService.loadUserByUsername(username);
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
return context;
}
}

默认情况下,SecurityContext 是在 TestExecutionListener.beforeTestMethod 事件期间设置的。这相当于发生在 JUnit@Before 之前。您可以将其更改为在 TestExecutionListener.beforeTestExecution 事件期间发生,该事件在JUnit@Before 之后但在调用测试方法之前。

@WithSecurityContext(setupBefore = TestExecutionEvent.TEST_EXECUTION)

1.6 Test Meta Annotations

如果您经常在测试中重用同一个用户,那么必须重复指定属性并不理想。例如,如果有许多与用户名“admin”以及角色 ROLE_USER 和 ROLE_ADMIN 的管理用户相关的测试,您必须编写:

@WithMockUser(username="admin",roles={"USER","ADMIN"})

我们可以使用元注释(meta annotation),而不是到处重复这一点。例如,我们可以创建一个名为 WithMockAdmin 的元注释:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value="rob",roles="ADMIN")
public @interface WithMockAdmin { }

现在我们可以像使用更详细的@WithMockUser一样使用@WithMockAdmin

元注释适用于上述任何测试注释。例如,这意味着我们也可以为 @WithUserDetails("admin") 创建一个元注释。

2. MockMvc Support

这章是基础内容,如果感觉太长可以跳转到第三章来看。

Spring Security 提供与 Spring MVC 测试的全面集成.参考文档

Spring MVC 测试框架,也称为 MockMvc,为测试 Spring MVC 应用程序提供支持。它执行完整的 Spring MVC 请求处理,但通过模拟请求和响应对象而不是正在运行的服务器。

MockMvc 可以单独用于执行请求和验证响应。它也可以通过 WebTestClient 使用,其中 MockMvc 被插入作为服务器来处理请求。WebTestClient 的优点是可以选择使用更高级别的对象而不是原始数据,并且能够切换到针对实时服务器的完整的端到端 HTTP 测试并使用相同的测试 API。

MockMvc 与 WebTestClient 对比

MockMVC 是模拟出一个 SpringMVC 的运行环境,并没有真正地运行一个完整的 Servlet 容器。

在测试类上加入@AutoConfigureMockMvc,然后依赖注入 MockMvc 对象即可。

1、支持测试时数据的回滚,这适用于测试一个涉及修改数据库数据的方法 2、由于 MockMVC 并没有真正的启动一个完整的 server 服务,故当要测试一些抛出可检异常并会返回 error page(4 开头的状态码),因为 error page 是由 Servlet container 提供的,所以 MockMVC 无法测试这些方法,他还是会返回 200 状态码。

WebTestClient 启动一个完整的 server。

在@SpringBootTest 注解上设置 webEnvironment 属性为 WebEnvironment.RANDOM_PORT 或 WebEnvironment.DEFINED_PORT 即可

// An highlighted block
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class RandomPortTestRestTemplateExampleTests {
@Test
void exampleTest(@Autowired TestRestTemplate restTemplate) {
String body = restTemplate.getForObject("/", String.class);
assertThat(body).isEqualTo("Hello World");
}
}

1、不支持回滚,因为运行测试方法和 server 服务不在同一个线程,所以无法控制 server 服务。 2、可以测试会抛出可检异常的方法。 3、TestRestTemplate 目前不支持测试 Patch 类型的 Http 请求,可以用 TestWebClient 代替。 4、可以将返回结果反序列化为特定的对象,MockMVC 无法做到,只能将返回的 Json 数据转化为 String。

2.1 Overview

您可以通过实例化控制器、注入依赖项并调用其方法来为 Spring MVC 编写简单的单元测试。但是,此类测试不验证请求映射、数据绑定、消息转换、类型转换、验证,也不涉及任何支持的 @InitBinder、@ModelAttribute 或 @ExceptionHandler 方法。

Spring MVC 测试框架,也称为 MockMvc,旨在为没有运行服务器的 Spring MVC 控制器提供更完整的测试。它通过调用 DispacherServlet 并从 spring-test 模块传递 Servlet API 的“模拟”实现来实现这一点,该模块在没有运行服务器的情况下复制完整的 Spring MVC 请求处理。

MockMvc 是一个服务器端测试框架,可让您使用轻量级和有针对性的测试来验证 Spring MVC 应用程序的大部分功能。您可以单独使用它来执行请求和验证响应,或者您也可以通过 WebTestClient API 使用它,并将 MockMvc 作为服务器插入以处理请求。

2.1.1 Static Imports

当直接使用 MockMvc 执行请求时,您需要 static imports:

  • MockMvcBuilders.*
  • MockMvcRequestBuilders.*
  • MockMvcResultMatchers.*
  • MockMvcResultHandlers.*

一个容易记住的方法是搜索 MockMvc*。通过 WebTestClient 使用 MockMvc 时,您不需要静态导入。WebTestClient 提供了一个流畅的 API,没有静态导入。

2.1.2 Setup Choices

MockMvc 可以通过以下两种方式之一进行设置。一种是直接指向要测试的控制器并以编程方式配置 Spring MVC 基础设施。第二个是指向 Spring 配置,其中包含 Spring MVC 和控制器基础设施。

要设置 MockMvc 以测试特定控制器,请使用以下命令:

class MyWebTests {
MockMvc mockMvc;
@BeforeEach
void setup() {
this.mockMvc = MockMvcBuilders.standaloneSetup(new AccountController()).build();
}
// ...
}

或者,您也可以在通过委托给同一个构建器的 WebTestClient 进行测试时使用此设置,如上所示。

要通过 Spring 配置设置 MockMvc,请使用以下命令:

@SpringJUnitWebConfig(locations = "my-servlet-context.xml")
class MyWebTests {
MockMvc mockMvc;
@BeforeEach
void setup(WebApplicationContext wac) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
}
// ...
}

或者,您也可以在通过委托给同一个构建器的 WebTestClient 进行测试时使用此设置,如上所示。

您应该使用哪个设置选项?

2.1.3 Setup Features

无论您使用哪个 MockMvc 构建器,所有 MockMvcBuilder 实现都提供了一些常见且非常有用的功能。例如,您可以为所有请求声明 Accept header,并期望状态为 200 以及所有响应中的 Content-Type header,如下所示:

// static import of MockMvcBuilders.standaloneSetup
MockMvc mockMvc = standaloneSetup(new MusicController())
.defaultRequest(get("/").accept(MediaType.APPLICATION_JSON))
.alwaysExpect(status().isOk())
.alwaysExpect(content().contentType("application/json;charset=UTF-8"))
.build();

此外,第三方框架(和应用程序)可以预先打包设置说明,例如 MockMvcConfigurer 中的说明。Spring 框架有一个这样的内置实现,它有助于跨请求保存和重用 HTTP 会话。您可以按如下方式使用它:

// static import of SharedHttpSessionConfigurer.sharedHttpSession
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
.apply(sharedHttpSession())
.build();
// Use mockMvc to perform requests...

有关所有 MockMvc 构建器功能的列表,请参阅 ConfigurableMockMvcBuilder 的 javadoc,或使用 IDE 探索可用选项。

2.1.4 Performing Requests

本节展示如何单独使用 MockMvc 来执行请求和验证响应。如果通过 WebTestClient 使用 MockMvc,请参阅有关编写测试的相应部分。

要执行使用任何 HTTP 方法的请求,如下例所示:

// static import of MockMvcRequestBuilders.*
mockMvc.perform(post("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON));

您还可以执行内部使用 MockMultipartHttpServletRequest 的文件上传请求,这样就不会实际解析多部分请求。相反,您必须将其设置为类似于以下示例:

mockMvc.perform(multipart("/doc").file("a1", "ABC".getBytes("UTF-8")));

您可以在 URI 模板样式中指定查询参数,如以下示例所示:

mockMvc.perform(get("/hotels?thing={thing}", "somewhere"));

您还可以添加代表查询或表单参数的 Servlet 请求参数,如以下示例所示:

mockMvc.perform(get("/hotels").param("thing", "somewhere"));

如果应用程序代码依赖于 Servlet 请求参数并且没有明确地检查查询字符串(通常是这种情况),您使用哪个选项都没有关系。但是请记住,URI 模板提供的查询参数被解码,而通过 param(...) 方法提供的请求参数预计已经被解码。

在大多数情况下,最好将上下文路径和 Servlet 路径留在请求 URI 之外。如果您必须使用完整的请求 URI 进行测试,请务必相应地设置 contextPath 和 servletPath 以便请求映射工作,如以下示例所示:

mockMvc.perform(get("/app/main/hotels/{id}").contextPath("/app").servletPath("/main"))

在前面的示例中,为每个执行的请求设置 contextPath 和 servletPath 会很麻烦。相反,您可以设置默认请求属性,如以下示例所示:

class MyWebTests {
MockMvc mockMvc;
@BeforeEach
void setup() {
mockMvc = standaloneSetup(new AccountController())
.defaultRequest(get("/")
.contextPath("/app").servletPath("/main")
.accept(MediaType.APPLICATION_JSON)).build();
}
}

上述属性会影响通过 MockMvc 实例执行的每个请求。实例。如果在给定的请求上也指定了相同的属性,它会覆盖默认值。这就是为什么默认请求中的 HTTP 方法和 URI 无关紧要的原因,因为必须在每个请求中指定它们。

2.1.5 Defining Expectations

您可以通过在执行请求后附加一个或多个 andExpect(..) 调用来定义期望,如以下示例所示。一旦一个期望失败,就不会断言其他期望。

// static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*
mockMvc.perform(get("/accounts/1")).andExpect(status().isOk());

您可以通过在执行请求后附加 andExpectAll(..) 来定义多个期望,如以下示例所示。与 andExpect(..) 相比,andExpectAll(..) 保证所有提供的期望都将被断言,并且所有失败都将被跟踪和报告。

// static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*
mockMvc.perform(get("/accounts/1")).andExpectAll(
status().isOk(),
content().contentType("application/json;charset=UTF-8"));

MockMvcResultMatchers.* 提供了许多期望,其中一些进一步嵌套了更详细的期望。

预期分为两大类。第一类断言验证响应的属性(例如,响应状态、headers 和内容)。这些是要断言的最重要的结果。

第二类断言超出了响应。这些断言让您可以检查 Spring MVC 的特定方面,例如哪个控制器方法处理了请求,是否引发和处理了异常,模型的内容是什么,选择了什么视图,添加了哪些 flash 属性等等。它们还允许您检查 Servlet 的特定方面,例如请求和会话属性。

以下测试断言绑定或验证失败:

mockMvc.perform(post("/persons"))
.andExpect(status().isOk())
.andExpect(model().attributeHasErrors("person"));

很多时候,在编写测试时,转储执行请求的结果很有用。您可以这样做,其中 print() 是从 MockMvcResultHandlers 的静态导入:

mockMvc.perform(post("/persons"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(model().attributeHasErrors("person"));

只要请求处理不会导致未处理的异常,print() 方法就会将所有可用的结果数据打印到 System.out。还有一个 log() 方法和 print() 方法的两个附加变体,一个接受 OutputStream 和一个接受 Writer。

例如,调用 print(System.err) 将结果数据打印到 System.err,而调用 print(myWriter) 将结果数据打印到自定义写入器。如果您希望记录而不是打印结果数据,可以调用 log() 方法,该方法将结果数据记录为 org.springframework.test.web.servlet.result 日志记录类别下的单个 DEBUG 消息。

在某些情况下,您可能希望直接访问结果并验证无法通过其他方式验证的内容。这可以通过在所有其他期望之后附加 .andReturn() 来实现,如以下示例所示:

MvcResult mvcResult = mockMvc.perform(post("/persons")).andExpect(status().isOk()).andReturn();
// ...

如果所有测试都重复相同的期望,您可以在构建 MockMvc 实例时设置一次共同期望,如以下示例所示:

standaloneSetup(new SimpleController())
.alwaysExpect(status().isOk())
.alwaysExpect(content().contentType("application/json;charset=UTF-8"))
.build()

请注意,始终应用共同的期望,并且在不创建单独的 MockMvc 实例的情况下无法覆盖。

当 JSON 响应内容包含使用 Spring HATEOAS 创建的超媒体链接时,您可以使用 JsonPath 表达式验证生成的链接,如以下示例所示:

mockMvc.perform(get("/people").accept(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.links[?(@.rel == 'self')].href").value("http://localhost:8080/people"));

当 XML 响应内容包含使用 Spring HATEOAS 创建的超媒体链接时,您可以使用 XPath 表达式验证生成的链接:

Map<String, String> ns = Collections.singletonMap("ns", "http://www.w3.org/2005/Atom");
mockMvc.perform(get("/handle").accept(MediaType.APPLICATION_XML))
.andExpect(xpath("/person/ns:link[@rel='self']/@href", ns).string("http://localhost:8080/people"));

2.1.6 Async Requests

本节展示如何单独使用 MockMvc 来测试异步请求处理。如果通过 WebTestClient 使用 MockMvc,则不需要做任何特殊的事情来使异步请求工作,因为 WebTestClient 会自动执行本节中描述的操作。

Spring MVC 支持的 Servlet 3.0 异步请求通过退出 Servlet 容器线程并允许应用程序异步计算响应来工作,之后进行异步调度以完成对 Servlet 容器线程的处理。

在 Spring MVC Test 中,可以通过先断言生成的异步值,然后手动执行异步调度,最后验证响应来测试异步请求。以下是返回 DeferredResult、Callable 或反应类型(如 Reactor Mono)的控制器方法的示例测试:

// static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*
@Test
void test() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/path"))
.andExpect(status().isOk())
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult("body"))
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().string("body"));
}

检查响应状态仍然不变

异步处理必须已经开始

等待并断言异步结果

手动执行异步调度(因为没有正在运行的容器)

验证最终响应

2.1.7 Streaming Responses

测试流响应(例如服务器发送事件)的最佳方法是通过 WebTestClient ,它可以用作测试客户端连接到 MockMvc 实例,以便在没有运行服务器的情况下在 Spring MVC 控制器上执行测试。例如:

WebTestClient client = MockMvcWebTestClient.bindToController(new SseController()).build();
FluxExchangeResult<Person> exchangeResult = client.get()
.uri("/persons")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType("text/event-stream")
.returnResult(Person.class);
// Use StepVerifier from Project Reactor to test the streaming response
StepVerifier.create(exchangeResult.getResponseBody())
.expectNext(new Person("N0"), new Person("N1"), new Person("N2"))
.expectNextCount(4)
.consumeNextWith(person -> assertThat(person.getName()).endsWith("7"))
.thenCancel()
.verify();

WebTestClient 还可以连接到实时服务器并执行完整的端到端集成测试。Spring Boot 也支持这一点,您可以在其中测试正在运行的服务器。

2.1.8 Filter Registrations

在设置 MockMvc 实例时,可以注册一个或多个 Servlet Filter 实例,如下例所示:

mockMvc = standaloneSetup(new PersonController()).addFilters(new CharacterEncodingFilter()).build();

已注册的过滤器通过 spring-test 中的 MockFilterChain 调用,最后一个过滤器委托给 DispatcherServlet。

2.1.9 MockMvc vs End-to-End Tests

MockMVc 建立在 spring-test 模块的 Servlet API 模拟实现之上,不依赖于正在运行的容器。因此,与使用实际客户端和实时服务器运行的完整端到端集成测试相比,存在一些差异。

考虑这一点的最简单方法是从空白的 MockHttpServletRequest 开始。无论您添加什么,请求都会变成什么。可能会让你大吃一惊的是,默认情况下没有上下文路径;没有 jsessionid cookie;没有转发、错误或异步调度;因此,没有实际的 JSP 渲染。相反,“转发”和“重定向”的 URL 保存在 MockHttpServletResponse 中,并且可以根据期望进行断言。

这意味着,如果您使用 JSP,您可以验证请求被转发到的 JSP 页面,但不会呈现 HTML。换句话说,不调用 JSP。但是请注意,所有其他不依赖转发的渲染技术,例如 Thymeleaf 和 Freemarker,按预期将 HTML 渲染到响应正文。通过@ResponseBody 方法渲染 JSON、XML 和其他格式也是如此。

或者,您可以考虑使用 @SpringBootTest 提供来自 Spring Boot 的完整端到端集成测试支持。请参阅 Spring Boot 参考指南。

每种方法都有优点和缺点。Spring MVC Test 中提供的选项是从经典单元测试到完全集成测试的不同阶段。可以肯定的是,Spring MVC Test 中的选项都不属于经典单元测试的范畴,但他们更接近它。例如,您可以通过将模拟服务注入控制器来隔离 Web 层,在这种情况下,您仅通过 DispatcherServlet 测试 Web 层,但使用实际的 Spring 配置,因为您可能会独立于其上的层来测试数据访问层。此外,您可以使用独立设置,一次只关注一个 controller 并手动提供使其工作所需的配置。

使用 Spring MVC 测试时的另一个重要区别是,从概念上讲,此类测试是服务器端的,因此您可以检查使用了什么处理程序,如果使用 HandlerExceptionResolver 处理异常,模型的内容是什么,存在哪些绑定错误以及其他详细信息。这意味着更容易编写期望,因为服务器不是一个不透明的盒子,就像通过实际的 HTTP 客户端测试它时一样。这通常是经典单元测试的一个优势:它更容易编写、推理和调试,但不能取代完全集成测试的需要。同时,重要的是不要忽视响应是最重要的检查这一事实。简而言之,即使在同一个项目中,这里也有多种风格和测试策略的空间。

2.1.10 Further Examples

该框架自己的测试包括许多示例测试,旨在展示如何单独使用 MockMvc 或通过 WebTestClient 使用。浏览这些示例以获取更多想法。

2.2 HtmlUnit 集成

Spring 提供了 MockMvc 和 HtmlUnit 之间的集成。这简化了在使用基于 HTML 的视图时执行端到端测试。这种集成让您:

  • 使用 HtmlUnitWebDriverGeb 等工具轻松测试 HTML 页面,无需部署到 Servlet 容器。
  • 在页面内测试 JavaScript。
  • 或者,使用模拟服务进行测试以加快测试速度。
  • 在容器内端到端测试和容器外集成测试之间共享逻辑。

备注

MockMvc 使用不依赖于 Servlet 容器的模板技术(例如,Thymeleaf、FreeMarker 等),但它不适用于 JSP,因为它们依赖于 Servlet 容器。

2.2.1 为什么要集成 HtmlUnit?

想到的最明显的问题是“我为什么需要这个?”

最好通过探索一个非常基本的示例应用程序来找到答案。假设您有一个 Spring MVC Web 应用程序,它支持对 Message 对象的 CRUD 操作。该应用程序还支持对所有消息进行分页。你将如何测试它?

使用 Spring MVC Test,我们可以轻松测试是否能够创建 Message,如下所示:

MockHttpServletRequestBuilder createMessage = post("/messages/")
.param("summary", "Spring Rocks")
.param("text", "In case you didn't know, Spring Rocks!");
mockMvc.perform(createMessage)
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/messages/123"));

如果我们想测试让我们创建消息的表单视图怎么办?例如,假设我们的表单如下所示:

<form id="messageForm" action="/messages/" method="post">
<div class="pull-right"><a href="/messages/">Messages</a></div>
<label for="summary">Summary</label>
<input type="text" class="required" id="summary" name="summary" value="" />
<label for="text">Message</label>
<textarea id="text" name="text"></textarea>
<div class="form-actions">
<input type="submit" value="Create" />
</div>
</form>

我们如何确保我们的表单产生正确的请求来创建新消息?初步的尝试可能类似于以下内容:

mockMvc.perform(get("/messages/form"))
.andExpect(xpath("//input[@name='summary']").exists())
.andExpect(xpath("//textarea[@name='text']").exists());

这个测试有一些明显的缺点。如果我们更新 controller 以使用参数 message 而不是 text,我们的表单测试将继续通过,即使 HTML 表单与 controller 不同步。为了解决这个问题,我们可以结合我们的两个测试,如下所示:

String summaryParamName = "summary";
String textParamName = "text";
mockMvc.perform(get("/messages/form"))
.andExpect(xpath("//input[@name='" + summaryParamName + "']").exists())
.andExpect(xpath("//textarea[@name='" + textParamName + "']").exists());
MockHttpServletRequestBuilder createMessage = post("/messages/")
.param(summaryParamName, "Spring Rocks")
.param(textParamName, "In case you didn't know, Spring Rocks!");
mockMvc.perform(createMessage)
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/messages/123"));

这会降低我们的测试错误通过的风险,但是仍然存在一些问题:

  • 如果我们的页面上有多个表单怎么办?诚然,我们可以更新 XPath 表达式,但随着我们考虑更多因素,它们会变得更加复杂:字段类型是否正确?字段是否启用?等等。
  • 另一个问题是我们正在做的工作是我们预期的两倍。我们必须首先验证视图,然后使用我们刚刚验证的相同参数提交视图。理想情况下,这可以一次完成。
  • 最后,我们仍然无法解释一些事情。例如,如果表单有我们希望测试的 JavaScript 验证怎么办?

总体问题是测试网页不涉及单个交互。相反,它是用户如何与网页交互以及该网页如何与其他资源交互的组合。例如,表单视图的结果被用作用户创建消息的输入。此外,我们的表单视图可能会使用影响页面行为的其他资源,例如 JavaScript 验证。

集成测试的缓解措施?

为了解决前面提到的问题,我们可以执行端到端的集成测试,但这有一些缺点。考虑测试让我们浏览消息的视图。我们可能需要以下测试:

  • 当消息为空时,我们的页面是否向用户显示通知以指示没有可用的结果?
  • 我们的页面是否正确显示了一条消息?
  • 我们的页面是否正确支持分页?

要设置这些测试,我们需要确保我们的数据库包含正确的消息。这导致了一些额外的挑战:

  • 确保正确的消息在数据库中可能很冗长。 (考虑外键约束。)
  • 测试可能会变慢,因为每个测试都需要确保数据库处于正确的状态。
  • 由于我们的数据库需要处于特定状态,我们不能并行运行测试。
  • 对自动生成的 ID、时间戳等项目执行断言可能很困难。

这些挑战并不意味着我们应该完全放弃端到端集成测试。相反,我们可以通过重构我们的详细测试以使用运行速度更快的模拟服务来减少端到端集成测试的数量,更可靠,而且没有副作用。然后,我们可以实施少量真正的端到端集成测试,以验证简单的工作流程,以确保一切正常工作。

进入 HtmlUnit 集成

那么,我们如何在测试页面交互和仍然在测试套件中保持良好性能之间取得平衡呢?答案是:“通过将 MockMvc 与 HtmlUnit 集成。”

HtmlUnit 集成选项

当您想将 MockMvc 与 HtmlUnit 集成时,您有多种选择:

  • MockMvc and HtmlUnit:如果要使用原始 HtmlUnit 库,请使用此选项。
  • MockMvc and WebDriver: 使用此选项可以简化集成和端到端测试之间的开发和重用代码。
  • MockMvc and Geb:如果您想使用 Groovy 进行测试、简化开发以及在集成和端到端测试之间重用代码,请使用此选项。

2.2.2 MockMvc and HtmlUnit

本节介绍如何集成 MockMvc 和 HtmlUnit。如果要使用原始 HtmlUnit 库,请使用此选项。

推荐使用 WebDriver,所以忽略这一章节。

2.2.3 MockMvc 和 WebDriver

在本节中,我们使用 Selenium WebDriver 来使事情变得更简单。

为什么选择 WebDriver 和 MockMvc?

我们已经可以使用 HtmlUnit 和 MockMvc,为什么还要使用 WebDriver?Selenium WebDriver 提供了一个非常优雅的 API,让我们可以轻松地组织我们的代码。为了更好地展示它是如何工作的,我们将在本节中探讨一个示例。

备注:尽管是 Selenium 的一部分,但 WebDriver 不需要 Selenium 服务器来运行您的测试。

假设我们需要确保正确创建消息。测试包括查找 HTML 表单输入元素、填写它们并做出各种断言。

这种方法会导致许多单独的测试,因为我们也想测试错误条件。例如,如果我们只填写表格的一部分,我们希望确保我们得到一个错误。如果我们填写整个表格,那么新创建的消息应该会在之后显示。

如果其中一个字段被命名为“summary”,我们可能会在测试中的多个位置重复出现类似于以下内容的内容:

HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary");
summaryInput.setValueAttribute(summary);

那么如果我们把 id 改成 smmry 会发生什么呢?这样做会迫使我们更新所有测试以纳入此更改。这违反了 DRY 原则,因此我们最好将这段代码提取到自己的方法中,如下所示:

public HtmlPage createMessage(HtmlPage currentPage, String summary, String text) {
setSummary(currentPage, summary);
// ...
}
public void setSummary(HtmlPage currentPage, String summary) {
HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary");
summaryInput.setValueAttribute(summary);
}

这样做可以确保我们在更改 UI 时不必更新所有测试。

我们甚至可以更进一步,将这个逻辑放在代表我们当前所在的 HtmlPage 的 Object 中,如下例所示:

public class CreateMessagePage {
final HtmlPage currentPage;
final HtmlTextInput summaryInput;
final HtmlSubmitInput submit;
public CreateMessagePage(HtmlPage currentPage) {
this.currentPage = currentPage;
this.summaryInput = currentPage.getHtmlElementById("summary");
this.submit = currentPage.getHtmlElementById("submit");
}
public <T> T createMessage(String summary, String text) throws Exception {
setSummary(summary);
HtmlPage result = submit.click();
boolean error = CreateMessagePage.at(result);
return (T) (error ? new CreateMessagePage(result) : new ViewMessagePage(result));
}
public void setSummary(String summary) throws Exception {
summaryInput.setValueAttribute(summary);
}
public static boolean at(HtmlPage page) {
return "Create Message".equals(page.getTitleText());
}
}

以前,这种模式被称为页面对象模式。虽然我们当然可以使用 HtmlUnit 做到这一点,但 WebDriver 提供了一些我们将在以下部分中探讨的工具,以使这种模式更容易实现。

设置 MockMvc 和 WebDriver

要将 Selenium WebDriverSpring MVC 测试框架一起使用,请确保您的项目包含对 org.seleniumhq.selenium:selenium-htmlunit-driver 的测试依赖项。

我们可以使用 MockMvcHtmlUnitDriverBuilder 轻松创建与 MockMvc 集成的 Selenium WebDriver,如下例所示:

WebDriver driver;
@BeforeEach
void setup(WebApplicationContext context) {
driver = MockMvcHtmlUnitDriverBuilder
.webAppContextSetup(context)
.build();
}

这是一个使用 MockMvcHtmlUnitDriverBuilder 的简单示例。有关更高级的用法,请参阅 Advanced MockMvcHtmlUnitDriverBuilder

前面的示例确保任何引用 localhost 作为服务器的 URL 都被定向到我们的 MockMvc 实例,而无需真正的 HTTP 连接。正常情况下,使用网络连接请求任何其他 URL。这让我们可以轻松地测试 CDN 的使用。

MockMvc 和 WebDriver 用法

现在我们可以像往常一样使用 WebDriver,但无需将应用程序部署到 Servlet 容器。例如,我们可以请求视图创建一条带有以下内容的消息:

CreateMessagePage page = CreateMessagePage.to(driver);

然后我们可以填写表单并提交以创建消息,如下所示:

ViewMessagePage viewMessagePage =
page.createMessage(ViewMessagePage.class, expectedSummary, expectedText);

这通过利用页面对象模式改进了我们的 HtmlUnit 测试的设计。正如我们在为什么使用 WebDriver 和 MockMvc? 中提到的,我们可以将页面对象模式与 HtmlUnit 一起使用,但使用 WebDriver 会更容易。思考以下 CreateMessagePage 实现:

public class CreateMessagePage
extends AbstractPage {
private WebElement summary;
private WebElement text;
@FindBy(css = "input[type=submit]")
private WebElement submit;
public CreateMessagePage(WebDriver driver) {
super(driver);
}
public <T> T createMessage(Class<T> resultPage, String summary, String details) {
this.summary.sendKeys(summary);
this.text.sendKeys(details);
this.submit.click();
return PageFactory.initElements(driver, resultPage);
}
public static CreateMessagePage to(WebDriver driver) {
driver.get("http://localhost:9990/mail/messages/form");
return PageFactory.initElements(driver, CreateMessagePage.class);
}
}

CreateMessagePage 继承了 AbstractPage抽象类。我们不详述 AbstractPage,但总而言之,它包含我们所有页面的通用功能。例如,如果我们的应用程序有导航栏、全局错误消息和其他 功能,我们可以将此逻辑放在共享位置。

我们所在的 HTML 页面的每个部分都有一个成员变量 感兴趣的。

  • 这些是 WebElement 类型。WebDriver 的 PageFactory 让我们自动解析每个 WebElement,不像通过HtmlUnit 版本的CreateMessagePage写大量代码来实现 。
  • PageFactory#initElements(WebDriver,Class<T>)方法通过使用字段名称并查找它来自动解析每个 WebElement 通过 HTML 页面中元素的 id 或名称。

我们可以使用 @FindBy解 覆盖默认查找行为。我们的示例展示了如何使用 @FindBy 使用 css 选择器 (input[type=submit]) 查找我们的提交按钮的注释。

最后,我们可以验证是否成功创建了一条新消息。以下断言使用 AssertJ 断言库:

assertThat(viewMessagePage.getMessage()).isEqualTo(expectedMessage);
assertThat(viewMessagePage.getSuccess()).isEqualTo("Successfully created a new message");

我们可以看到 ViewMessagePage 允许我们与自定义域模型进行交互。例如,它公开了一个返回 Message 对象的方法:

public Message getMessage() throws ParseException {
Message message = new Message();
message.setId(getId());
message.setCreated(getCreated());
message.setSummary(getSummary());
message.setText(getText());
return message;
}

然后我们可以在断言中使用丰富的域对象。

最后,我们一定不要忘记在测试完成时关闭 WebDriver 实例,如下所示:

@AfterEach
void destroy() {
if (driver != null) {
driver.close();
}
}

有关使用 WebDriver 的更多信息,请参阅 WebDriver documentation.

MockMvcHtmlUnitDriverBuilder 高级用法

在到目前为止的示例中,我们以最简单的方式使用了 MockMvcHtmlUnitDriverBuilder,即基于 Spring TestContext Framework 为我们加载的 WebApplicationContext 构建 WebDriver。此处重复此方法,如下:

WebDriver driver;
@BeforeEach
void setup(WebApplicationContext context) {
driver = MockMvcHtmlUnitDriverBuilder
.webAppContextSetup(context)
.build();
}

我们还可以指定额外的配置选项,如下:

WebDriver driver;
@BeforeEach
void setup() {
driver = MockMvcHtmlUnitDriverBuilder
// 演示应用 MockMvcConfigurer (Spring Security)
.webAppContextSetup(context, springSecurity())
// 仅用于说明 - 默认为“”
.contextPath("")
// 默认情况下,MockMvc 仅用于 localhost;
// 以下内容也将使用 MockMvc for example.com 和 example.org
.useMockMvcForHosts("example.com","example.org")
.build();
}

作为替代方案,我们可以通过单独配置 MockMvc 实例并将其提供给 MockMvcHtmlUnitDriverBuilder 来执行完全相同的设置,如下所示:

MockMvc mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
driver = MockMvcHtmlUnitDriverBuilder
.mockMvcSetup(mockMvc)
// for illustration only - defaults to ""
.contextPath("")
// By default MockMvc is used for localhost only;
// the following will use MockMvc for example.com and example.org as well
.useMockMvcForHosts("example.com","example.org")
.build();

这更加冗长,但是,通过使用 MockMvc 实例构建 WebDriver,我们可以轻松获得 MockMvc 的全部功能。

有关创建 MockMvc 实例的其他信息,请参阅 Setup Choices

2.2.4 MockMvc 和 Geb

这以章节可以跳过,因为不使用 Geb。

3. MockMvc Setup

为了将 Spring Security Spring MVC 测试一起使用,有必要将 Spring Security FilterChainProxy 添加为过滤器。还需要添加 Spring Security TestSecurityContextHolderPostProcessor 以支持在 Spring MVC Test with Annotations 中以用户身份运行。这可以使用 Spring Security 的 SecurityMockMvcConfigurers.springSecurity() 来完成。例如:

import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.*;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = SecurityConfig.class)
@WebAppConfiguration
public class CsrfShowcaseTests {
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Before
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
...

SecurityMockMvcConfigurers.springSecurity() 将执行我们将 Spring SecuritySpring MVC 测试集成所需的所有初始设置

4. RequestPostProcessors

为了使用 Spring Security 的 RequestPostProcessor 实现,请确保使用以下静态导入:

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;

4.1 模拟 Users

在 Spring MVC 测试中以用户身份运行测试,

通常希望以特定用户的身份运行测试。填充用户有两种简单的方法:

  • 使用 RequestPostProcessor :在 Spring MVC 测试中以用户身份运行
  • 使用 注释: 在 Spring MVC 测试中以用户身份运行

4.1.1 使用 RequestPostProcessor

有许多选项可用于将用户与当前的 HttpServletRequest 相关联。例如,以下将作为用户(不需要存在)运行,用户名为“user”,密码为“password”,角色为“ROLE_USER”:

Note:

  • 该支持通过将用户关联到 HttpServletRequest 来工作。要将请求与 SecurityContextHolder 相关联,您需要确保 SecurityContextPersistenceFilter 与 MockMvc 实例相关联。有几种方法可以做到这一点:
    • 调用 apply(springSecurity())
    • 将 Spring Security 的 FilterChainProxy 添加到 MockMvc
    • 使用 MockMvcBuilders.standaloneSetup 时,手动将 SecurityContextPersistenceFilter 添加到 MockMvc 实例可能有意义
① 使用 user 对象
mvc.perform(get("/").with(user("user")))

您可以轻松进行自定义。例如,以下将作为用户(不需要存在)运行,用户名为“admin”,密码为“pass”,角色为“ROLE_USER”和“ROLE_ADMIN”。

mvc.perform(get("/admin").with(user("admin").password("pass").roles("USER","ADMIN")))
② 使用 UserDetails

如果您有一个想要使用的自定义 UserDetails,您也可以轻松地指定它。例如,以下将使用指定的 UserDetails(不需要存在)以使用具有指定 UserDetails 的主体的 UsernamePasswordAuthenticationToken 运行:

mvc.perform(get("/").with(user(userDetails)))
③ 使用匿名用户

您可以使用以下命令以匿名用户身份运行:

mvc.perform(get("/").with(anonymous()))

如果您使用默认用户运行并希望以匿名用户身份处理一些请求,这将特别有用。

④ 使用 authentication

如果您想要自定义身份验证(不需要存在),您可以使用以下方法:

mvc.perform(get("/").with(authentication(authentication)))
⑤ 使用 SecurityContext

您甚至可以使用以下内容自定义 SecurityContext:

mvc.perform(get("/").with(securityContext(securityContext)))
⑥ 定义成默认用户

我们还可以通过使用 MockMvcBuilders 的默认请求来确保每个请求都以特定用户身份运行。例如,以下将作为用户(不需要存在)运行,用户名为“admin”,密码为“password”,角色为“ROLE_ADMIN”:

mvc = MockMvcBuilders
.webAppContextSetup(context)
.defaultRequest(get("/").with(user("user").roles("ADMIN")))
.apply(springSecurity())
.build();
⑦ 代码重构

如果您发现在许多测试中都使用同一个用户,建议将用户移动到一个方法。例如,您可以在自己的名为 CustomSecurityMockMvcRequestPostProcessors 的类中指定以下内容:

public static RequestPostProcessor rob() {
return user("rob").roles("ADMIN");
}

现在您可以在 CustomSecurityMockMvcRequestPostProcessors 上执行静态导入并在您的测试中使用它:

import static sample.CustomSecurityMockMvcRequestPostProcessors.*;
...
mvc.perform(get("/").with(rob()))

4.1.2 使用 注释

这里简单说一下,详细内容可以看看前几章的说明。

作为使用 RequestPostProcessor 创建用户的替代方法,您可以使用测试方法安全中描述的注释。例如,以下将使用用户名“user”、密码“password”和角色“ROLE_USER”的用户运行测试:

@Test
@WithMockUser
public void requestProtectedUrlWithUser() throws Exception {
mvc.perform(get("/"))
...
}

或者,以下将使用用户名“user”、密码“password”和角色“ROLE_ADMIN”的用户运行测试:

@Test
@WithMockUser(roles="ADMIN")
public void requestProtectedUrlWithUser() throws Exception {
mvc.perform(get("/"))
...
}

4.2 模拟 CSRF

在测试任何非安全的 HTTP 方法并使用 Spring Security 的 CSRF 保护时,您必须确保在请求中包含有效的 CSRF Token。要将有效的 CSRF 令牌指定为请求参数,请使用 CSRF RequestPostProcessor,如下所示:

mvc.perform(post("/").with(csrf()))

如果您愿意,可以在标头中包含 CSRF 令牌:

mvc.perform(post("/").with(csrf().asHeader()))

您还可以使用以下方法测试提供无效的 CSRF 令牌:

mvc.perform(post("/").with(csrf().useInvalidToken()))

4.3 模拟 Form Login

测试基于表单的身份验证,您可以使用 Spring Security 的测试支持轻松创建请求以测试基于表单的身份验证。例如,以下 formLogin RequestPostProcessor 将使用用户名“user”、密码“password”和有效的 CSRF 令牌向“/login”提交 POST:

mvc
.perform(formLogin())

自定义请求很容易。例如,以下将使用用户名“admin”、密码“pass”和有效的 CSRF 令牌向“/auth”提交 POST:

mvc
.perform(formLogin("/auth").user("admin").password("pass"))

我们还可以自定义包含用户名和密码的参数名称。例如,这是修改为在 HTTP 参数“u”中包含用户名和在 HTTP 参数“p”中包含密码的上述请求。

mvc
.perform(formLogin("/auth").user("u","admin").password("p","pass"))

4.4 模拟 HTTP Basic

测试 HTTP Basic 身份验证,虽然始终可以使用 HTTP Basic 进行身份验证,但记住标头名称、格式和编码值有点乏味。现在这可以使用 Spring Security 的 httpBasic RequestPostProcessor 来完成。例如,下面的片段:

mvc
.perform(get("/").with(httpBasic("user","password")))

将尝试使用 HTTP Basic 通过确保在 HTTP 请求中填充以下 Request 来验证具有用户名“user”和密码“password”的用户:

Authorization: Basic dXNlcjpwYXNzd29yZA==

4.5 模拟 OAuth2

对于 OAuth 2.0,前面介绍的相同原则仍然适用:最终,这取决于您的测试方法期望在 SecurityContextHolder 中的内容。

例如,对于如下所示的控制器:

@GetMapping("/endpoint")
public String foo(Principal user) {
return user.getName();
}

它没有任何特定于 OAuth2 的内容,因此您可能只需使用 @WithMockUser 就可以了。

但是,如果您的控制器绑定到 Spring Security 的 OAuth 2.0 支持的某些方面,如下所示:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OidcUser user) {
return user.getIdToken().getSubject();
}

那么 Spring Security 的测试支持就可以派上用场了。

4.5.1 测试 OIDC 登录

使用 Spring MVC Test 测试上述方法需要使用授权服务器模拟某种授权流。当然,这将是一项艰巨的任务,这就是 Spring Security 支持删除此样板的原因。

① 模拟登陆

例如,我们可以告诉 Spring Security 使用 oidcLogin RequestPostProcessor 包含一个默认的OidcUser,如下所示:

mvc
.perform(get("/endpoint").with(oidcLogin()));

这将做的是使用 OidcUser 配置关联的 MockHttpServletRequest,其中包括简单的 OidcIdTokenOidcUserInfo 和授予权限的集合。

验证 Claim

具体来说,它将包含一个 OidcIdToken,其中一个 sub claim 设置为 user:

assertThat(user.getIdToken().getClaim("sub")).isEqualTo("user");

一个没有 claims 的 OidcUserInfo:

assertThat(user.getUserInfo().getClaims()).isEmpty();

验证权限

以及权限集合,只有一个权限SCOPE_read

assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

Spring Security 做了必要的工作来确保 OidcUser 实例可用于 @AuthenticationPrincipal 注释。

此外,它还将 OidcUser 链接到 OAuth2AuthorizedClient 的简单实例,并将其存入模拟 OAuth2AuthorizedClientRepository。如果您的测试使用 @RegisteredOAuth2AuthorizedClient 注释,这会很方便。

② 配置 Authorities

在许多情况下,您的方法受到过滤器或方法安全性的保护,并且需要您的身份验证具有某些授予权限才能允许请求。

在这种情况下,您可以使用 authority() 方法提供所需的授权权限:

mvc
.perform(get("/endpoint")
.with(oidcLogin()
.authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
)
);
③ 配置 Claims

虽然授予的权限在所有 Spring Security 中都很常见,但我们在 OAuth 2.0 的情况下也有 claims。

例如,假设您有一个 user_id claim,表明系统中的用户 id。您可以在控制器中像这样访问它:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OidcUser oidcUser) {
String userId = oidcUser.getIdToken().getClaim("user_id");
// ...
}

在这种情况下,您需要使用 idToken() 方法指定该 claim:

mvc
.perform(get("/endpoint")
.with(oidcLogin()
.idToken(token -> token.claim("user_id", "1234"))
)
);

因为 OidcUser 从 OidcIdToken 收集其声明。

④ 附加配置

还有其他方法可以进一步配置身份验证;它仅取决于您的控制器期望的数据:

  • userInfo(OidcUserInfo.Builder) - 用于配置 OidcUserInfo 实例
  • clientRegistration(ClientRegistration) - 用于使用给定的 ClientRegistration 配置关联的 OAuth2AuthorizedClient
  • oidcUser(OidcUser) - 用于配置完整的 OidcUser 实例

如果您: 1. 拥有自己的 OidcUser 实现,或者 2. 需要更改 name 属性,则最后一个很方便

例如,假设您的授权服务器在 user_name claim 中发送 principal 名称而不是 sub claim。在这种情况下,您可以手动配置 OidcUser:

OidcUser oidcUser = new DefaultOidcUser(
AuthorityUtils.createAuthorityList("SCOPE_message:read"),
OidcIdToken.withTokenValue("id-token").claim("user_name", "foo_user").build(),
"user_name");
mvc
.perform(get("/endpoint")
.with(oidcLogin().oidcUser(oidcUser))
);

也可以将这个 oidcUser 函数化,这样就比较通用。

4.5.2 测试 OAuth 2.0 登录

与测试 OIDC 登录一样,测试 OAuth 2.0 登录也面临模拟授权流程的类似挑战。正因为如此,Spring Security 还对非 OIDC 用例提供了测试支持。

① 模拟登陆

假设我们有一个 controller,它将登录用户作为 OAuth2User 获取:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OAuth2User oauth2User) {
return oauth2User.getAttribute("sub");
}

在这种情况下,我们可以告诉 Spring Security 使用 oauth2User RequestPostProcessor 包含一个默认的 OAuth2User,如下所示:

mvc
.perform(get("/endpoint").with(oauth2Login()));

这将做的是使用 OAuth2User 配置关联的 MockHttpServletRequest,其中包括一个简单的属性映射和授予权限的集合。

具体来说,它将包含一个带有 sub/userkey/value对的 Map:

assertThat((String) user.getAttribute("sub")).isEqualTo("user");

以及只有一个权限的权限集合,SCOPE_read:

assertThat(user.getAuthorities()).hasSize(1);
assertThat(user.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

Spring Security 做了必要的工作来确保 OAuth2User 实例可用于 @AuthenticationPrincipal 注释。

此外,它还将 OAuth2User 链接到 OAuth2AuthorizedClient 的简单实例,该实例存放在模拟 OAuth2AuthorizedClientRepository 中。如果您的测试使用 @RegisteredOAuth2AuthorizedClient 注释,这会很方便。

② 配置 Authorities

在许多情况下,您的方法受到过滤器或方法安全性的保护,并且需要您的身份验证具有某些授予权限才能允许请求。

在这种情况下,您可以使用 authority() 方法提供所需的授权权限:

mvc
.perform(get("/endpoint")
.with(oauth2Login()
.authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
)
);
③ 配置 Claims

虽然授予的权限在所有 Spring Security 中都很常见,但我们在 OAuth 2.0 的情况下有 claims 也很常见。

例如,假设您有一个 user_id 属性,该属性指示系统中的用户 ID。您可以在控制器中像这样访问它:

@GetMapping("/endpoint")
public String foo(@AuthenticationPrincipal OAuth2User oauth2User) {
String userId = oauth2User.getAttribute("user_id");
// ...
}

在这种情况下,您需要使用 attributes() 方法指定该属性:

mvc
.perform(get("/endpoint")
.with(oauth2Login()
.attributes(attrs -> attrs.put("user_id", "1234"))
)
);
④ 附加配置

还有其他方法可以进一步配置身份验证;它仅取决于您的控制器期望的数据:

  • clientRegistration(ClientRegistration) - 用于使用给定的 ClientRegistration 配置关联的 OAuth2AuthorizedClient
  • oauth2User(OAuth2User) - 用于配置完整的 OAuth2User 实例

如果您: 1. 拥有自己的 OAuth2User 实现,或者 2. 需要更改名称属性,则最后一个非常方便:

例如,假设您的授权服务器在 user_name 声明中发送 principal 名称而不是 sub claim。在这种情况下,您可以手动配置 OAuth2User:

OAuth2User oauth2User = new DefaultOAuth2User(
AuthorityUtils.createAuthorityList("SCOPE_message:read"),
Collections.singletonMap("user_name", "foo_user"),
"user_name");
mvc
.perform(get("/endpoint")
.with(oauth2Login().oauth2User(oauth2User))
);

4.5.3 测试 OAuth 2.0 Clients

无论您的用户如何进行身份验证,您可能有其他令牌和客户端注册正在用于您正在测试的请求。例如,您的控制器可能依赖客户端凭据授予来获取与用户完全不关联的令牌:

@GetMapping("/endpoint")
public String foo(@RegisteredOAuth2AuthorizedClient("my-app") OAuth2AuthorizedClient authorizedClient) {
return this.webClient.get()
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(String.class)
.block();
}

模拟与授权服务器的握手可能很麻烦。您可以使用 oauth2Client RequestPostProcessor 将 OAuth2AuthorizedClient 添加到模拟 OAuth2AuthorizedClientRepository 中:

mvc
.perform(get("/endpoint").with(oauth2Client("my-app")));

这将创建一个具有简单 ClientRegistration、OAuth2AccessToken 和资源所有者名称的 OAuth2AuthorizedClient。具体来说,它将包含一个 ClientRegistration,其客户端 ID 为“test-client”,客户端密码为“test-secret”:

assertThat(authorizedClient.getClientRegistration().getClientId()).isEqualTo("test-client");
assertThat(authorizedClient.getClientRegistration().getClientSecret()).isEqualTo("test-secret");

资源所有者名称: "user"

assertThat(authorizedClient.getPrincipalName()).isEqualTo("user");

和一个只有一个范围的 OAuth2AccessToken ,阅读:

assertThat(authorizedClient.getAccessToken().getScopes()).hasSize(1);
assertThat(authorizedClient.getAccessToken().getScopes()).containsExactly("read");

然后可以在控制器方法中使用 @RegisteredOAuth2AuthorizedClient 正常检索客户端。

① 配置 Scopes

在许多情况下,OAuth 2.0 访问令牌带有一组范围。如果您的控制器检查这些,请这样说:

@GetMapping("/endpoint")
public String foo(@RegisteredOAuth2AuthorizedClient("my-app") OAuth2AuthorizedClient authorizedClient) {
Set<String> scopes = authorizedClient.getAccessToken().getScopes();
if (scopes.contains("message:read")) {
return this.webClient.get()
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(String.class)
.block();
}
// ...
}

然后您可以使用 accessToken() 方法配置范围:

mvc
.perform(get("/endpoint")
.with(oauth2Client("my-app")
.accessToken(new OAuth2AccessToken(BEARER, "token", null, null, Collections.singleton("message:read"))))
)
);
② 附加 Configurations

还有其他方法可以进一步配置身份验证;它仅取决于您的控制器期望的数据:

  • principalName(String) - 用于配置资源所有者名称
  • clientRegistration(Consumer<ClientRegistration.Builder>) - 用于配置关联的 ClientRegistration
  • clientRegistration(ClientRegistration) - 用于配置完整 ClientRegistration

如果您想使用真实的ClientRegistration,最后一个很方便

例如,假设您想要使用您的应用程序的 ClientRegistration 定义之一,如您的 application.yml 中指定的那样。

在这种情况下,您的测试可以自动装配 ClientRegistrationRepository 并查找您的测试需要的那个:

@Autowired
ClientRegistrationRepository clientRegistrationRepository;
// ...
mvc
.perform(get("/endpoint")
.with(oauth2Client()
.clientRegistration(this.clientRegistrationRepository.findByRegistrationId("facebook"))));

4.5.4 测试 JWT 身份验证

为了在资源服务器上发出授权请求,您需要一个不记名令牌。

如果您的资源服务器是为 JWT 配置的,那么这意味着需要对不记名令牌进行签名,然后根据 JWT 规范对其进行编码。所有这些都可能非常令人生畏,尤其是当这不是您的测试重点时。

幸运的是,有许多简单的方法可以克服这个困难,让您的测试专注于授权而不是代表不记名令牌。我们现在来看看其中的两个:

① jwt() RequestPostProcessor

第一种方法是通过 jwt RequestPostProcessor。其中最简单的看起来像这样:

mvc
.perform(get("/endpoint").with(jwt()));

这将创建一个模拟 Jwt,通过任何身份验证 API 正确传递它,以便您的授权机制可以验证它。

默认情况下,它创建的 JWT 具有以下特征:

{
"headers": { "alg": "none" },
"claims": {
"sub": "user",
"scope": "read"
}
}

生成的 Jwt,如果经过测试,将通过以下方式:

assertThat(jwt.getTokenValue()).isEqualTo("token");
assertThat(jwt.getHeaders().get("alg")).isEqualTo("none");
assertThat(jwt.getSubject()).isEqualTo("sub");

当然可以配置这些值。

任何 headers 或 claims 都可以使用其相应的方法进行配置:

mvc
.perform(get("/endpoint")
.with(jwt().jwt(jwt -> jwt.header("kid", "one").claim("iss", "https://idp.example.org"))));
// claims
mvc
.perform(get("/endpoint")
.with(jwt().jwt(jwt -> jwt.claims(claims -> claims.remove("scope")))));

scopescp claims 的处理方式与它们在正常不记名令牌请求中的处理方式相同。但是,这可以通过提供测试所需的 GrantedAuthority 实例列表来覆盖:

mvc
.perform(get("/endpoint")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_messages"))));

或者,如果您有一个自定义 Jwt 到 Collection<GrantedAuthority> 转换器,您也可以使用它来派生权限:

mvc
.perform(get("/endpoint")
.with(jwt().authorities(new MyConverter())));

您还可以指定一个完整的 Jwt,Jwt.Builder 非常方便:

Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.claim("sub", "user")
.claim("scope", "read")
.build();
mvc
.perform(get("/endpoint")
.with(jwt().jwt(jwt)));
② authentication() RequestPostProcessor

第二种方法是使用 authentication() RequestPostProcessor。本质上,您可以实例化自己的 JwtAuthenticationToken 并在测试中提供它,如下所示:

Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.claim("sub", "user")
.build();
Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("SCOPE_read");
JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, authorities);
mvc
.perform(get("/endpoint")
.with(authentication(token)));

请注意,作为替代方案,您还可以使用 @MockBean 注释模拟 JwtDecoder bean 本身。

4.5.5 测试不透明令牌身份验证

与 JWT 类似,不透明令牌需要授权服务器来验证其有效性,这会使测试变得更加困难。为了帮助解决这个问题,Spring Security 提供了对不透明令牌的测试支持。

假设我们有一个控制器,它将身份验证作为 BearerTokenAuthentication 检索:

@GetMapping("/endpoint")
public String foo(BearerTokenAuthentication authentication) {
return (String) authentication.getTokenAttributes().get("sub");
}

在这种情况下,我们可以告诉 Spring Security 使用 opaqueToken RequestPostProcessor 方法包含一个默认的 BearerTokenAuthentication,如下所示:

mvc
.perform(get("/endpoint").with(opaqueToken()));

这将使用 BearerTokenAuthentication 配置关联的 MockHttpServletRequest,其中包括简单的 OAuth2AuthenticatedPrincipal、属性映射和授予权限的集合。

具体来说,它将包含一个带有 sub/user 键/值对的 Map:

assertThat((String) token.getTokenAttributes().get("sub")).isEqualTo("user");

以及只有一个权限的权限集合,SCOPE_read:

assertThat(token.getAuthorities()).hasSize(1);
assertThat(token.getAuthorities()).containsExactly(new SimpleGrantedAuthority("SCOPE_read"));

Spring Security 做了必要的工作来确保 BearerTokenAuthentication 实例可用于您的控制器方法。

① 配置 Authorities

在许多情况下,您的方法受到过滤器或方法安全性的保护,并且需要您的身份验证具有某些授予权限才能允许请求。

在这种情况下,您可以使用 authority() 方法提供所需的授权权限:

mvc
.perform(get("/endpoint")
.with(opaqueToken()
.authorities(new SimpleGrantedAuthority("SCOPE_message:read"))
)
);
② 配置 Claims

虽然授予权限在所有 Spring Security 中都很常见,但在 OAuth 2.0 的情况下我们也有属性。

例如,假设您有一个 user_id 属性,该属性指示系统中的用户 ID。您可以在控制器中像这样访问它:

@GetMapping("/endpoint")
public String foo(BearerTokenAuthentication authentication) {
String userId = (String) authentication.getTokenAttributes().get("user_id");
// ...
}

在这种情况下,您需要使用 attributes() 方法指定该属性:

mvc
.perform(get("/endpoint")
.with(opaqueToken()
.attributes(attrs -> attrs.put("user_id", "1234"))
)
);
③ 附加配置

还有其他方法可以进一步配置身份验证;它仅取决于您的 controller 期望的数据。

其中之一是 principal(OAuth2AuthenticatedPrincipal),您可以使用它来配置作为 BearerTokenAuthentication 基础的完整 OAuth2AuthenticatedPrincipal 实例

如果您: 1. 拥有自己的 OAuth2AuthenticatedPrincipal 实现,或 2. 想要指定不同的主体名称,这将非常方便:

例如,假设您的授权服务器在 user_name 属性而不是 sub 属性中发送主体名称。在这种情况下,您可以手动配置 OAuth2AuthenticatedPrincipal:

Map<String, Object> attributes = Collections.singletonMap("user_name", "foo_user");
OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal(
(String) attributes.get("user_name"),
attributes,
AuthorityUtils.createAuthorityList("SCOPE_message:read"));
mvc
.perform(get("/endpoint")
.with(opaqueToken().principal(principal))
);

请注意,作为使用 opaqueToken() 测试支持的替代方法,您还可以使用 @MockBean 注释模拟 OpaqueTokenIntrospector bean 本身。

4.6 模拟 Logout

虽然使用标准 Spring MVC 测试相当简单,但您可以使用 Spring Security 的测试支持使测试注销更容易。例如,以下注销 RequestPostProcessor 将使用有效的 CSRF 令牌向“/logout”提交 POST:

mvc
.perform(logout())

您还可以自定义要发布到的 URL。例如,下面的代码片段将使用有效的 CSRF 令牌向“/signout”提交 POST:

mvc
.perform(logout("/signout"))

5. RequestBuilders

Spring MVC Test 还提供了一个 RequestBuilder 接口,可用于创建测试中使用的 MockHttpServletRequest。Spring Security 提供了一些 RequestBuilder 实现,可用于简化测试。为了使用 Spring Security 的 RequestBuilder 实现,请确保使用以下静态导入:

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.*;

6. ResultMatchers

有时需要对请求做出各种与安全相关的断言。为了满足这种需求,Spring Security Test 支持实现了 Spring MVC Test 的 ResultMatcher 接口。为了使用 Spring Security 的 ResultMatcher 实现,请确保使用以下静态导入:

import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.*;

6.1 Unauthenticated Assertion

有时,断言没有与 MockMvc 调用的结果相关联的经过身份验证的用户可能很有价值。例如,您可能想要测试提交无效的用户名和密码并验证没有用户通过身份验证。您可以使用 Spring Security 的测试支持轻松地做到这一点,如下所示:

mvc
.perform(formLogin().password("invalid"))
.andExpect(unauthenticated());

6.2 Authenticated Assertion

很多时候,我们必须断言经过身份验证的用户存在。例如,我们可能想要验证我们是否成功进行了身份验证。我们可以使用以下代码片段验证基于表单的登录是否成功:

mvc
.perform(formLogin())
.andExpect(authenticated());

如果我们想断言用户的角色,我们可以改进我们之前的代码,如下所示:

mvc
.perform(formLogin().user("admin"))
.andExpect(authenticated().withRoles("USER","ADMIN"));

或者,我们可以验证用户名:

mvc
.perform(formLogin().user("admin"))
.andExpect(authenticated().withUsername("admin"));

我们还可以组合断言:

mvc
.perform(formLogin().user("admin"))
.andExpect(authenticated().withUsername("admin").withRoles("USER", "ADMIN"));

我们还可以对身份验证进行任意断言

mvc
.perform(formLogin())
.andExpect(authenticated().withAuthentication(auth ->
assertThat(auth).isInstanceOf(UsernamePasswordAuthenticationToken.class)));

7. ResultHandlers

Spring Security 提供了一些 ResultHandlers 实现。为了使用 Spring Security 的 ResultHandlers 实现,请确保使用以下静态导入:

import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultHandlers.*;

7.1 导出 SecurityContext

很多时候,我们想查询一个存储库,看看是否有一些 MockMvc 请求实际持久化在数据库中。在某些情况下,我们的存储库查询使用 Spring Data Integration 根据当前用户的用户名或任何其他属性过滤结果。让我们看一个例子:

存储库接口:

private interface MessageRepository extends JpaRepository<Message, Long> {
@Query("SELECT m.content FROM Message m WHERE m.sentBy = ?#{ principal?.name }")
List<String> findAllUserMessages();
}

我们的测试场景:

mvc
.perform(post("/message")
.content("New Message")
.contentType(MediaType.TEXT_PLAIN)
)
.andExpect(status().isOk());
List<String> userMessages = messageRepository.findAllUserMessages();
assertThat(userMessages).hasSize(1);

这个测试不会通过,因为在我们的请求完成后,SecurityContextHolder 将被过滤器链清除。但是我们可以将 TestSecurityContextHolder 导出到我们的 SecurityContextHolder 并根据需要使用它:

mvc
.perform(post("/message")
.content("New Message")
.contentType(MediaType.TEXT_PLAIN)
)
.andDo(exportTestSecurityContext())
.andExpect(status().isOk());
List<String> userMessages = messageRepository.findAllUserMessages();
assertThat(userMessages).hasSize(1);

请记住在您的测试之间清除 SecurityContextHolder,否则它可能会在它们之间泄漏