在 Spring boot 上撰寫 API 與 Service 的單元測試 JUnit、Mockito

在 Spring boot 上撰寫 API 與 Service 的單元測試 JUnit、Mockito

2021, Dec 19    

軟體在開發時期常會一直疊代更新,為了確保功能正常並保證出產品質,測試是不可少的,而單元測試也是其中最小單位也較好實作的,後端的單元測試最基本要確保 API 的正常運作與回覆,深入一點要確保每個 Service 的功能運作正常,今天這篇將針對這兩部分來做個紀錄

JUnit

Junit 是一個 Java 的單元測試框架,可以透過簡單的 @Annotation 宣告來撰寫 Unit test,配合 Assertions 來斷言測試結果

Spring boot 的 Unit test 主要運行在 JUnit 上,透過加入 spring-boot-starter-test 的依賴,來把初始的測試環境架設好

Mockito

Mockito 是用來輔助進行單元測試的 mock 框架,可以用來模擬 method 被呼叫後的行為,包含返回值以及異常拋出,讓我們可以專注在測試案例上,也方便建立單一的測試環境

撰寫測試

  • IntelliJ IDEA 2021.3
  • Spring boot 2.5.2 版,預設 JUnit5
  • 加入依賴
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

建立路徑 /src/test/java 以及 /src/test/resources,將撰寫的單元測試放在 /src/test/javaresources 下則放希望在測試環境下套用的資源檔

基本測試

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class DemoTest {
    @Test
    public void oneEqualOne() {
        Assertions.assertEquals(1, 1);
    }

    @Test
    public void oneEqualTwo() {
        Assertions.assertNotEquals(1, 2);
    }
}

寫完測試後輸入指令 mvn clean test,就會開始執行測試,看到下面訊息表示執行完畢,並且會列出統計數據

[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
  • 不過如果是用 IntelliJ 的話可以直接在下面位置執行:

Alt

  • 這樣子會有比較詳細的執行結果,也會有階層式結構,結果一目瞭然:

Alt

Test Service

接下來要進入到 Spring boot 的測試階段,通常細部的功能都會被包裝在 Service 之中,所以就先從 Service 開始寫起吧。這邊的例子是一個用來產生以及驗證 JWT 的 Service:

@SpringBootTest(classes = JwtTokenService.class)
public class JwtTokenServiceTest {
    @Autowired
    private JwtTokenService jwtTokenService;

    @Test
    public void testInvalidJwt() {
        Assertions.assertThrows(AuthException.class, () -> {
            jwtTokenService.validateToken("test");
        });
    }

    @Test
    public void testWrongSignature() {
        Assertions.assertThrows(SignatureException.class, () -> {
            jwtTokenService.validateToken("eyJhbGciOiJIUzUxMiJ9.
            eyJleHAiOjE2MzkyMDQwMjYsInVzZXJuYW1lIjoib2FtLXJlcG9ydGVyIn0.
            Rx1rDcfZjxh55eemcvYMA1-E0Tt4V5PCH8LyZUsEaTNMIsHV
            O4CGQPh9WwHYk3xkcOJnMfyzOc0Mk6Kl9h3USg");
        });
    }

    @Test
    public void testExpiredToken() {
        Assertions.assertThrows(ExpiredJwtException.class, () -> {
            jwtTokenService.validateToken("eyJhbGciOiJIUzUxMiJ9.
            eyJleHAiOjEsInVzZXJuYW1lIjoib2FtLXJlcG9ydGVyIn0.
            _RJl8H4dOWpjYoOz1qyr_qnqSExFB0i7FqA8LAJwFSfvwhE4DS6
            YD7zXgpCqoZqROzaZryCJ2xF34ARMbPZtFw");
        });
    }

    @Test
    public void testGenerateAndValidToken() throws AuthException {
        JwtPayloadDto payloadDto = new JwtPayloadDto("test");
        TokenDto tokenDto = jwtTokenService.generateToken(payloadDto);
        Assertions.assertDoesNotThrow(() -> {
            jwtTokenService.validateToken(tokenDto.getToken());
        });
        Assertions.assertEquals(
            jwtTokenService.validateToken(tokenDto.getToken()).get("username"), "test"
        );
    }
}

首先是最重要的 @SpringBootTest(classes = JwtTokenService.class),這可以宣告這個測試是在 Spring boot 的環境下進行的,因此會建立起需要的 bean 來進行測試,並且使用 @Autowired 來取得實體,接下來的測試撰寫就像一般的程式一樣。

這邊有用到比較特別的 Assertions.assertThrows 可以用來斷言 callback 中拋出的 exception。

Test API

後端程式最常用的接入點 API 當然也是要經過測試比較好的,而要從 API 角度來測試的話寫法會稍有不同,會使用到 MockitoMockMvc 來完成

@SpringBootTest(classes = {UserController.class})
public class UserControllerTest {
    private MockMvc mockMvc;
    @Autowired
    private UserController userController;
    @MockBean
    private UserDaoService userDaoService;
    @MockBean
    private UserDao userDao;

    @BeforeEach
    public void setup() {
        this.mockMvc = MockMvcBuilders.standaloneSetup(this.userController)
                .setControllerAdvice(new GlobalExceptionHandler())
                .build();
        User user;
        user = new User();
        user.setId(1L);
        user.setName("Tony");
        user.setEmail("Tony@stark.org");
        Mockito.when(userDao.findById(1L)).thenReturn(Optional.of(user));
    }

    @Test
    public void testGetOneUser() throws Exception {
        ResultActions positiveCase = mockMvc.perform(get("/v1/user/1")
                                        .contentType(MediaType.APPLICATION_JSON));
        positiveCase.andExpect(status().isOk())
                .andExpect(jsonPath("name").value("Tony"));
    }

    @Test
    public void testGetUserA() throws Exception {
        ResultActions negativeCase = mockMvc.perform(get("/v1/user/A")
                                        .contentType(MediaType.APPLICATION_JSON));
        negativeCase.andExpect(status().isBadRequest())
                .andExpect(result -> Assertions.assertTrue(
                        result.getResolvedException() instanceof MethodArgumentTypeMismatchException));
    }

    @Test
    public void testGetUser2() throws Exception {
        ResultActions notFoundCase = mockMvc.perform(get("/v1/user/2")
                .contentType(MediaType.APPLICATION_JSON));
        notFoundCase.andDo(print());
        notFoundCase.andExpect(status().isNotFound())
                .andExpect(result -> Assertions.assertTrue(
                        result.getResolvedException() instanceof StatusException));
    }
}

MockMvc 有點像負責建立起與 Controller 的通道以及 Advice 等功能的套用與否,Mockito 則是用來模擬 method 的行為,由於我們在意的是 API 的行為而不希望牽涉到實際資料庫的內容,因此去模擬 DAO 以及 Service 的行為

這邊要特別注意的是,原先的 Controller 有注入 UserDao 以及 UserDaoService 那即使沒有使用到,在這裡也必須要注入或是 Mock,這樣 Controller 的注入才會成功

MockMvc 比較便利的是可以利用 andExpect 配合 MockMvcResultMatchers 的靜態方法來直接進行斷言,不需要寫太多程式碼


結語

一般的 Unit test 寫起來之後在每次 mvn 打包的時候都會執行一次,確保程式的執行結果都是正確的,所以至少要將 API 的 Unit test 寫好,而且基本的 Unit test 寫起來也非常容易理解,之後再針對細部的功能去了解各個 Unit test 該怎麼使用