Spring Boot 的單元測試和整合測試
學習如何使用本教程中提供的工具,並在 Spring Boot 環境中編寫單元測試和整合測試。
1. 概覽
本文中,我們將瞭解如何編寫單元測試並將其整合在 Spring Boot 環境中。你可在網上找到大量關於這個主題的教程,但很難在一個頁面中找到你需要的所有資訊。我經常注意到初級開發人員混淆了單元測試和整合測試的概念,特別是在談到 Spring 生態系統時。我將嘗試講清楚不同註解在不同上下文中的用法。
2. 單元測試 vs. 整合測試
維基百科是這麼說 單元測試 的:
> 在計算機程式設計中,單元測試是一種軟體測試方法,用以測試原始碼的單個單元、一個或多個計算機程式模組的集合以及相關的控制資料、使用過程和操作過程,以確定它們是否適合使用。
整合測試:
> “整合測試(有時也稱整合和測試,縮寫為 I&T)是軟體測試的一個階段,在這個階段中,各個軟體模組被組合在一起來進行測試。”
簡而言之,當我們在做單元測試時,只是測試了一個程式碼單元,每次只測試一個方法,不包括與正測試元件相互動的其他所有元件。
另一方面,在整合測試中,我們測試各元件之間的整合。**由於單元測試,我們可知這些元件行為與所需一致,但不清楚它們是如何在一起工作的。**這就是整合測試的職責。
3. Java 單元測試
所有 Java 開發者都知道 JUnit 是執行單元測試的主要框架。它提供了許多註解來對期望進行斷言。
Hamcrest 是一個用於軟體測試的附加框架。Hamcrest 允許使用現有的 matcher 類來檢查程式碼中的條件,還允許自定義 matcher 實現。要在 JUnit 中使用 Hamcrest matcher,必須使用 assertThat
在這裡,你可以看到使用這兩種框架的簡單測試:
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 > > 作者:Marco Giglione > > 譯者:萬想
9月福利,關注公眾號 後臺回覆:004,領取8月翻譯集錦! 往期福利回覆:001,002, 003即可領取!
</manga></manga></manga></list<manga></list<manga></ma