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

Spring Boot中的單元和整合測試

瞭解如何在Spring Boot環境中編寫單元和整合測試,以及在本教程中為此提供便利的工具,本文還會提供一種工具來幫助我們寫單元和整合測試。

1 概述

在這篇文章中,我們將瞭解如何在Spring Boot環境中編寫測試單元和整合。您可以線上找到大量有關此主題的教程,但很難在一個頁面中找到所需的所有資訊。我經常注意到初級開發人員在單元和整合測試之間混淆,特別是在談到spring生態系統時,我會嘗試解釋說明在不同環境中使用的不同註釋的用法。

2. Unit vs. Integration tests

unit testing維基百科中的解釋:在計算機程式設計中,單元測試是一種軟體測試方法,通過該方法測試各個原始碼單元,一個或多個計算機程式模組的集合以及相關的控制資料,使用程式和操作程式,以確定它們是否適合使用。

Integration testing:整合測試(有時稱為整合和測試,縮寫為I&T)是軟體測試階段,其中各個軟體模組組合並作為一組進行測試。

簡單來說,當我們進行單元測試時,我們只測試一個程式碼單元,一次測試一個方法,排除對測試有影響的所有其他元件。

在另一方面的整合測試中,我們測試元件之間的整合。由於單元測試,我們知道元件的行為與所需的一樣,但我們不知道它們將如何完全工作。這是整合測試的責任。

3. Java Test Unit

所有Java開發人員都知道JUnitas是執行測試單元的主要框架。它提供了許多註釋來對期望做出斷言。
Hamcrest是另一個軟體測試框架。Hamcrest允許使用現有匹配器類檢查程式碼中的條件,還允許您定義自定義匹配器實現。要在JUnit中使用Hamcrest匹配器,您必須使用assertThat語句,後跟一個或多個匹配器。
在這裡,您可以看到使用兩個框架的簡單測試:

package edu.princeton.cs.algs4;

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.either(equalTo(3)).or(equalTo(4)))); assertThat(new Object(), not(sameInstance(new Object()))); } @Test public void testAssertTrue() { assertTrue("failure - should be true", true); } }

4. Introducing Our Example

讓我們編寫簡單的應用程式。這個想法是為漫畫提供一個基本的搜尋引擎。

4.1. Maven Dependencies

首先,我們必須給我們的專案新增一些依賴:

<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. Define the Model

我們的模型非常簡單;它只由兩個類組成:Manga和MangaResult。

4.2.1. Manga Class

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是一個包裝類,包含一個漫畫列表。

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. Implementing the 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. Implementing the Controller

列表的下一步是寫下暴露兩個端點的REST 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. Launching and Testing the System

mvn spring-boot:run

Then let’s try it:

Example of output:

{  
   "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. Unit Testing the Spring Boot Application

Spring boot提供一個很棒的類是測試變得簡單: @SpringBootTest annotation

可以在執行基於Spring Boot的測試的測試類上指定此批註。除常規Spring TestContext框架之外,還提供以下功能:

  • 當沒有定義特定的@ContextConfiguration(loader = …)時,使用SpringBootContextLoader作為預設的ContextLoader。
  • 在未使用巢狀的@Configuration和未指定顯式類時自動搜尋@SpringBootConfiguration。
  • 允許使用properties屬性定義自定義環境屬性。
  • 支援不同的web環境模型,包括啟動在定義或隨機埠上偵聽的完全執行的Web伺服器的功能。
  • 註冊TestRestTemplate和/或WebTestClient bean,以便在使用完全執行的Web伺服器的Web測試中使用。

我們在這裡測試基本上有兩個元件:MangaService和MangaController

5.1. Unit Testing 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. Unit Testing MangaController

正如在服務的單元測試中所做的那樣,我們需要隔離元件。在這種情況下,我們需要模擬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<>();
        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. Integration Testing the Spring Boot Application

對於整合測試,我們希望通過下游通訊檢查我們的主要元件。

6.1. Integration Testing of MangaService

這個測試非常簡單。我們不需要模擬任何東西因為我們只需要呼叫遠端的 mangas 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. Integration Testing of MangaController

測試與單元測試非常相似,但在這種情況下,我們不需要沒有模擬服務。

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儲存庫中找到所有內容。