Spring單元測試教程(JUnit5+Mockito)

KerryWu發表於2021-10-13

1. 測試

如果專案組有測試團隊,最常接觸的概念有:功能測試、迴歸測試、冒煙測試等,但這些都是由測試人員發起的。

開發人員寫的常常是“單元測試”,但其實可以細分成 單元測試整合測試 兩個。

劃分的原因拿常見的 Spring IoC 舉例。Spring 不同Bean之間相互依賴,例如某API業務邏輯中會依賴不同模組的 Service,Service 方法中又可能依賴不同的 Dao 層方法,甚至還會通過 RPC、HTTP 呼叫外部服務方法。這給我們寫測試用例帶來了難度,本來只想測試某個方法的功能,卻要考慮一連串的依賴關係。

1.1. 單元測試

單元測試:是指對軟體中的最小可測試單元進行檢查和驗證。

通常任何軟體都會劃分為不同的模組和元件。單獨測試一個元件時,我們叫做單元測試。單元測試用於驗證相關的一小段程式碼是否正常工作。單元測試不是用於發現應用程式範圍內的 bug,或者回歸測試的 bug,而是分別檢測每個程式碼片段。

單元測試不驗證應用程式程式碼是否和外部依賴正常工作。它聚焦與單個元件並且 Mock 所有和它互動的依賴。例如,方法中呼叫發簡訊的服務,以及和資料庫的互動,我們只需要 Mock 假執行即可,畢竟測試的焦點在當前方法上。

單元測試的特點:

  • 不依賴任何模組。
  • 基於程式碼的測試,不需要在 ApplicationContext 中執行。
  • 方法執行快,500ms以內(也和不啟動 Spring 有關)。
  • 同一單元測試可重複執行N次,並每次執行結果相同。

1.2. 整合測試

整合測試:在單元測試的基礎上,將所有模組按照設計要求組裝成為子系統或系統,進行整合測試。

整合測試主要用於發現使用者端到端請求時不同模組互動產生的問題。整合測試範圍可以是整個應用程式,也可以是一個單獨的模組,取決於要測試什麼。

在整合測試中,我們應該聚焦於從控制器層到持久層的完整請求。應用程式應該執行嵌入服務(例如:Tomcat)以建立應用程式上下文和所有 bean。這些 bean 有的可能會被 Mock 覆蓋。

整合測試的特點:

  • 整合測試的目的是測試不同的模組一共工作能否達到預期。
  • 應用程式應該在 ApplicationContext 中執行。Spring boot 提供 @SpringBootTest 註解建立執行上下文。
  • 使用 @TestConfiguration 等配置測試環境。

2. 測試框架

2.1. spring-boot-starter-test

SpringBoot中有關測試的框架,主要來源於 spring-boot-starter-test。一旦依賴了spring-boot-starter-test,下面這些類庫將被一同依賴進去:

  • JUnit:java測試事實上的標準。
  • Spring Test & Spring Boot Test:Spring的測試支援。
  • AssertJ:提供了流式的斷言方式。
  • Hamcrest:提供了豐富的matcher。
  • Mockito:mock框架,可以按型別建立mock物件,可以根據方法引數指定特定的響應,也支援對於mock呼叫過程的斷言。
  • JSONassert:為JSON提供了斷言功能。
  • JsonPath:為JSON提供了XPATH功能。
測試環境自定義Bean
  • @TestComponent:該註解是另一種@Component,在語義上用來指定某個Bean是專門用於測試的。該註解適用於測試程式碼和正式混合在一起時,不載入被該註解描述的Bean,使用不多。
  • @TestConfiguration:該註解是另一種@TestComponent,它用於補充額外的Bean或覆蓋已存在的Bean。在不修改正式程式碼的前提下,使配置更加靈活。

2.2. JUnit

前者說了,JUnit 是一個Java語言的單元測試框架,但同樣可用於整合測試。當前最新版本是 JUnit5。

常見區別有:

  • JUnit4 所需 JDK5+ 版本即可,而 JUnit5 需 JDK8+ 版本,因此支援很多 Lambda 方法。
  • JUnit 4將所有內容捆綁到單個jar檔案中。Junit 5由3個子專案組成,即JUnit Platform,JUnit Jupiter和JUnit Vintage。核心是JUnit Jupiter,它具有所有新的junit註釋和TestEngine實現,以執行使用這些註釋編寫的測試。而JUnit Vintage包含對JUnit3、JUnit4的相容,所以spring-boot-starter-test新版本pom中往往會自動exclusion它。
  • SpringBoot 2.2.0 開始引入 JUnit5 作為單元測試預設庫,在 SpringBoot 2.2.0 之前,spring-boot-starter-test 包含了 JUnit4 的依賴,SpringBoot 2.2.0 之後替換成了 Junit Jupiter。

JUnit5 和 JUnit4 在註解上的區別在於:

功能JUnit4JUnit5
宣告一種測試方法@Test@Test
在當前類中的所有測試方法之前執行@BeforeClass@BeforeAll
在當前類中的所有測試方法之後執行@AfterClass@AfterAll
在每個測試方法之前執行@Before@BeforeEach
在每個測試方法之後執行@After@AfterEach
禁用測試方法/類@Ignore@Disabled
測試工廠進行動態測試NA@TestFactory
巢狀測試NA@Nested
標記和過濾@Category@Tag
註冊自定義擴充套件NA@ExtendWith
RunWith 和 ExtendWith

在 JUnit4 版本,在測試類加 @SpringBootTest 註解時,同樣要加上 @RunWith(SpringRunner.class)才生效,即:

@SpringBootTest
@RunWith(SpringRunner.class)
class HrServiceTest {
...
}

但在 JUnit5 中,官網告知 @RunWith 的功能都被 @ExtendWith 替代,即原 @RunWith(SpringRunner.class) 被同功能的 @ExtendWith(SpringExtension.class) 替代。但 JUnit5 中 @SpringBootTest 註解中已經預設包含了 @ExtendWith(SpringExtension.class)。

因此,在 JUnit5 中只需要單獨使用 @SpringBootTest 註解即可。其他需要自定義擴充的再用 @ExtendWith,不要再用 @RunWith 了。

2.3. Mockito

測試驅動的開發(TDD)要求我們先寫單元測試,再寫實現程式碼。在寫單元測試的過程中,我們往往會遇到要測試的類有很多依賴,這些依賴的類/物件/資源又有別的依賴,從而形成一個大的依賴樹。而 Mock 技術的目的和作用是模擬一些在應用中不容易構造或者比較複雜的物件,從而把測試與測試邊界以外的物件隔離開。

Mock 框架有很多,除了傳統的 EasyMock、Mockito以外,還有PowerMock、JMock、JMockit等。這裡選用 Mockito ,是因為 Mockito 在社群流行度較高,而且是 SpringBoot 預設整合的框架。

Mockito 框架中最核心的兩個概念就是 MockStub。測試時不是真正的操作外部資源,而是通過自定義的程式碼進行模擬操作。我們可以對任何的依賴進行模擬,從而使測試的行為不需要任何準備工作或者不具備任何副作用。

當我們在測試時,如果只關心某個操作是否執行過,而不關心這個操作的具體行為,這種技術稱為 mock。比如我們測試的程式碼會執行傳送郵件的操作,我們對這個操作進行 mock;測試的時候我們只關心是否呼叫了傳送郵件的操作,而不關心郵件是否確實傳送出去了。

另一種情況,當我們關心操作的具體行為,或者操作的返回結果的時候,我們通過執行預設的操作來代替目標操作,或者返回預設的結果作為目標操作的返回結果。這種對操作的模擬行為稱為 stub(打樁)。比如我們測試程式碼的異常處理機制是否正常,我們可以對某處程式碼進行 stub,讓它丟擲異常。再比如我們測試的程式碼需要向資料庫插入一條資料,我們可以對插入資料的程式碼進行stub,讓它始終返回1,表示資料插入成功。

推薦一個 Mockito 中文文件

mock 和 spy 的區別

mock方法和spy方法都可以對物件進行mock。但是前者是接管了物件的全部方法,而後者只是將有樁實現(stubbing)的呼叫進行mock,其餘方法仍然是實際呼叫。

如下例,因為只mock了List.size()方法。如果mockList是通過mock生成的,List的add、get等其他方法都失效,返回的資料都為null。但如果是通過spy生成的,則驗證正常。

平時開發過程中,我們通常只需要mock類的某些方法,用spy即可。

@Test
    void mockAndSpy() {
        List<String> mockList = Mockito.mock(List.class);
        // List<String> mockList = Mockito.spy(new ArrayList<>());
        Mockito.when(mockList.size())
                .thenReturn(100);

        mockList.add("A");
        mockList.add("B");
        Assertions.assertEquals("A", mockList.get(0));
        Assertions.assertEquals(100, mockList.size());
    }

3. 示例

3.1. 單元測試示例

因為 JUnit5、Mockito 都是 spring-boot-starter-test 預設依賴的,所以 pom 中無需引入其他特殊依賴。先寫個簡單的 Service 層方法,通過兩張表查詢資料。
HrService.java

@AllArgsConstructor
@Service
public class HrService {
    private final OrmDepartmentDao ormDepartmentDao;
    private final OrmUserDao ormUserDao;

    List<OrmUserPO> findUserByDeptName(String deptName) {
        return ormDepartmentDao.findOneByDepartmentName(deptName)
                .map(OrmDepartmentPO::getId)
                .map(ormUserDao::findByDepartmentId)
                .orElse(Collections.emptyList());
    }
}
IDEA 建立測試類

接下來針對該 Service 類建立測試類,我們使用的開發工具是 IDEA。點進當前類,右鍵->Go To->Test->Create New Test,在 Testing library 中選擇 Junit5,則在對應目錄生成測試類和方法。

HrServiceTest.java

@ExtendWith(MockitoExtension.class)
class HrServiceTest {
    @Mock
    private OrmDepartmentDao ormDepartmentDao;
    @Mock
    private OrmUserDao ormUserDao;
    @InjectMocks
    private HrService hrService;

    @DisplayName("根據部門名稱,查詢使用者")
    @Test
    void findUserByDeptName() {
        Long deptId = 100L;
        String deptName = "行政部";
        OrmDepartmentPO ormDepartmentPO = new OrmDepartmentPO();
        ormDepartmentPO.setId(deptId);
        ormDepartmentPO.setDepartmentName(deptName);
        OrmUserPO user1 = new OrmUserPO();
        user1.setId(1L);
        user1.setUsername("001");
        user1.setDepartmentId(deptId);
        OrmUserPO user2 = new OrmUserPO();
        user2.setId(2L);
        user2.setUsername("002");
        user2.setDepartmentId(deptId);
        List<OrmUserPO> userList = new ArrayList<>();
        userList.add(user1);
        userList.add(user2);

        Mockito.when(ormDepartmentDao.findOneByDepartmentName(deptName))
                .thenReturn(
                        Optional.ofNullable(ormDepartmentPO)
                                .filter(dept -> deptName.equals(dept.getDepartmentName()))
                );
        Mockito.doReturn(
                userList.stream()
                        .filter(user -> deptId.equals(user.getDepartmentId()))
                        .collect(Collectors.toList())
        ).when(ormUserDao).findByDepartmentId(deptId);

        List<OrmUserPO> result1 = hrService.findUserByDeptName(deptName);
        List<OrmUserPO> result2 = hrService.findUserByDeptName(deptName + "error");

        Assertions.assertEquals(userList, result1);
        Assertions.assertEquals(Collections.emptyList(), result2);
    }

因為單元測試不用啟動 Spring 容器,則無需加 @SpringBootTest,因為要用到 Mockito,只需要自定義擴充 MockitoExtension.class 即可,依賴簡單,執行速度更快。

可以明顯看到,單元測試寫的程式碼,怎麼是被測試程式碼長度的好幾倍?其實單元測試的程式碼長度比較固定,都是造資料和打樁,但如果針對越複雜邏輯的程式碼寫單元測試,還是越划算的。

3.2. 整合測試示例

還是那個方法,如果使用Spring上下文,真實的呼叫方法依賴,可直接用下列方式:

@SpringBootTest
class HrServiceTest {
    @Autowired
    private HrService hrService;

    @DisplayName("根據部門名稱,查詢使用者")
    @Test
    void findUserByDeptName() {
        List<OrmUserPO> userList = hrService.findUserByDeptName("行政部");
        Assertions.assertTrue(userList.size() > 0);
    }  
}

還可以使用@MockBean@SpyBean替換Spring上下文中的對應的Bean:

@SpringBootTest
class HrServiceTest {
    @Autowired
    private HrService hrService;
    @SpyBean
    private OrmDepartmentDao ormDepartmentDao;

    @DisplayName("根據部門名稱,查詢使用者")
    @Test
    void findUserByDeptName() {
        String deptName="行政部";
        OrmDepartmentPO ormDepartmentPO = new OrmDepartmentPO();
        ormDepartmentPO.setDepartmentName(deptName);
        Mockito.when(ormDepartmentDao.findOneByDepartmentName(ArgumentMatchers.anyString()))
                .thenReturn(Optional.of(ormDepartmentPO));
        List<OrmUserPO> userList = hrService.findUserByDeptName(deptName);
        Assertions.assertTrue(userList.size() > 0);
    }
}
小提示:@SpyBean 和 spring boot data 的問題

當用 @SpyBean 新增到 spring data jpa 的dao層上時(繼承 JpaRepository 的介面),會無法啟動容器,報錯 org.springframework.beans.factory.BeanCreationException: Error creating bean with name。包括 mongo 等 spring data 都會有此問題,是 spring boot 官方不支援,可檢視 Issues-7033,已在 spring boot 2.5.3 版本修復。

相關文章