標題
- 單元測試
- 衡量指標
- 具體測試
- 1、@Resource
- 2、@MockBean
- 3、@Test
- 4、Test模板
- 5、單測示例
- H2數據庫+JSON
- 1、使用方式
- AI輔助單測
- 使用方法
單元測試
單元測試一般指程序員在寫好代碼后,提交測試前,需要驗證自己的代碼是否可以正常工作,同時將自己的代碼邏輯驗證放入到項目中,方便驗證后續新需求的代碼不會影響到本次需求的邏輯。
基本原則:
單測應該是不依賴于任何外部依賴(如MySQL、Redis、RabbitMQ等),在自己本地可以直接執行的代碼。其中外部數據讀取以及RPC調用,應該在單測中進行模擬,而不是實際進行調用。
衡量指標
在實際開發過程種,往往會嚴格規定了測試的行覆蓋率和分支覆蓋率,對代碼有嚴格要求的大廠,會硬性規定提交代碼的覆蓋率,如果覆蓋率過低,會被拒絕部署上線。
- 行覆蓋率:被測試執行到的行數÷可執行總數
- 分支覆蓋率:被執行的分支÷總分支
其中,分支覆蓋率指的是在整個測試過程中,覆蓋的分支數量。如下:
func1(a,b){if(a>1){return 1;}else{return 0;}if(b>1){return 1;}else{return 0;}
}
只需要傳入(a=1,b=1),(a=2,b=2),就要達到分支覆蓋率100%,而不需要分別傳入四次進行測試。
具體測試
在上面的介紹中,應該知道了單測是不應該進行外部依賴的,那涉及的數據以及RPC調用函數應該如何進行模擬呢?這里剛好有一些好框架可以用來很方便的進行測試,我這里以Junit5+mockit為例進行介紹。
- Junit5
Junit5是一套用來單測的框架,主要功能是幫助用戶簡化測試流程。當需要進行測試的時候,如果不依賴框架,就需要自己寫main函數,每個運行單元都需要有自己的main函數,并且所有的日志都需要自己進行管理,非常不方便。而Junit和Idea進行了集成,使得測試可以直接點箭頭來測試具體的函數,并且有詳細的日志報告。 - mockit
在測試的過程中,需要對外部依賴進行模擬,這里采用最常用的mockit進行mock,支持對Spring的Bean等等進行mock。
這里可以給一個簡單的示例:
@Resource
OrderService orderServiceImpl;@MockBean
goodsRepository goodsRepository;@Test
public void testOrderService(){// arrange。。。//act。。。//assertResult。。。
}
幾乎所有的單測都可以按照這個結構編寫。第一次看到整個接口可能有點蒙,接下來我大概介紹一下大概的意思。
1、@Resource
同Spring的依賴注入,這里不做重復介紹。這個注解聲明的類,就是接下來要測試的類。
2、@MockBean
MockBean是Spring中Bean的模擬,這里的注解只是聲明的意思,表示這個接下來這個函數由測試類進行代理,他使用到的方法需要在后面進行聲明。
3、@Test
這個注解需要注釋在函數上,表示測試的基本單元。比如訂單服務中,有可正常情況,也有可能漏傳參數的情況,也有可能傳了錯誤參數的情況。這些需要在三個函數中進行測試,每個函數都標記@Test,分別可以獨立運行。
@Test
public void testOrderService_normal(){// ...
}@Test
public void testOrderService_nullId(){// ...
}@Test
public void testOrderService_errorId(){// ...
}
4、Test模板
可以看到每個@Test函數,都按照統一的模板進行編寫,這個也是測試的要求,需要增加測試代碼的可讀性。
- arrange:安排的意思,這里的意思做一些前置準備,對必要的數據、函數進行實際的模擬。這里使用Mockit舉一個簡單的例子。
// arrange 不管傳入什么id,都返回null,表示數據不存在
when(goodsRepository.searchById(anyInt())).thenReturn(null);
- act:行動的意思,這里的意思是開始執行邏輯,往往只有一行代碼
// act 搜索貨物是否還有庫存,并創建一個訂單,這里假設貨物單號為11000
result = orderServiceImpl.create(11000);
- assertResult:驗證結果的意思,這里需要使用assert來判斷結果是否符合預期。
Assert.assertNotNull(result);
Assert.assertEqual(result.getCode(),0);
Assert.assertEqual(result.getMsg(),"");
驗證階段一般不允許使用print進行手動驗證,這是因為當項目變得龐大時,手動驗證的工作量會變得極為龐大,并且測試的結果往往是確定的,所有可以使用Assert進行驗證。
5、單測示例
這里給一個最終的模板,可以看看效果
Class OrderTest{@ResourceOrderService orderServiceImpl;@MockBeangoodsRepository goodsRepository;@Testpublic void testOrderService_normal(){// arrange,這里假設訂單創建需要調用goodsRepository服務去查詢MySQL數據庫Goods goods = new Goods(11000,"cup",15);when(goodsRepository.searchById(anyInt())).thenReturn(goods);// actresult = orderService.create(11000);// assertResult,這里假設訂單創建成功時,會返回狀態碼為0,以及message=“success”Assert.assertNotNull(result);Assert.assertEqual(result.getCode(),0);Assert.assertEqual(result.getMsg(),"success");}@Testpublic void testOrderService_nullId(){// 同理}@Testpublic void testOrderService_errorId(){// 同理}
}
H2數據庫+JSON
看完上面的內容,可以發現不管多么復雜的單測,最終都可以拆分為上面的三個部分。
同時,為了更高效了進行模擬,減少代碼的侵入性,也可以采用JSON文件來模擬數據庫,不然每次模擬repository服務,都需要手動在代碼里面建立一個對象并手動賦值。所以,H2數據庫配合JSON被引入了單測。
這里也很簡單,首先需要在Spring項目中引入依賴,并在application文件中進行配置,這里不贅述了,可以去網上搜一搜。
1、使用方式
H2原生不支持直接將Json轉化為數據表。一般大型公司會有自己內部的二開框架。一般是在測試方法上加一個注解,然后就當成數據表訪問。
@Test@H2Data("dataset/goods.json") //這里只是給個示例,實際并不存在這個注解public void testOrderService_normal(){// arrange,這里假設訂單創建需要調用goodsRepository服務去查詢MySQL數據庫Goods goods = new Goods(11000,"cup",15);when(goodsRepository.searchById(anyInt())).thenReturn(goods);// actresult = orderService.create(11000);// assertResult,這里假設訂單創建成功時,會返回狀態碼為0,以及message=“success”Assert.assertNotNull(result);Assert.assertEqual(result.getCode(),0);Assert.assertEqual(result.getMsg(),"success");}
還有第二種方式,就是自己寫SQL,這樣需要維護一個SQL文件,當測試啟動時,這個SQL文件會被導入到H2數據庫中,但是H2是內存數據庫,所以在測試結束了,H2中的數據會被全部清空。
AI輔助單測
由上文可知,單測的寫法是有固定的模板的,且代碼行數涉及不多,可以使用AI來進行輔助設計。
相信很多人都用AI寫過單測,但是發現效果特別差,基本上能用的只有框架,里面的邏輯,以及mock全部都要大改,所以AI寫單測效率提升也很低。后面經過我琢磨,發現這個問題是由于我每次寫單測時,給GPT描述的語言不夠明確,希望他可以自己看代碼去進行生成。(但是當規則不明確時,AI是傾向于偷懶的)
所以搞了一個prompt,可以參考這個規則,基于自己的項目特征改改,就可以實現較高的效率了。
目前我的情況是 :需要改JSON數據文件以及極少量代碼,就可以實現目標單測邏輯
使用方法
首先需要編寫rule.md文件,然后將這個文件放在ai可以閱讀到的地方。然后使用如下詢問順序:
1、仔細閱讀rule.md文件,后續寫單元測試會用到。
2、幫我完成xx接口的單測
3、。。。(如果有完成的不好的地方,可以讓他改經,但是推薦自己改)
具體的rule文件如下,需要根據自己的項目,填充一些空缺的地方。
---
description: 單元測試|unit test|單測
globs:
alwaysApply: true
---
# 單元測試**根本原則:單元測試以接口為單位,需要驗證接口及涉及到的調用函數的執行邏輯,除非方法內部直接涉及到RPC調用、MQ調用,否則不允許mock**## 注意事項- 編寫單元測試代碼時需要注意import新增加的類或方法
- 編寫單元測試代碼過程中,**禁止更改業務代碼**
- **只允許模擬本系統的外部依賴調用,對其余方法進行mock會被懲罰**
- 強烈推薦模仿xxx.java進行單測編寫
- **查詢數據庫邏輯必須使用H2數據庫實現**## 數據準備對于請求參數類、返回參數類的構建應該進行抽象,避免出現大量的參數類構建的重復代碼。舉例如下:private Order buildOrder() {// Order組裝
}## 測試執行### 模版使用需要使用arrange、act、assertResult三段式架構編寫(簡單場景可選擇不使用,如基礎的參數校驗驗證),需要注意使用測試模版的方法需要在方法簽名上加throws Exception。模版的使用需要遵循一下規范,**不得在act中驗證結果**- arrange: 負責資源的準備,包括請求的參數構建、mock結果返回、預期返回結果構建
- act: 待測試的方法執行
- assertResult: 測試結果驗證如下所示:
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;import static org.mockito.Mockito.*;public class DemoTest{@InjectMocksprivate Demo demo;@Mockprivate UserService userService;@Mockprivate EmailClient emailClient;private User buildUser() {// User組裝...}@Testpublic void testSendEmail_UserNotFound() throws Exception {}@Overrideprotected void act() {}@Overrideprotected void assertResult() {});}
}在模版使用內部類中,常量的定義需要增加final關鍵字// goodcase: 使用final關鍵字private final Integer pageNum = 1;// badcase: 未使用final關鍵字private Integer pageSize = 10;@Overrideprotected void arrange() {// do something}@Overrideprotected void act() {// do something}@Overrideprotected void assertResult() {// do something}
### RPC操作的mock測試接口對外部進行RPC調用時,具有非常明顯的架構,比如:xxx測試接口中**具備上述調用特征的RPC調用函數都需要進行mock**### DAO層測試DAO層使用H2代替數據庫進行測試增刪改查測試,**所有的測試類中通過Repository讀取數據庫的操作,都需要你新建SQL文件進行存儲,不允許進行Mock**。#### 數據庫初始化配置datasource.xml。可根據實際情況調整。優先復用工程內已有的配置,沒有再新增。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xmlns:tx="http://www.springframework.org/schema/tx"xmlns:p="http://www.springframework.org/schema/p"xmlns="http://www.springframework.org/schema/beans" xmlns:jdbc="http://www.springframework.org/schema/jdbc"xsi:schemaLocation="http://www.springframework.org/schema/beanshttp://www.springframework.org/schema/beans/spring-beans.xsdhttp://www.springframework.org/schema/txhttp://www.springframework.org/schema/tx/spring-tx.xsdhttp://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd"><jdbc:embedded-database id="h2TestDataSource" type="H2" database-name="h2TestDataSource;DATABASE_TO_UPPER=TRUE;MODE=MYSQL;"><!-- 這里的 h2/init.sql 是要初始化表結構的文件 --><jdbc:script location="classpath:h2/init.sql"/></jdbc:embedded-database><!-- sqlSessionFactory --><bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"><property name="dataSource" ref="h2TestDataSource"/><!-- 配置 Mybatis 配置文件的位置 --><property name="configLocation" value="classpath:mybatis-config.xml"/><property name="mapperLocations"><list><!-- 這里的 value 要替換成真實的 Mybatis 的 Mapper 文件地址 --><value>classpath:mapper/**/*DAO.xml</value></list></property></bean><bean id="sqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate"><constructor-arg ref="sqlSessionFactory"/></bean><!-- 配置掃描 Mapper 接口的包路徑 --><bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"p:dataSource-ref="h2TestDataSource"/><tx:annotation-driven />
</beans>#### mybatis配置mybatis-config.xml(僅供參考,優先使用項目本身的配置)<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration><settings><setting name="cacheEnabled" value="false"/><setting name="lazyLoadingEnabled" value="true"/><setting name="aggressiveLazyLoading" value="false"/><setting name="multipleResultSetsEnabled" value="true"/><setting name="useColumnLabel" value="true"/><setting name="defaultExecutorType" value="SIMPLE"/><setting name="defaultStatementTimeout" value="25000"/><setting name="mapUnderscoreToCamelCase" value="true"/><setting name="useGeneratedKeys" value="true"/><setting name="logImpl" value="LOG4J2" /></settings>
</configuration>#### 數據庫表初始化h2/init.sqlCREATE TABLE IF NOT EXISTS users (`id` bigint(20) NOT NULL AUTO_INCREMENT,`name` varchar(255) NOT NULL,`status` tinyint(4) NOT NULL DEFAULT 1,`ctime` int(11) NOT NULL DEFAULT '0',`utime` int(11) NOT NULL DEFAULT '0',`valid` tinyint(4) NOT NULL DEFAULT 1PRIMARY KEY (`id`)
);#### 測試數據準備測試數據的生成格式如下,但是每張相關表你只允許**仿照Dao層的Vo類**生成一條數據,稍后會由用戶進行具體數據的填充。datasets/user/users.json{"users": [{"id": 1,"name": "Alice","status": 1,"ctime": 1744856001,"utime": 1744856001,"valid": 1},{"id": 2,"name": "Bob","status": 2,"ctime": 1744856001,"utime": 1744856001,"valid": 1}]
}#### 示例被測試類public interface UserMapper {User getUserById(@Param("id") Long id);User getUserByName(@Param("name") String name);User save(@Param("user") User user);int updateStatusById(@Param("id") Long id, @Param("status") Integer status);
}### 私有方法測試優先通過公有方法來測試私有方法的行為,特殊場景可通過Spring提供的`ReflectionTestUtils`工具類進行私有方法的測試。> 特殊場景舉例:在無單元測試代碼的存量代碼上增加了一個邏輯分支,這時想通過公用方法來做單測的話,需要編寫這個公有方法歷史邏輯的單元測試代碼,此時可考慮直接測新增的私有方法。#### 注意事項1. **謹慎使用**:私有方法測試應該是例外而非常規做法。優先考慮通過公有方法間接測試私有方法。2. **參數類型匹配**:`invokeMethod`方法的參數必須與私有方法的參數類型完全匹配。對于基本類型和包裝類型,需要特別注意類型轉換。3. **方法名稱準確**:確保提供的方法名稱字符串與實際的私有方法名稱完全一致。4. **重載方法**:如果測試的私有方法有重載版本,`ReflectionTestUtils`會根據提供的參數類型選擇匹配的方法。## 結果驗證需要充分驗證測試結果,包括返回參數驗證、異常信息驗證、下游方法調用情況驗證。### 返回參數驗證對于返回結果的驗證需要直接驗證整個結果類:**goodcase:**1. 比較實現了equals方法的類: 通過@Data/@EqualsAndHashCode或手動實現equals方法,直接比較兩個對象是否相等`Assert.assertEquals(expected, result);`
2. 比較未實現equals方法的類: 使用json工具轉為json字符串進行比較`Assert.assertEquals(gson.toJson(expected), gson.toJson(result));`
3. 比較集合類(List,Set,Map): Set,Map直接比較整個集合類,List需要注意元素的順序,不關心返回結果順序的場景可以先排序后驗證。**badcase:**1. **不允許**通過返回對象的字段驗證: 通過逐一驗證返回的字段來驗證,這樣做容易遺漏字段Assert.assertEquals(123, result.getUserId());
Assert.assertEquals("abac", result.getUserName());
Assert.assertEquals(0, result.getCode());
Assert.assertEquals("success", result.getMessage());2. **不允許**集合類結果只驗證size: 集合類的返回只驗證了返回結果集的size,而不驗證具體的內容。如下所示:public class DemoTest extends BaseJunitTest {@InjectMocksprivate Demo demo;@Mockprivate UserService userService;@Mockprivate EmailClient emailClient;private User buildUser() {// User組裝...}private SendEmailParam buildSendEmailParam() {// EmailParam組裝...}private SendEmailResult buildSendEmailResult() {// EmailResult組裝...}@Testpublic void testSendEmail() throws Exception {TestHandleTemplate.test(new TestCallBack("測試發送郵件") {private SendEmailParam param;private SendEmailResult result;@Overrideprotected void arrange() {User user = buildUser();param = buildSendEmailParam();when(userService.getByUserId(param.getUserId())).thenReturn(user);}@Overrideprotected void act() {result = demo.sendEmail(param);}@Overrideprotected void assertResult() {SendEmailResult expected = buildSendEmailResult();// goodcase: 直接通過equals驗證整個結果類(針對實現了equals方法的類)Assert.assertEquals(expected, result);// goodcase: 比較序列化后的json字符串Assert.assertEquals(gson.toJson(expected), gson.toJson(result));// badcase: 對結果類的字段逐一驗證Assert.assertEquals(123, result.getUserId());Assert.assertEquals("abac", result.getUserName());Assert.assertEquals(0, result.getCode());Assert.assertEquals("success", result.getMessage());}});}
}### 異常驗證對于拋出異常類型的校驗需要使用assertThrows來獲取異常,并校驗異常類型與message。assertThrows方法常見與兩個包,結合所在工程使用情況來選用:1. org.testng.Assert.assertThrows
2. org.junit.jupiter.api.Assertions.assertThrows示例如下:@Test
public void testSendEmail_UserNotFound() throws Exception {TestHandleTemplate.test(new TestCallBack("測試用戶不存在") {private SendEmailParam param;private IllegalArgumentException exception;@Overrideprotected void arrange() {param = buildSendEmailParam();when(userService.getByUserId(param.getUserId())).thenReturn(null);}@Overrideprotected void act() {exception = assertThrows(IllegalArgumentException.class,() -> demo.sendEmail(param));}@Overrideprotected void assertResult() {assertEquals("用戶不存在", exception.getMessage());}});
}### 下游方法調用驗證對于下游方法的調用需驗證調用次數以及調用的入參是否符合預期。如下所示@Test
public void testSendEmail() throws Exception {TestHandleTemplate.test(new TestCallBack("測試發送郵件") {private SendEmailParam param;private SendEmailResult result;@Overrideprotected void arrange() {User user = buildUser();param = buildSendEmailParam();when(userService.getByUserId(param.getUserId())).thenReturn(user);}@Overrideprotected void act() {result = demo.sendEmail(param);}@Overrideprotected void assertResult() {verify(userService).getByUserId(eq(param.getUserId()));verify(emailClient).send(eq(buildSendEmailReq()));// 調用次數驗證...// 結果驗證...}});
}
## tips為了更好的幫助你完成單測編寫需求,我偷偷給你準備了一些小tips- 想一想,如果涉及到數據庫讀取數據操作,你會如何辦?
- 仔細閱讀xxx.java代碼,**記住這個測試代碼的風格,以及非必要不mock原則**
- 多次強調**必須使用@DataSet注解模擬所有數據庫訪問**,記住哦!
- 最后需要生成readme.md文件來記錄你的測試思路好的,你已經是一個成熟的單測輔助AI了,我相信你可以勝任接下來的單測編寫工作,如果寫的好,我會給你獎勵