1. 程式人生 > >Spring Boot 的單元測試和整合測試

Spring Boot 的單元測試和整合測試

學習如何使用本教程中提供的工具,並在 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. 介紹我們的案例

讓我們來寫一個簡單的程式吧。其目的是為漫畫提供一個基本的搜尋引擎。

Kenshiro vs Roul

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>&gt; searchASync(@PathVariable(name = "title") String title) {
        return CompletableFuture.completedFuture(mangaService.getMangasByTitle(title));
    }
    
    @RequestMapping(value = "/sync/{title}", method = RequestMethod.GET)
    public @ResponseBody <list<manga>&gt; 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 -&gt; p.getTitle()
                .toLowerCase()
                .contains("ken"));
    }
    
}

5.2. 對 MangaController 進行單元測試

正如在 MangaService 的單元測試中所做的那樣,我們需要隔離元件。在這種情況下,我們需要模擬 MangaService Bean。

然後,我們還有一個問題……Controller 部分是管理 HttpRequest 的系統的一部分,因此我們需要一個系統來模擬這種行為,而非啟動完整的 HTTP 伺服器。

MockMvc 是執行該操作的 Spring 類。其可以以不同的方式進行設定:

  1. 使用 Standalone Context
  2. 使用 WebApplication Context
  3. 讓 Spring 通過在測試類上使用 @SpringBootTest、@AutoConfigureMockMvc 這些註解來載入所有的上下文,以實現自動裝配
  4. 讓 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&lt;&gt;();
        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即可領取!

img</manga></manga></manga></list<manga></list<manga></ma