学习如何使用本教程中提供的工具,并在 Spring Boot 环境中编写单元测试和集成测试。
1. 概览
本文中,我们将了解如何编写单元测试并将其集成在 Spring Boot 环境中。你可在网上找到大量关于这个主题的教程,但很难在一个页面中找到你需要的所有信息。我经常注意到初级开发人员混淆了单元测试和集成测试的概念,特别是在谈到 Spring 生态系统时。我将尝试讲清楚不同注解在不同上下文中的用法。
2. 单元测试 vs. 集成测试
维基百科是这么说 单元测试 的:
在计算机编程中,单元测试是一种软件测试方法,用以测试源代码的单个单元、一个或多个计算机程序模块的集合以及相关的控制数据、使用过程和操作过程,以确定它们是否适合使用。
集成测试:
“集成测试(有时也称集成和测试,缩写为 I&T)是软件测试的一个阶段,在这个阶段中,各个软件模块被组合在一起来进行测试。”
简而言之,当我们在做单元测试时,只是测试了一个代码单元,每次只测试一个方法,不包括与正测试组件相交互的其他所有组件。
另一方面,在集成测试中,我们测试各组件之间的集成。由于单元测试,我们可知这些组件行为与所需一致,但不清楚它们是如何在一起工作的。这就是集成测试的职责。
3. Java 单元测试
所有 Java 开发者都知道 JUnit 是执行单元测试的主要框架。它提供了许多注解来对期望进行断言。
Hamcrest 是一个用于软件测试的附加框架。Hamcrest 允许使用现有的 matcher 类来检查代码中的条件,还允许自定义 matcher 实现。要在 JUnit 中使用 Hamcrest matcher,必须使用 assertThat
语句,后跟一个或多个 matcher。
在这里,你可以看到使用这两种框架的简单测试:
import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.anyOf; import static org.hamcrest.CoreMatchers.both; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.everyItem; import static org.hamcrest.CoreMatchers.hasItems; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.sameInstance; import static org.hamcrest.CoreMatchers.startsWith; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import java.util.Arrays; import org.hamcrest.core.CombinableMatcher; import org.junit.Test; public class AssertTests { @Test public void testAssertArrayEquals() { byte[] expected = "trial".getBytes(); byte[] actual = "trial".getBytes(); assertArrayEquals("failure - byte arrays not same", expected, actual); } @Test public void testAssertEquals() { assertEquals("failure - strings are not equal", "text", "text"); } @Test public void testAssertFalse() { assertFalse("failure - should be false", false); } @Test public void testAssertNotNull() { assertNotNull("should not be null", new Object()); } @Test public void testAssertNotSame() { assertNotSame("should not be same Object", new Object(), new Object()); } @Test public void testAssertNull() { assertNull("should be null", null); } @Test public void testAssertSame() { Integer aNumber = Integer.valueOf(768); assertSame("should be same", aNumber, aNumber); } // JUnit Matchers assertThat @Test public void testAssertThatBothContainsString() { assertThat("albumen", both(containsString("a")).and(containsString("b"))); } @Test public void testAssertThatHasItems() { assertThat(Arrays.asList("one", "two", "three"), hasItems("one", "three")); } @Test public void testAssertThatEveryItemContainsString() { assertThat(Arrays.asList(new String[] { "fun", "ban", "net" }), everyItem(containsString("n"))); } // Core Hamcrest Matchers with assertThat @Test public void testAssertThatHamcrestCoreMatchers() { assertThat("good", allOf(equalTo("good"), startsWith("good"))); assertThat("good", not(allOf(equalTo("bad"), equalTo("good")))); assertThat("good", anyOf(equalTo("bad"), equalTo("good"))); assertThat(7, not(CombinableMatcher.<Integer> either(equalTo(3)).or(equalTo(4)))); assertThat(new Object(), not(sameInstance(new Object()))); } @Test public void testAssertTrue() { assertTrue("failure - should be true", true); } }
4. 介绍我们的案例
让我们来写一个简单的程序吧。其目的是为漫画提供一个基本的搜索引擎。
4.1. Maven 依赖
首先,需要添加一些依赖到我们的工程中。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.20</version> <scope>provided</scope> </dependency>
4.2. 定义 Model
我们的模型非常简单,只有两个类组成:Manga 和 MangaResult
4.2.1. Manga 类
Manga 类表示系统检索到的 Manga 实例。使用 Lombok 来减少样板代码。
package com.mgiglione.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class Manga { private String title; private String description; private Integer volumes; private Double score; }
4.2.2. MangaResult
MangaResult 类是包含了一个 Manga List 的包装类。
package com.mgiglione.model; import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor public class MangaResult { private List<Manga> result; }
4.3. 实现 Service
为实现本 Service,我们将使用由 Jikan Moe 提供的免费 API 接口。
RestTemplate 是用来对 API 进行发起 REST 调用的 Spring 类。
package com.mgiglione.service; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import com.mgiglione.model.Manga; import com.mgiglione.model.MangaResult; @Service public class MangaService { Logger logger = LoggerFactory.getLogger(MangaService.class); private static final String MANGA_SEARCH_URL="http://api.jikan.moe/search/manga/"; @Autowired RestTemplate restTemplate; public List<Manga> getMangasByTitle(String title) { return restTemplate.getForEntity(MANGA_SEARCH_URL+title, MangaResult.class).getBody().getResult(); } }
4.4. 实现 Controller
下一步就是写一个暴露了两个端点的 REST Controller,一个是同步的,一个是异步的,其仅用于测试目的。该 Controller 使用了上面定义的 Service。
package com.mgiglione.controller; import java.util.List; import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Async; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import com.mgiglione.model.Manga; import com.mgiglione.service.MangaService; @RestController @RequestMapping(value = "/manga") public class MangaController { Logger logger = LoggerFactory.getLogger(MangaController.class); @Autowired private MangaService mangaService; @RequestMapping(value = "/async/{title}", method = RequestMethod.GET) @Async public CompletableFuture<List<Manga>> searchASync(@PathVariable(name = "title") String title) { return CompletableFuture.completedFuture(mangaService.getMangasByTitle(title)); } @RequestMapping(value = "/sync/{title}", method = RequestMethod.GET) public @ResponseBody <List<Manga>> searchSync(@PathVariable(name = "title") String title) { return mangaService.getMangasByTitle(title); } }
4.5. 启动并测试系统
mvn spring-boot:run
然后,Let’s try it:
curl http://localhost:8080/manga/async/ken curl http://localhost:8080/manga/sync/ken
示例输出:
{ "title":"Rurouni Kenshin: Meiji Kenkaku Romantan", "description":"Ten years have passed since the end of Bakumatsu, an era of war that saw the uprising of citizens against the Tokugawa shogunate. The revolutionaries wanted to create a time of peace, and a thriving c...", "volumes":28, "score":8.69 }, { "title":"Sun-Ken Rock", "description":"The story revolves around Ken, a man from an upper-class family that was orphaned young due to his family's involvement with the Yakuza; he became a high school delinquent known for fighting. The only...", "volumes":25, "score":8.12 }, { "title":"Yumekui Kenbun", "description":"For those who suffer nightmares, help awaits at the Ginseikan Tea House, where patrons can order much more than just Darjeeling. Hiruko is a special kind of a private investigator. He's a dream eater....", "volumes":9, "score":7.97 }
5. Spring Boot 应用的单元测试
Spring Boot 提供了一个强大的类以使测试变得简单:@SpringBootTest 注解
可以在基于 Spring Boot 运行的测试类上指定此注解。
除常规 Spring TestContext Framework 之外,其还提供以下功能:
- 当 @ContextConfiguration (loader=…) 没有特别声明时,使用 SpringBootContextLoader 作为默认 ContextLoader。
- 在未使用嵌套的 @Configuration 注解,且未显式指定相关类时,自动搜索 @SpringBootConfiguration。
- 允许使用 Properties 来自定义 Environment 属性。
- 对不同的 Web 环境模式提供支持,包括启动在已定义或随机端口上的完全运行的 Web 服务器的功能。
- 注册 TestRestTemplate 和 / 或 WebTestClient Bean,以便在完全运行在 Web 服务器上的 Web 测试中使用。
此处,我们仅有两个组件需要测试:MangaService 和 MangaController
5.1. 对 MangaService 进行单元测试
为了测试 MangaService,我们需要将其与外部组件隔离开来。本例中,只需要一个外部组件:RestTemplate
,我们用它来调用远程 API。
我们需要做的是模拟 RestTemplate Bean,并让它始终以固定的给定响应进行响应。Spring Test 结合并扩展了 Mockito 库,通过 @MockBean 注解,我们可以配置模拟 Bean。
package com.mgiglione.service.test.unit; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.io.IOException; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; import com.mgiglione.model.Manga; import com.mgiglione.model.MangaResult; import com.mgiglione.service.MangaService; import com.mgiglione.utils.JsonUtils; @RunWith(SpringRunner.class) @SpringBootTest public class MangaServiceUnitTest { @Autowired private MangaService mangaService; // MockBean is the annotation provided by Spring that wraps mockito one // Annotation that can be used to add mocks to a Spring ApplicationContext. // If any existing single bean of the same type defined in the context will be replaced by the mock, if no existing bean is defined a new one will be added. @MockBean private RestTemplate template; @Test public void testGetMangasByTitle() throws IOException { // Parsing mock file MangaResult mRs = JsonUtils.jsonFile2Object("ken.json", MangaResult.class); // Mocking remote service when(template.getForEntity(any(String.class), any(Class.class))).thenReturn(new ResponseEntity(mRs, HttpStatus.OK)); // I search for goku but system will use mocked response containing only ken, so I can check that mock is used. List<Manga> mangasByTitle = mangaService.getMangasByTitle("goku"); assertThat(mangasByTitle).isNotNull() .isNotEmpty() .allMatch(p -> p.getTitle() .toLowerCase() .contains("ken")); } }
5.2. 对 MangaController 进行单元测试
正如在 MangaService 的单元测试中所做的那样,我们需要隔离组件。在这种情况下,我们需要模拟 MangaService Bean。
然后,我们还有一个问题……Controller 部分是管理 HttpRequest 的系统的一部分,因此我们需要一个系统来模拟这种行为,而非启动完整的 HTTP 服务器。
MockMvc 是执行该操作的 Spring 类。其可以以不同的方式进行设置:
- 使用 Standalone Context
- 使用 WebApplication Context
- 让 Spring 通过在测试类上使用 @SpringBootTest、@AutoConfigureMockMvc 这些注解来加载所有的上下文,以实现自动装配
- 让 Spring 通过在测试类上使用 @WebMvcTest 注解来加载 Web 层上下文,以实现自动装配
package com.mgiglione.service.test.unit; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; import java.util.ArrayList; import java.util.List; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.web.context.WebApplicationContext; import com.mgiglione.controller.MangaController; import com.mgiglione.model.Manga; import com.mgiglione.service.MangaService; @SpringBootTest @RunWith(SpringRunner.class) public class MangaControllerUnitTest { MockMvc mockMvc; @Autowired protected WebApplicationContext wac; @Autowired MangaController mangaController; @MockBean MangaService mangaService; /** * List of samples mangas */ private List<Manga> mangas; @Before public void setup() throws Exception { this.mockMvc = standaloneSetup(this.mangaController).build();// Standalone context // mockMvc = MockMvcBuilders.webAppContextSetup(wac) // .build(); Manga manga1 = Manga.builder() .title("Hokuto no ken") .description("The year is 199X. The Earth has been devastated by nuclear war...") .build(); Manga manga2 = Manga.builder() .title("Yumekui Kenbun") .description("For those who suffer nightmares, help awaits at the Ginseikan Tea House, where patrons can order much more than just Darjeeling. Hiruko is a special kind of a private investigator. He's a dream eater....") .build(); mangas = new ArrayList<>(); mangas.add(manga1); mangas.add(manga2); } @Test public void testSearchSync() throws Exception { // Mocking service when(mangaService.getMangasByTitle(any(String.class))).thenReturn(mangas); mockMvc.perform(get("/manga/sync/ken").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].title", is("Hokuto no ken"))) .andExpect(jsonPath("$[1].title", is("Yumekui Kenbun"))); } @Test public void testSearchASync() throws Exception { // Mocking service when(mangaService.getMangasByTitle(any(String.class))).thenReturn(mangas); MvcResult result = mockMvc.perform(get("/manga/async/ken").contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(request().asyncStarted()) .andDo(print()) // .andExpect(status().is2xxSuccessful()).andReturn(); .andReturn(); // result.getRequest().getAsyncContext().setTimeout(10000); mockMvc.perform(asyncDispatch(result)) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].title", is("Hokuto no ken"))); } }
正如在代码中所看到的那样,选择第一种解决方案是因为其是最轻量的一个,并且我们可以对 Spring 上下文中加载的对象有更好的治理。
在异步测试中,必须首先通过调用服务,然后启动 asyncDispatch
方法来模拟异步行为。
6. Spring Boot 应用的集成测试
对于集成测试,我们希望提供下游通信来检查我们的主要组件。
6.1. 对 MangaService 进行集成测试
这个测试也是非常简单的。我们不需要模拟任何东西,因为我们的目的就是要调用远程 Manga API。
package com.mgiglione.service.test.integration; import static org.assertj.core.api.Assertions.assertThat; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import com.mgiglione.model.Manga; import com.mgiglione.service.MangaService; @RunWith(SpringRunner.class) @SpringBootTest public class MangaServiceIntegrationTest { @Autowired private MangaService mangaService; @Test public void testGetMangasByTitle() { List<Manga> mangasByTitle = mangaService.getMangasByTitle("ken"); assertThat(mangasByTitle).isNotNull().isNotEmpty(); } }
6.2. 对 MangaController 进行集成测试
这个测试和单元测试很是相似,但在这个案例中,我们无需再模拟 MangaService。
package com.mgiglione.service.test.integration; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.web.context.WebApplicationContext; import com.mgiglione.controller.MangaController; @SpringBootTest @RunWith(SpringRunner.class) public class MangaControllerIntegrationTest { // @Autowired MockMvc mockMvc; @Autowired protected WebApplicationContext wac; @Autowired MangaController mangaController; @Before public void setup() throws Exception { this.mockMvc = standaloneSetup(this.mangaController).build();// Standalone context // mockMvc = MockMvcBuilders.webAppContextSetup(wac) // .build(); } @Test public void testSearchSync() throws Exception { mockMvc.perform(get("/manga/sync/ken").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.*.title", hasItem(is("Hokuto no Ken")))); } @Test public void testSearchASync() throws Exception { MvcResult result = mockMvc.perform(get("/manga/async/ken").contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(request().asyncStarted()) .andDo(print()) .andReturn(); mockMvc.perform(asyncDispatch(result)) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.*.title", hasItem(is("Hokuto no Ken")))); } }
7. 结论
我们已经了解了在 Spring Boot 环境下单元测试和集成测试的主要不同,了解了像 Hamcrest 这样简化测试编写的框架。当然,也可以在我的 GitHub 仓库 里找到所有代码。
原文:https://dzone.com/articles/unit-and-integration-tests-in-spring-boot-2
译者:万想
9月福利,关注公众号
后台回复:004,领取8月翻译集锦!
往期福利回复:001,002, 003即可领取!