在 SpringBoot 中進行單元測試

1. 單元測試概述

最小的可測試的單元就是單元測試龙宏,可以是一個函數明垢,一個類狐赡。

1.1 為什么需要單元測試

  • 節(jié)省測試時間
    測試一個最小單元是否有邏輯問題盒粮,無需到測試環(huán)境中去(比如創(chuàng)建數據庫,創(chuàng)建文件等一些麻煩且耗時的操作)嵌巷。
  • 防止回歸
    我們寫的東西不會破壞已有的功能萄凤,確保我們的修改和設計不會破壞已有的功能。比如你修改了一個類搪哪,這個類會被很多其他的類使用靡努,這樣可能會造成一些問題。所以單元測試在代碼重構上相當重要晓折。
  • 提高代碼質量
  • 保證行為的正確性
    比如你輸入了一個異常的數惑朦,就需要拋異常。

1.2 什么是好的單元測試

  • 快速:對成熟項目進行數千次單元測試,這很常見漓概。應花非常少的時間來運行單元測試漾月。
  • 獨立:單元測試是獨立的,可以單獨運行,并且不依賴文件系統(tǒng)或數據庫等任何外部因素。如果真的連數據庫我們叫它集成測試胃珍。
  • 可重復:運行單元測試的結果應該保持一致梁肿,也就是說,如果在運行期間不更改任何內容觅彰,總是返回相同的結果吩蔑。
  • 自檢查:測試應該能夠在沒有任何人工交互的情況下,自動檢測測試是否通過填抬。

1.3 測試用例命名

理想情況下,包括一下三個部分:

  • 要測試方法的名稱
  • 測試的方案
  • 調用方案時的預期行為

我們至少包含前兩條,比如:
testGetUserInfoByUserIdWithInvalidUserId()
testGetUserInfoByUserId() -> happy case

1.4 AAA(Arrange, Act, Assert) pattern

Arrange-Act-Assert是單元測試的常見模式
包括三個操作:

  • Arrange:安排好所有先要條件和輸入,根據需要進行創(chuàng)建和設置厌秒。
  • Act:對要測試的對象或者方法進行調用擅憔。
  • Assert:斷言結果是否按預期進行鸵闪。

2. 在 SpringBoot中進行單元測試

我們從文檔中找到依賴,發(fā)現依賴中有exclusion蚌讼,這是因為使用JUnit 5, 就必須exclude JUnit 4个榕。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

2.1 一些用于快速入門的簡單例子

public class AddManager {
    public int add(int number) {
        return number += 1;
    }
}
public class AddManagerTest {
    private AddManager addManager = new AddManager();

    @Test
    void testAdd() {
        // Arrange
        int number = 100;

        // Act
        int result = addManager.add(number);

        // Assert
        assertEquals(101, result);
    }
}

如果將101改成80篡石,則會出現以下的報錯

org.opentest4j.AssertionFailedError: 
Expected :80
Actual   :101

如果在每個方法執(zhí)行之前我們要執(zhí)行一些操作(比如初始化),就可以使用@BeforeEach注解西采。同理還有@AfterEach(主要用于一些拆卸操作)械馆。

public class AddManagerTest {
    private AddManager addManager;

    @BeforeEach
    void setup() {
        addManager = new AddManager();
    }

    @AfterEach
    void teardown() {
        // .....
    }
    // ......
}

下面是一個真實的例子。我們要去測試Manager層的UserInfoManager類珊搀。以下是一些解釋尾菇。

  • UserInfoMapper 是使用 MyBatis 寫一個接口,用于查詢數據庫并返回用戶信息劳淆,在源碼中沒有該接口的實現類千埃。
  • 其他所有的類都有對應的實現類放可,可以拿來直接用朝刊。
  • 如果用戶為空,則拋出 ResourceNotFoundException 異常冯挎。

該例子的所有類依賴關系如下所示咙鞍。

UserInfoManage //有實現類 UserInfoManageImpl
└── UserInfoDao //有實現類 UserInfoDaoImpl
    └── userInfoMapper //無實現類,只是個接口
└── UserInfoP2CConverter //有實現類 UserInfoP2CConverter

那么問題來了孵奶,UserInfoMapper是個接口蜡峰,無法直接生成實例,而 UserInfoDao 又依賴 UserInfoMapper载绿,怎么去創(chuàng)造這個實例呢油航?方法其實很簡單我們在test/java/com/.../utils下創(chuàng)建一些測試會使用的到的工具類。因為 UserInfoMapper 的作用就是返回用戶信息冀自,我們可以直接返回一些假的數秒啦。具體地余境,我們創(chuàng)建 UserInfoMapperTestImpl 類去繼承 UserInfoMapper 作為它的一個實現類,完成它的功能含末。

public class UserInfoMapperTestImpl implements UserInfoMapper {

    @Override
    public UserInfo getUserInfoById(long id) {
        return id > 0 ? UserInfo.builder()
                .username("admin")
                .password("admin")
                .createTime(LocalDate.now())
                .id(1L)
                .build() : null;
    }
}

如下是單元測試的代碼即舌。在 Assert 階段我們分別使用了 JUnit 5 和 AssertJ 中的不同方法去實現顽聂,AssertJ看其來更加清晰。一般地蜜葱,如果測試一些會拋出異常的函數耀石,我們將 Act 和 Assert 寫在一起,用 assertThrows 方法揭鳞。

public class UserInfoManagerTest2 {
    private UserInfoManager userInfoManager;

    @BeforeEach
    void setup() {
        UserInfoP2CConverter userInfoP2CConverter = new UserInfoP2CConverter();
        UserInfoMapper userInfoMapper = new UserInfoMapperTestImpl();
        UserInfoDao userInfoDao = new UserInfoDaoImpl(userInfoMapper);
        userInfoManager = new UserInfoManagerImpl(userInfoDao, userInfoP2CConverter);
    }

    @Test
    void testGetUserInfoById() {
        // Arrange
        long userId = 1L;

        // Act
        UserInfo userInfo = userInfoManager.getUserInfoById(userId);

        // Assert with JUnit 5
        assertEquals("admin", userInfo.getUsername());
        assertEquals("admin", userInfo.getPassword());
        assertEquals(userId, userInfo.getId());

        // Assert With AssertJ
        assertThat(userInfo).isNotNull()
                .hasFieldOrPropertyWithValue("id", userId)
                .hasFieldOrPropertyWithValue("username", "admin")
                .hasFieldOrPropertyWithValue("password", "admin");
    }

    @Test
    void testGetUserInfoByIdWithInvalidUserId() {
        // Arrange
        long userId = -1L;

        // Act & Assert
        assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));
    }
}

2.2 引入 Mockito 完善單元測試

上文的依賴還不夠復雜鲁驶,如果依賴非常的復雜舞骆,我們難道要一個個造 testImp(test place holder) 嗎?是否可以直接模擬這些復雜函數的行為呢脆霎?比如:

when xxx case 
call UserInfoDao.getUserInfoById()
return xxx value or throw xxx exception

2.2.1 Mockito的簡單使用

Mockito 就是完成以上需求的睛蛛,以下是 Mockito 的簡單使用胧谈。

 LinkedList mockedList = mock(LinkedList.class);

 //stubbing
 when(mockedList.get(0)).thenReturn("first");
 when(mockedList.get(1)).thenThrow(new RuntimeException());

 //following prints "first"
 System.out.println(mockedList.get(0));

 //following throws runtime exception
 System.out.println(mockedList.get(1));

 //following prints "null" because get(999) was not stubbed
 System.out.println(mockedList.get(999));

 //Although it is possible to verify a stubbed invocation, usually it's just redundant
 //If your code cares what get(0) returns, then something else breaks (often even before verify() gets executed).
 //If your code doesn't care what get(0) returns, then it should not be stubbed.
 verify(mockedList).get(0);

Mockito 以 equals() 方法驗證參數菱肖。有時,當需要額外的靈活性時场仲,可以使用參數匹配器退疫。參數匹配器褒繁,只有兩種形式 anyX() 或是 eq()。

 //可以用anyInt()燕差,表示任何int
 when(mockedList.get(anyInt())).thenReturn("element");

 //following prints "element"
 System.out.println(mockedList.get(999));

 //我們也可以在verify的時候用參數匹配
 verify(mockedList).get(anyInt());

//如果你正在使用的是參數匹配器俊抵,所有參數都必須由匹配器提供徽诲。
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));
//above is correct - eq() is also an argument matcher

verify(mock).someMethod(anyInt(), anyString(), "third argument");
//above is incorrect - exception will be thrown because third argument is given without an argument matcher.

還可以驗證多次調用的函數

verify(mockedList, times(3)).add("three times");
verify(mockedList, never()).add("never happened");
verify(mockedList, atMostOnce()).add("once");
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("three times");
verify(mockedList, atMost(5)).add("three times");

subbing 一些拋出異常的函數

doThrow(new RuntimeException()).when(mockedList).clear();

subbing 的兩種寫法

// doNothing doThrow 只能這樣寫
doReturn("Hello World").when(mockList).get(1)
when(mockedList.get(1)).thenReturn("Hello World");

2.2.2 使用 Mockito 改寫例子

除了用 mock() 的方式外谎替,我們還可以使用注解 @Mock 的方式進行mock,值得注意的是使用注解的方式必須初始化MockitoAnnotations.initMocks(this)(這是老版本的寫法挫掏,目前已經廢棄秩命,這里提供新的寫法)弃锐。可以看到使用了 Mockito 后我們無需再為一些接口創(chuàng)建實現類了剧蚣,我們更加注重需要 mock 的這個函數到底該完成了什么樣的功能旋廷。

public class UserInfoManagerTest {
    private UserInfoManager userInfoManager;

    @Mock
    private UserInfoDao userInfoDao;

    @BeforeEach
    void setup() {
        MockitoAnnotations.initMocks(this);
        userInfoManager = new UserInfoManagerImpl(userInfoDao, new UserInfoP2CConverter());
    }

    @Test
    public void testGetUserInfoById() {
        long userId = 1L;
        String username = "admin";
        String password = "admin";
        LocalDate createTime = LocalDate.now();

        val userInfo = UserInfo.builder()
                .id(userId)
                .username(username)
                .password(password)
                .createTime(createTime)
                .build();

        doReturn(userInfo).when(userInfoDao).getUserInfoById(userId);

        val result = userInfoManager.getUserInfoById(userId);
        assertEquals(userId, result.getId());
        assertEquals("admin", result.getUsername());
        assertEquals("admin", result.getPassword());

        verify(userInfoDao).getUserInfoById(eq(userId));
    }

    @Test
    public void testGetUserInfoByIdWithInvalidParameter() {
        long userId = -1L;

        doReturn(null).when(userInfoDao).getUserInfoById(userId);

        assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));

        verify(userInfoDao).getUserInfoById(eq(userId));
    }
}

controller層的測試有些不一樣饶碘。按我們前面的做法扎运,在arrange的時候,我們直接 new 一個 controller 的實例用于測試测蹲,這樣是不能測試 MVC 的一些特性(返回的一些東西)鬼吵。這里我們先舉兩個例子齿椅。
例1. 假設有這樣一個GreetingController。

@RestController
public class GreetingController {
    @GetMapping("/greeting")
    public String greeting(@RequestParam("name") String name) {
        return "Hello " + name;
    }
}

Spring 自帶了用于測試 MVC 的 MockMvc 類示辈。生成 MockMvc 需要用MockMvcBuilders 的 standaloneSetup 方法遣蚀。MockMvc 的 perform 方法用于執(zhí)行一個請求并返回一個 ResultActions 類,該類型允許對結果鏈式操作操作险耀,例如斷言期望甩牺。考慮到 java 中有很多的 get急但、status搞乏、content 方法查描,這里給出具體的方法(千萬不要 import 錯了):
MockMvcRequestBuilders.get()
MockMvcResultMatchers.status()
MockMvcResultMatchers.content()

public class GreetingControllerTest {
    private MockMvc mockMvc;

    @BeforeEach
    public void setup() {
        mockMvc = MockMvcBuilders.standaloneSetup(new GreetingController()).build();
    }

    @Test
    void testGreeting() throws Exception {
        mockMvc.perform(get("/greeting").param("name", "admin"))
                .andExpect(status().isOk())
                .andExpect(content().string("Hello admin"));
    }
}

例2. 另有一個 UserController,如果接受到的 userId 小于等于0則拋出 InvalidParameterException匀油,而我們用 @RestControllerAdvice 對異常進行了統(tǒng)一處理(在 SpringBoot 中的異常處理)敌蚜。我們在 MockMvcBuilders 創(chuàng)建MockMvc 時設置Controller的增強(setControllerAdvice)窝爪。

@RestController
@Slf4j
public class UserController {
    private final UserInfoManager userInfoManager;
    private final UserInfoC2SConverter userInfoC2SConverter;

    @Autowired
    public UserController(UserInfoManager userInfoManager, UserInfoC2SConverter userInfoC2SConverter) {
        this.userInfoManager = userInfoManager;
        this.userInfoC2SConverter = userInfoC2SConverter;
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> getUserInfoById(@PathVariable("id") long id) {
        if (id <= 0L) {
            throw new InvalidParameterException(String.format("User id %s is invalid", id));
        }
        val userInfo = userInfoManager.getUserInfoById(id);
        return ResponseEntity.ok(userInfoC2SConverter.convert(userInfo));
    }
}

具體寫法如下所示蒲每,大同小異。親測贫奠,如果不設置Conroller增強望蜡,則會報奇怪的錯誤脖律。

public class UserControllerTest {                                                                                                                                                                                                                        
    private MockMvc mockMvc;

    @Mock                                                                                                                                                                                                                                                
    public UserInfoManager userInfoManager;

    @BeforeEach                                                                                                                                                                                                                                          
    void setup() {                                                                                                                                                                                                                                       
        MockitoAnnotations.initMocks(this);                                                                                                                                                                                                              
        mockMvc = MockMvcBuilders.standaloneSetup(new UserController(userInfoManager, new UserInfoC2SConverter()))                                                                                                                                       
                .setControllerAdvice(new GlobalExceptionHandler())                                                                                                                                                                                       
                .build();

    }

    @AfterEach                                                                                                                                                                                                                                           
    void teardown() {                                                                                                                                                                                                                                    
        reset(userInfoManager);                                                                                                                                                                                                                          
    }

    @Test                                                                                                                                                                                                                                                
    void testGetUserInfoById() throws Exception {                                                                                                                                                                                                        
        // Arrange                                                                                                                                                                                                                                       
        val userId = 100L;                                                                                                                                                                                                                               
        val username = "admin";                                                                                                                                                                                                                          
        val password = "admin";

        val userInfoInCommon = com.lazyben.accounting.model.common.UserInfo.builder()                                                                                                                                                                    
                .id(userId)                                                                                                                                                                                                                              
                .username(username)                                                                                                                                                                                                                      
                .password(password)                                                                                                                                                                                                                      
                .build();

        doReturn(userInfoInCommon).when(userInfoManager).getUserInfoById(userId);

        // Act & Assert                                                                                                                                                                                                                                  
        mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))                                                                                                                                                                                        
                .andExpect(content().string("{\"id\":100,\"username\":\"admin\",\"password\":null}"))                                                                                                                                                    
                .andExpect(content().contentType("application/json"))                                                                                                                                                                                    
                .andExpect(status().isOk());

        verify(userInfoManager).getUserInfoById(anyLong());                                                                                                                                                                                              
    }

    @Test                                                                                                                                                                                                                                                
    void testGetUserInfoByIdWithInvalidUserId() throws Exception {                                                                                                                                                                                       
        // Arrange                                                                                                                                                                                                                                       
        val userId = -1L;

        doThrow(new InvalidParameterException(String.format("User %s is not found", userId)))                                                                                                                                                            
                .when(userInfoManager)                                                                                                                                                                                                                   
                .getUserInfoById(anyLong());

        // Act & Assert                                                                                                                             {"code":"INVALID_PARAMETER","message":"User id -1 is invalid","statusCode":400,"errorType":"Client"} 
        mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))                                                                                                                                                                                        
                .andExpect(status().is4xxClientError())                                                                                                                                                                                                  
                .andExpect(content().contentType("application/json"))                                                                                                                                                                                    
                .andExpect(content().string("{\"code\":\"INVALID_PARAMETER\",\"message\":\"User id -1 is invalid\",\"statusCode\":400,\"errorType\":\"Client\"}"));                                                                                      
    }                                                                                                                                                                                                                                                    
}

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市弊决,隨后出現的幾起案子魁淳,更是在濱河造成了極大的恐慌,老刑警劉巖昆稿,帶你破解...
    沈念sama閱讀 211,123評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件溉潭,死亡現場離奇詭異少欺,居然都是意外死亡,警方通過查閱死者的電腦和手機畏陕,發(fā)現死者居然都...
    沈念sama閱讀 90,031評論 2 384
  • 文/潘曉璐 我一進店門惠毁,熙熙樓的掌柜王于貴愁眉苦臉地迎上來崎页,“玉大人,你說我怎么就攤上這事洞豁』母” “怎么了?”我有些...
    開封第一講書人閱讀 156,723評論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長挑辆。 經常有香客問我孝情,道長箫荡,這世上最難降的妖魔是什么渔隶? 我笑而不...
    開封第一講書人閱讀 56,357評論 1 283
  • 正文 為了忘掉前任间唉,我火速辦了婚禮,結果婚禮上低矮,老公的妹妹穿的比我還像新娘被冒。我一直安慰自己,他們只是感情好良姆,可當我...
    茶點故事閱讀 65,412評論 5 384
  • 文/花漫 我一把揭開白布玛追。 她就那樣靜靜地躺著闲延,像睡著了一般。 火紅的嫁衣襯著肌膚如雪陆馁。 梳的紋絲不亂的頭發(fā)上合愈,一...
    開封第一講書人閱讀 49,760評論 1 289
  • 那天佛析,我揣著相機與錄音,去河邊找鬼寸莫。 笑死,一個胖子當著我的面吹牛桃纯,可吹牛的內容都是我干的。 我是一名探鬼主播盐数,決...
    沈念sama閱讀 38,904評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼玫氢,長吁一口氣:“原來是場噩夢啊……” “哼壮锻!你這毒婦竟也來了涮阔?” 一聲冷哼從身側響起敬特,我...
    開封第一講書人閱讀 37,672評論 0 266
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎辣之,沒想到半個月后皱炉,有當地人在樹林里發(fā)現了一具尸體合搅,經...
    沈念sama閱讀 44,118評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,456評論 2 325
  • 正文 我和宋清朗相戀三年康铭,在試婚紗的時候發(fā)現自己被綠了从藤。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片锁蠕。...
    茶點故事閱讀 38,599評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡荣倾,死狀恐怖,靈堂內的尸體忽然破棺而出鳖孤,到底是詐尸還是另有隱情,我是刑警寧澤苏揣,帶...
    沈念sama閱讀 34,264評論 4 328
  • 正文 年R本政府宣布平匈,位于F島的核電站,受9級特大地震影響忍燥,放射性物質發(fā)生泄漏隙姿。R本人自食惡果不足惜输玷,卻給世界環(huán)境...
    茶點故事閱讀 39,857評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望机久。 院中可真熱鬧赔嚎,春花似錦、人聲如沸侠畔。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至揖盘,卻和暖如春锌奴,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背箕慧。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評論 1 264
  • 我被黑心中介騙來泰國打工颠焦, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人伐庭。 一個月前我還...
    沈念sama閱讀 46,286評論 2 360
  • 正文 我出身青樓圾另,卻偏偏與公主長得像,于是被迫代替她去往敵國和親去件。 傳聞我的和親對象是個殘疾皇子饺著,可洞房花燭夜當晚...
    茶點故事閱讀 43,465評論 2 348

推薦閱讀更多精彩內容