Unit and Integration Tests for RestControllers in Spring Boot
原文链接 常用术语未做翻译:Spring Context,standalone,MockMvc,SpringRunner,SpringBootTest,Mocking,Filter,ControllerAdvice。
在Spring Boot中有多种测试您的Controller(Web或API层)类的方法,有些提供了编写纯粹的单元测试的支持,而另一些则对集成测试更有用。在本文中,我将介绍可用于Spring的三种主要测试方法:在standalone模式下使用MockMVC,将MockMVC与SpringRunner一起使用,以及使用SpringBootTest。
Introduction
Springboot 提供了几种不同的方法用以测试。Springboot是一个不断发展进化的框架,新版本会有新的特性出现,而老的特性也会保留以便向后兼容。
结果就是测试相同的一段带代码可以有不同的方法,并且有些方法并没有明确什么时候可以使用。
本文将帮你理解不同的替代方法,这些方法可以使用的原因以及每一个方法的使用时机。
本文主要针对于Controller的测试,因为这基本上是最不明确的部分,Mocking Object可以在不同的级别上出现。
The sample application
本文将使用一些示例代码来阐述不同的概念。
所有的代码都位于GitHub:Spring Boot Testing Strategies
简单来说,这是一个通过Rest API发布的实体-超级英雄存储库。
下面列出了程序的一些特性以便更进一步的理解在使用不同的策略时候会发生什么:
如果不同通过identifier找到一个超级英雄,那么就抛出NonExistingHeroException异常。有一个Spring的@RestControllerAdvice 会拦截这个异常,然后把它转换成404状态-NOT_FOUND。
在我们的HTTP通信中会用到一个SuperHeroFilter类,来向HTTP响应添加标头:X-SUPERHERO-APP
Server and Client Side Tests
首先我们把服务器端测试和客户端测试分开:
服务器端测试是最广泛的测试方式:执行请求,并且检查服务器的行为,响应组成,响应内容等。
而客户端的测试不太常见,当你想验证请求的组成和动作,他们是有用的。在这些测试中,你会模拟服务器行为,然后间接地调用一些代码,这些代码将间接地对该服务器执行请求。这正是你想要测试的内容,你想验证是否存在请求以及该请求的内容。您不关心响应的内容(你模拟了那部分内容)。不幸的是,没有很多好的关于客户端测试的例子。即使你看了官方示例,它们也并不是那么有用(请参阅Javadoc注释)。反正重要的概念是,当你开发一个客户端程序,并且需要验证从客户端向外部发出的请求的时候,你会用到这种测试。
我们将专注于服务器端测试,验证服务器逻辑是否有效。在这种情况下,通常会模拟请求,并且检查服务器逻辑的输出。这类测试与应用程序中的Controller层紧密相关,因为它是Spring负责处理HTTP请求的那部分。
Server-Side Tests
如果我们深入研究服务器端测试,在Spring有两种策略可以使用:
- 使用MockMVC方法编写Controller测试,
- 或使用RestTemplate。
如果要编写一个真正的单元测试,则应该首选第一种策略(MockMVC),而如果要编写集成测试,则应使用RestTemplate。原因是使用MockMVC,我们可以为Controller细化断言。另一方面,RestTemplate将使用Spring的WebApplicationContext(部分或全部取决于是否使用Standalone模式)。后面我们将更详细地说明这两种策略。
Inside-Server Tests
我们可以直接测试Controller逻辑,而无需运行Web服务器。这叫做Inside-Server测试,它比较接近于单元测试的定义。为了做这个测试,你需要模拟整个Web服务器的行为,因此就某种程度上来说,有部分程序没有被测试到。但是不用担心,因为集成测试可以完美覆盖这些部分。
策略 1: MockMVC in Standalone Mode
在Spring中,你可以以Standalone使用MockMVC来编写inside-server测试用例,而不会加载任何Context。让我们来看一个例子。
MockMVC standalone code example
@RunWith(MockitoJUnitRunner.class)
public class SuperHeroControllerMockMvcStandaloneTest {
private MockMvc mvc;
@Mock
private SuperHeroRepository superHeroRepository;
@InjectMocks
private SuperHeroController superHeroController;
// This object will be magically initialized by the initFields method below.
private JacksonTester<SuperHero> jsonSuperHero;
@Before
public void setup() {
// We would need this line if we would not use MockitoJUnitRunner
// MockitoAnnotations.initMocks(this);
// Initializes the JacksonTester
JacksonTester.initFields(this, new ObjectMapper());
// MockMvc standalone approach
mvc = MockMvcBuilders.standaloneSetup(superHeroController)
.setControllerAdvice(new SuperHeroExceptionHandler())
.addFilters(new SuperHeroFilter())
.build();
}
@Test
public void canRetrieveByIdWhenExists() throws Exception {
// given
given(superHeroRepository.getSuperHero(2))
.willReturn(new SuperHero("Rob", "Mannon", "RobotMan"));
// when
MockHttpServletResponse response = mvc.perform(
get("/superheroes/2")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonSuperHero.write(new SuperHero("Rob", "Mannon", "RobotMan")).getJson()
);
}
@Test
public void canRetrieveByIdWhenDoesNotExist() throws Exception {
// given
given(superHeroRepository.getSuperHero(2))
.willThrow(new NonExistingHeroException());
// when
MockHttpServletResponse response = mvc.perform(
get("/superheroes/2")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
assertThat(response.getContentAsString()).isEmpty();
}
@Test
public void canRetrieveByNameWhenExists() throws Exception {
// given
given(superHeroRepository.getSuperHero("RobotMan"))
.willReturn(Optional.of(new SuperHero("Rob", "Mannon", "RobotMan")));
// when
MockHttpServletResponse response = mvc.perform(
get("/superheroes/?name=RobotMan")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonSuperHero.write(new SuperHero("Rob", "Mannon", "RobotMan")).getJson()
);
}
@Test
public void canRetrieveByNameWhenDoesNotExist() throws Exception {
// given
given(superHeroRepository.getSuperHero("RobotMan"))
.willReturn(Optional.empty());
// when
MockHttpServletResponse response = mvc.perform(
get("/superheroes/?name=RobotMan")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo("null");
}
@Test
public void canCreateANewSuperHero() throws Exception {
// when
MockHttpServletResponse response = mvc.perform(
post("/superheroes/").contentType(MediaType.APPLICATION_JSON).content(
jsonSuperHero.write(new SuperHero("Rob", "Mannon", "RobotMan")).getJson()
)).andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value());
}
@Test
public void headerIsPresent() throws Exception {
// when
MockHttpServletResponse response = mvc.perform(
get("/superheroes/2")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getHeaders("X-SUPERHERO-APP")).containsOnly("super-header");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
下面一节将详尽的解释这段代码
MockitoJUnitRunner and MockMVC
我们使用MockitoJUnitRunner
来运行单元测试。这由Mockito提供,并在内置的JUnit运行器之上添加了一些功能:检测到正在使用该框架,没有未使用的stubs,并为我们初始化了所有带@Mock
注解的字段,因此我们不需要调用Mockito.initMocks()
方法。
请注意我们如何初始化模拟:使用注解正常的模拟了SuperHeroRepository
。但是,我们需要在真正的controller中用到它,因此在SuperHeroController
实例上使用了@InjectMocks
注解。这样,模拟的SuperHeroRepository
将被注入到控制器中,而不是真正的bean实例。
对于每个测试,我们都使用MockMVC
实例执行各种假请求(GET,POST等),并且会收到MockHttpServletResponse
响应。请记住,这也不是真正的响应,所有的东西都被模拟了。
JacksonTester initialization
使用JacksonTester.initFields()
自动注入了一个JacksonTesterobject
。这是Spring提供的一个工具类,并且你看到它要使用静态方法初始化
,所以这有点微妙。
在setup方法(每次测试之前需要执行的方法)中,我们以Standalone模式配置MockMVC,并显示的配置被测试的Controller,ControllerAdvice和HTTP Filter但是,不管怎样,您已经看到了这种方法的主要缺点:在ControllerAdvice,Filters等中建模的逻辑的任何部分都配置在这,这是因为您没有任何可以自动注入它们的Spring context。
Testing ControllerAdvices and Filters with MockMVC
请注意如何验证周围的内容:在第60行中,我们用检查一个ID不存在的请求,看他是否以NOT_FOUND结束,因此ControllerAdvice
可以正常工作。我们还有一个测试(113行)来验证标头是否存在,因此我们Filter 也在工作。您可以改一下代码看看:删除Standalone设置中指定Advice和Filter的部分,然后再次运行测试。不出所料的话,在这种情况下它将失败,因为没有Context可以注入这些类。
为了这篇文章的教学目的,我在此测试中包括了Filter和ControllerAdvice。但是我们可以不这样做,而将标头测试和404测试留给集成测试(因此我们从此处和独立配置中将其删除)。如果这样做,就是一个纯粹的单元测试:我们将仅测试Controller类的逻辑,而无其他干扰项。
顺便提一下:该代码使用BDDMockito和AssertJ编写了human-readable, fluent-style的测试。如果您想了解更多有关此技术的知识,请保存另一文章以便后面阅读: 【Write BDD Unit Tests with BDDMockito and AssertJ](https://thepracticaldeveloper.com/2018/05/10/write-bdd-unit-tests-with-bddmockito-and-assertj/)
策略 2: MockMVC with WebApplicationContext
这种模式下,我们还可以使用MockMC
来编写测试用例,但是我们会加载Spring的WebApplicationContext
。因为我们还在使用inside-server策略,所以不会部署web server。
MockMVC and WebMvcTest code example
@RunWith(SpringRunner.class)
@WebMvcTest(SuperHeroController.class)
public class SuperHeroControllerMockMvcWithContextTest {
@Autowired
private MockMvc mvc;
@MockBean
private SuperHeroRepository superHeroRepository;
// This object will be magically initialized by the initFields method below.
private JacksonTester<SuperHero> jsonSuperHero;
@Before
public void setup() {
// Initializes the JacksonTester
JacksonTester.initFields(this, new ObjectMapper());
}
@Test
public void canRetrieveByIdWhenExists() throws Exception {
// given
given(superHeroRepository.getSuperHero(2))
.willReturn(new SuperHero("Rob", "Mannon", "RobotMan"));
// when
MockHttpServletResponse response = mvc.perform(
get("/superheroes/2")
.accept(MediaType.APPLICATION_JSON))
.andReturn().getResponse();
// then
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonSuperHero.write(new SuperHero("Rob", "Mannon", "RobotMan")).getJson()
);
}
// ...
// Rest of the class omitted, it's the same implementation as in Standalone mode
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
如果与StandAlone模式对比,主要有这样一些不同:
SpringRunner
测试通过SpringRunner
执行,这就是context或者部分context如何初始化的。当你运行测试的时候,你在一开始的日志trace中可以看到context启动以及bean的注入。
MockMVC Autoconfiguration
通过@WebMVCTest
注解,MockMVC
实例在context中被自动配置(所以后面我们使用autowire就可以拿到它)。除此之外,我们在注解中指定了要被测试的Controller类。Spring只会部分加载context(controller以及和它相关的配置)。
注解的实现足够智能,它知道应该还把过滤器和ControllerAdvice注入,所以在这种方法中,不需要显示的在setup()
方法里面配置。
@WebMvcTest only auto-configures the Spring MVC infrastructure and limits scanned beans to @Controller, @ControllerAdvice, @JsonComponent, Filter, WebMvcConfigurer, and HandlerMethodArgumentResolver beans.
When you use @WebMvcTest, regular @Component, @Service, or @Repository beans will not be scanned – an important point to differentiate @WebMvcTest from a full-blown @SpringBootTest.
If you’re looking to load your full application configuration and use MockMVC, you should consider @SpringBootTest combined with @AutoConfigureMockMvc rather than @WebMvcTest. I will cover it in an upcoming post of this Spring MVC testing series. I will also help you to explore more about mocking services and JPA repositories with @MockBean combined with @DataJpaTest and @WebMvcTest, and also how to unit test RESTful controller’s GETs and POSTs using MockMvc and @JsonTest.
Overriding beans for testing using MockBean
Now the repository is injected in the Spring’s context using @MockBean. We don’t need to make any reference to our controller class apart from the one in the annotation, since the controller will be injected and available. This bean will just replace the real repository implementation.
现在使用@MockBean
把repository注入到了Spring的Context中。除了注解,我们不需要再引用Controller类,因为Controller会被注入并可用。Mock的Bean会替代掉真正的SuperHeroRepository实现
No server calls
记住,我们验证的响应仍然是假的。因为在这个测试中还没有web服务器。无论如何,这是一个相当有效的测试,因为我们在类内部验证我们的逻辑,同时也验证了其他的一些参与者(SuperHeroExceptionHandler
和SuperHeroFilter
)
Using MockMVC with a Web Application Context – Conclusions
最主要区别是我们不需要显示的加载一些参与类,因为存在有Spring的Context。如果我们创建了新的过滤器,新的ControllerAdvice或者其他在请求-响应过程中的参与类,他们会被自动的注入到测试用。所以我们不需要手动配置。在测试中没有进行细粒度的控制,用什么或者不用什么,但是者更接近于真实情况。当我们运行应用程序时,默认情况下所有这些东西都存在。
你可以把这种方法当做向集成测试的一个小的过渡。在这种情况下,过滤器和ControllerAdvice在测试中开箱即用,无需做任何的引用。如果未来有其他的类会干预请求-响应流,他也将参与到这个测试中。
由于此测试包含不止一个类,因此您可以将其归类为这些类之间的集成测试。但是,这条线是模糊的:另一方面,您可能会说只有一个控制器被测试,但是您需要额外的配置才能正确地对其进行测试。
Outside-Server Tests
我把向应用程序发出HTTP请求进行测试的方式称为**“outside-server"**测试,但是,即使在server之外,你可以给这些测试注入一些模拟对象,所以在这种测试中,你能获得跟单元测试类似的内容。 例如,在传统的3层应用程序中,您可以模拟Service层,并通过Web服务器仅测试Controller。但是,实际上,这种方法比普通的单元测试要重得多。 你加载了整个应用程序上下文,除非你在测试期间告诉Spring不要这样做(通过配置排除或仅包括所需的内容)
在Spring中,你可以使用RestTemplate
来为REST Controller编写outside-server测试用例来执行请求,或者用新的TestRestTemplate,它拥有集成测试的一些有用功能(包括身份验证标头和容错能力)
在Spring Boot中, 你也可以使用@SpringBootTest
注解。之后通过开箱即用的模式,你可以获得一些注入到Context中的bean,可以访问从application.properties
中加载的属性,等等。它是@ContextConfiguration
(Spring)的替代,它可以为测试提供所有的Spring Boot特性。
在Spring Boot中的测试策略容易引起混淆,因为有太多的特性和可用的选项。让我们看一下这些策略,如同之前我们研究MockMVC一样。
策略 3: SpringBootTest with a MOCK WebEnvironment value
如果你使用@SpringBootTest
或者@SpringBootTesst(webEnvironment = WebEnvironment.MOCK)
, 就不会加载一个真的HTTP 服务器。是不是听起来很熟悉?这跟策略2(MockMVC with an application context)非常接近。所以,虽然在理论上我们使用了@Springboot
注解开发outside-server测试用例,但是当把WebEnviroment设为Mock的时候,我们其实在做inside-server测试。
我们不能使用RestTemplate
,因此我们没有任何Web服务器,所以我们继续使用MockMVC
,这通过额外的注解@AutoConfigureMockMVC
完成配置。我认为这是所有可用方法之间最棘手的方法,因此我个人不鼓励使用它。
最好使用加载特定Controller的策略2,这样你对测试有更多的控制。
策略 4: SpringBootTest with a Real Web Server
当您使用@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
或@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)
,那就是通过真正的HTTP Server进行测试了,在这种情况下,你需要使用RestTemplate
或TestRestTemplate
。使用随机端口或已定义端口之间的区别是,在第一种情况下,server.port
将不会使用默认端口8080(或server.port属性指定的端口),而是将其替换为随机分配的端口号。这当您要运行并行测试时很有用,可以避免端口冲突。让我们看一下代码,然后说明主要部分。
Spring Boot Test Code Example
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class SuperHeroControllerSpringBootTest {
@MockBean
private SuperHeroRepository superHeroRepository;
@Autowired
private TestRestTemplate restTemplate;
@Test
public void canRetrieveByIdWhenExists() {
// given
given(superHeroRepository.getSuperHero(2))
.willReturn(new SuperHero("Rob", "Mannon", "RobotMan"));
// when
ResponseEntity<SuperHero> superHeroResponse = restTemplate.getForEntity("/superheroes/2", SuperHero.class);
// then
assertThat(superHeroResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(superHeroResponse.getBody().equals(new SuperHero("Rob", "Mannon", "RobotMan")));
}
@Test
public void canRetrieveByIdWhenDoesNotExist() {
// given
given(superHeroRepository.getSuperHero(2))
.willThrow(new NonExistingHeroException());
// when
ResponseEntity<SuperHero> superHeroResponse = restTemplate.getForEntity("/superheroes/2", SuperHero.class);
// then
assertThat(superHeroResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(superHeroResponse.getBody()).isNull();
}
@Test
public void canRetrieveByNameWhenExists() {
// given
given(superHeroRepository.getSuperHero("RobotMan"))
.willReturn(Optional.of(new SuperHero("Rob", "Mannon", "RobotMan")));
// when
ResponseEntity<SuperHero> superHeroResponse = restTemplate
.getForEntity("/superheroes/?name=RobotMan", SuperHero.class);
// then
assertThat(superHeroResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(superHeroResponse.getBody().equals(new SuperHero("Rob", "Mannon", "RobotMan")));
}
@Test
public void canRetrieveByNameWhenDoesNotExist() {
// given
given(superHeroRepository.getSuperHero("RobotMan"))
.willReturn(Optional.empty());
// when
ResponseEntity<SuperHero> superHeroResponse = restTemplate
.getForEntity("/superheroes/?name=RobotMan", SuperHero.class);
// then
assertThat(superHeroResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(superHeroResponse.getBody()).isNull();
}
@Test
public void canCreateANewSuperHero() {
// when
ResponseEntity<SuperHero> superHeroResponse = restTemplate.postForEntity("/superheroes/",
new SuperHero("Rob", "Mannon", "RobotMan"), SuperHero.class);
// then
assertThat(superHeroResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
@Test
public void headerIsPresent() throws Exception {
// when
ResponseEntity<SuperHero> superHeroResponse = restTemplate.getForEntity("/superheroes/2", SuperHero.class);
// then
assertThat(superHeroResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(superHeroResponse.getHeaders().get("X-SUPERHERO-APP")).containsOnly("super-header");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
现在我们开看主要区别。
Web Server Testing
我们使用SpringRunner
来执行测试,但是我们用@SpringBootTest
来注解声明使用RANDOM_PORT模式。完成这步,我们就拥有了一个可以执行测试的WEB服务器。
现在我们用模板(18行)来触发请求,就跟我们访问外部服务器一样。
断言有一些变化,因为我们要确定这是一个ResponseEntity
而不是一个MockHttpServletResponse
Mocking layers
注意我们现在仍然使用@MockBean
注解来模拟repository
TestRestTemplate
我们可以注入一个TestRestTemplate
对象,因为我们使用的是@SpringBootTest
。它与标准RestTemplate作用完全相同,但是之前看到的一些额外的功能。
SpringBootTest approach – Conclusions
即使我们的目标是相同的——测试Controller层,这种测试方法与策略1(Standalone下的MockMVC)相比较,也是从完全不同的角度解决问题。之前,我们只是在加载类并不包括周围的参与者(Filter和ControllerAdvice)。现在,我们加载了包括Web服务器在内的整个Spring Boot Context。这种方法是最重的,并且离单元测试的概念最远。
这种策略主要用于集成测试。您仍然可以模拟bean并在上下文中替换它们,但是您可以验证Spring Boot应用程序中不同类之间的交互,以及Web服务器的参与。
我的建议是,应避免在单元测试中采用这种策略。这样测试用例变胖,并且你可能无法控制要测试的内容。但是不要误会我的意思,对于集成测试,你应该倾向于这种方法:这层测试在验证不同的组件如何在一起工作时候是很有用的。
Performance and Context Caching
您可能会认为策略1的性能要比其他策略更好。或者,如果您每次运行测试时都需要加载整个Spring Boot Context,则Strategy 4可能会表现糟糕。嗯,这并不完全正确。当您使用Spring(包括Boot)进行测试时,默认情况下,在同一Test Suite中Spring Context会被复用。
这意味着策略2、3和4在首次加载Spring Context后会复用它。请注意,如果你的测试用例修改了Context中包含的bean,则Context复用可能会引起一些副作用。如果是这种情况,你需要对@DirtiesContext
注解做一些变通,表明您想重新加载Context(请参阅完整的文档)。
Conclusion
如上文,针对在Spring Boot中统一测试Controller,我们讲了从最轻到最终的方法。现在是时候给出有关何时用什么的个人建议了:
为Controller逻辑编写单元测试,而不关注其他行为。选择策略1:use MockMVC in Standalone mode.。
如果你需要测试与WEB层相关的一些周边行为(例如过滤,拦截,认证等),那么选择策略4:SpringBootTest with a web server on a random port。但是请把它作为单独的集成测试,以你为你在验证应用程序的不同组成。如果需要,请勿跳过纯Controller层的单元测试。换句话说,尽可能的分开测试不同的层,而不是混在一起测试。
我希望这篇指南对你有所帮助。欢迎用过评论给与反馈。
如果您正在研究测试Controller,也许您也对REST控制器中的自定义错误处理感兴趣,请查看新指南以获取更多详细信息。