新聞中心
寫在前面
為了寫而寫的單元測試沒什么價值,但一個好的單元測試帶來的收益是非??陀^的。問題是怎么去寫好單元測試?怎么去驅動寫好單元測試?

一 、我們的現狀
現狀一:多個項目完全沒有單元測試。
現狀二:開發(fā)人員沒有寫單元測試的習慣,或者由于趕業(yè)務記錄而沒有時間去寫。
現狀三:單元測試寫成了集成測試,比如容器、數據庫,導致單元測試運行時間長,失去了意義。
現狀四:太依賴集成測試。
以上是我在aone找的兩個項目的測試情況,基本不考慮單元測試就合并發(fā)布,形同虛設。
站在開發(fā)的角度講,導致以上問題的原因大概有以下幾點:
開發(fā)成本
對于系統(tǒng)初期,可能要花很多時間去寫新業(yè)務,對于老系統(tǒng)又太過龐大,無法下手。
維護成本
每修改相關的類,或者重構一次代碼,我們就要去修改相應的單元測試。
ROI
投入產出是不是正收益?可能無論是管理者還是我們開發(fā)自己都回質疑這個問題,所以有時候沒有強有力的動力。
二、 怎么解決
說來說去都是成本的問題,所以我們怎么去解決成本呢?
那么,我們一切從最開始說起:開發(fā)的成本
一個單元測試的傳統(tǒng)寫法,包含以下幾個方面:
- 測試數據 (被測數據,和依賴對象)
- 測試方法
- 返回值斷言
@Test
public void testAddGroup() {
// 數據
BuyerGroupDTO groupDTO = new BuyerGroupDTO();
groupDTO.setGmtCreate(new Date());
groupDTO.setGmtModified(new Date());
groupDTO.setName("中國");
groupDTO.setCustomerId(customerId);
// 方法
Resultresult = customerBuyerDomainService.addBuyerGroup(groupDTO);
// 返回值斷言
Assert.assertTrue(result.isSuccess());
Assert.assertNotNull(result.getData());
}
一個簡單的測試還好,但如果是一邏輯復雜,且入參數據復雜的時候,那寫起來其實挺頭痛的。怎么解放我們程序員的雙手?
“工欲善其事必先利其器”
我們以最大的努力降低我們的開發(fā)成本,這就涉及到我們測試框架和工具的選擇問題
1. 測試框架選擇
首先第一個問題就是junit4和junit5的選擇,【從junit4到junit5】 我覺得最便利的一個好處就是可以參數化測試,并且基于參數化測試我們可以更加靈活的配置我們的參數。
效果如下:
@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}
更好的是,junit5提供了擴展,比如我們常用的json格式。這里我們使用json文件作為輸入:
@ParameterizedTest
@JsonFileSource(resources = {"/com/cq/common/KMPAlgorithm/test.json"})
public void test2Test(JSONObject arg) {
Animal animal = JSONObject.parseObject(arg.getString("Animal"),Animal.class);
ListstringList = JSONObject.parseArray(arg.getString("List "),String.class);
when(testService.testOther(any(Student.class))).thenReturn(stringArg);
when(testService.testMuti(any(List.class),any(Integer.class))).thenReturn(stringList);
when(testService.getAnimal(any(Integer.class))).thenReturn(animal);
String result = kMPAlgorithm.test2();
//todo verify the result
}
2. mock框架
然后就是其他mock類的框架了
Mockito: 語法特別優(yōu)雅,對于容器類的模擬比較合適,且對于返回值為空的函數調用也提供比較好的斷言。缺點是不能模擬靜態(tài)方法(3.4.x以上版本已支持)
EasyMock: 使用方法類似,但是更嚴格
PowerMock: 可以作為Mockito的一個補充,比如要測試靜態(tài)方法,不過不支持junit5
Spock: 基于Groovy語言的單元測試框架
3. 數據庫層
這里主要介紹一下H2數據庫,其基于內存來作為對于關系型數據庫的模擬,運行完成自動釋放,達到隔離的目的。
主要配置:ddl文件路徑、dml文件路徑。這里不作詳述。
但對于要不要集成數據庫,很難去定義,它的作用主要是用來驗證sql語法的問題,但是相對來說較重,建議可以用于輕量級的集成測試。
三、 Junit5和Mockito
后面講到的自動生成使用的框架和業(yè)界使用最多的都是MocKito,所以這里重點介紹一下,包括使用時遇到的問題。
1. 使用方法
分別單獨引入依賴,推薦引入最新版
org.junit.jupiter
junit-jupiter
5.7.2
test
org.mockito
mockito-core
3.9.0
test
org.mockito
mockito-junit-jupiter
3.9.0
test
使用spring-test全家桶
org.springframework.boot
spring-boot-starter-test
test
2.5.0
junit5的使用方法這里就不多做介紹,主要說一下這個ArgumentsProvider接口,實現它就可以自定義參數化類,類似于自帶的ValueSource、EnumSource等。
2. Mockito 主要注解介紹
先問為什么,為什么需要Mockito
因為:現在的java項目幾乎離不開spring框架,而其最為著名的就是IOC,所有的bean用容器來管理,所以這給我們單元測試帶來一個問題,如果要對bean做單元測試,就需要啟動容器,那么帶來的時間的開銷將會很大。所以Mockito給我門帶來了一系列的解決方法,讓我們可以輕松的對bean 進行測試。
@Component
public class A {
@Autowired
private B b; // 完全mock
@Autowired
private C c; // 需要執(zhí)行方法
@Autowired D d; // 需要執(zhí)行真實方法
public void func(){
}
}
@Component
class C {
@Autowired
private B b;
public void needExec(){
}
}
@Component
public class B {
}
假設我們要對上面的A.func()進行單元測試。
@InjectMocks注解
表示需要注入bean的類,有兩種
- 被測試類,這種很容易理解,我們測試這個類,當然也需要向其注入bean。比如上面的A
- 被測試類中的,需要執(zhí)行其真實的方法,但其里面也要主要bean,也就是上面的C,我們需要測試neeExec方法,但我們不關系B的具體細節(jié)?,F實中比如事物,并發(fā)鎖等。這一類需要Mockito.spy(new C())的形式,不然會報錯
@Mock
表示要mock的數據,也就是不真實執(zhí)行其方法內容,只按照我們的規(guī)則執(zhí)行,或者返回,比如使用when().thenReturn()語法。
當然也可以,執(zhí)行真實方法,則需要when().thenCallRealMethod()方式。
@Spy
表示所有方法都走真實方式,比如有些工具類,轉換類,我們也寫成了bean的形式(嚴格來說這種需要寫成靜態(tài)工具類)。
@ExtendWith(MockitoExtension.class)
public class ATest {
@InjectMocks
private A a=new A();
@Mock
private B b;
@Spy
private D d;
@InjectMocks
private C c= Mockito.spy(new C());;
@BeforeEach
public void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
}
@ParameterizedTest
@ValueSource(strings = {"/com/alibaba/cq/springtest/jcode5test/needMockService/A/func.json"})
public void funcTest(String str) {
JSONObject arg= TestUtils.getTestArg(str);
a.func();
//todo verify the result
}
}
3. Mockito和junit5常見問題
mock靜態(tài)方法
mockito3.4以后開始支持,之前的版本可以使用PowerMock輔助使用
Mockito版本和java版本兼容問題
報錯如下
Mockito cannot mock this class: xxx
Mockito can only mock non-private & non-final classes.
原因是2.17.0及之前的版本與java8是兼容的
但2.18之后需要使用java11,為了在java8中使用Mockito,則需要引入另一個包
net.bytebuddy
byte-buddy
1.12.6
Jupiter-api版本兼容問題
Process finished with exit code 255
java.lang.NoSuchMethodError: org.junit.jupiter.api.extension.ExtensionContext.getRequiredTestInstances()Lorg/junit/jupiter/api/extension/TestInstance
第一個問題是因為junit5中api、engine、params版本不一致導致的。
第二個問題是因為jupiter-api版本太低的問題,5.7.0以后的版本才支持。
四 、測試代碼自動生成
選好了框架,我們還是沒有解決我們的問題,“怎么節(jié)約開發(fā)成本?” ,這一節(jié)我們來談這個問題,這也是我主要想表達的。
對于寫單元測試,一直以來是比較頭痛的事情,要組裝各種各樣的數據,可能還沒跑成功,就被一堆“xxxx不能為null”的報錯搞煩了。因此我們有理由去設想,有沒有辦法去解決這件事情。
1. 業(yè)界和集團方案調研
在做這個事情之前,肯定是要調研有沒有現成的框架。答案是有,但很遺憾,沒有找到完全契合我想要的效果,我們來看一下這些插件:
public class BaseTest {
protected TestService testService;
public String baseTest() {
return testService.testBase(1); // 4
}
}
public class JCode5 extends BaseTest {
public void testExtend(){
String s = testService.testOther(new Student()); //1
// 調用 另一個方法
System.out.println(testBean());
// 調用基類方法
baseTest();
}
// 使用testService
public String testBean() {
testService.testMuti(new ArrayList() {{add(1);}}, 2); //2
return testService.getStr(12); //3
}
/**
* 測試范型類
*/
public void testGeneric(Person person) {
//test
list.stream().forEach(a -> {
System.out.println(a);
});
for (int i = 0; i < 2; i++) {
Long aLong = testService.getLong("1213"
, "12323");
System.out.println(aLong);
}
System.out.println(testBean());
}
}
public class TestService {
public String testBase(Integer integer) {
return "TestBase";
}
public List testMuti(List a, Integer c) {
List res = new ArrayList<>();
res.add(a.toString() + c + "test muti");
return res;
}
public String getStr(Integer integer) {
return "TestService" + getInt();
}
public String testOther(Student student) {
return student.getAge() + "age";
}
} 如上,testExtend一共調用了testService的4個方法,我們對比下各個插件生成的代碼。
TestMe
@Test
void testTestExtend() {
when(testService.getStr(anyInt())).thenReturn("getStrResponse");
when(testService.testMuti(any(), anyInt())).thenReturn(Arrays.asList("String"));
when(testService.testOther(any())).thenReturn("testOtherResponse");
jCode5.testExtend(Integer.valueOf(0));
}
@Test
void testTestGeneric() {
when(testService.getStr(anyInt())).thenReturn("getStrResponse");
when(testService.getLong(anyString(), anyString())).thenReturn(Long.valueOf(1));
when(testService.testMuti(any(), anyInt())).thenReturn(Arrays.asList("String"));
jCode5.testGeneric(new Person());
}
生成的代碼基本符合邏輯,包括需要mock的bean的邏輯都生成了。
- 但它把最重要的一環(huán),也就是數據省略了,只是單純的用了構造函數的形式。這顯然對于我們DDD模型不適應。
- 另外他沒用用到junit5的一些特性,比如參數化測試。
- 對于testExtend的方法,它只識別了3個方法。沒有識別父類的調用。
JunitGenerate
只能生成基礎的框架代碼,對于我想mock的邏輯、以及測試方法都沒有生成,用處不大。
@Test
public void testTestExtend() throws Exception {
//TODO: Test goes here...
}
Squaretest
生成的方法非常豐富,且一個非常厲害的一點,它能生成多個分支,比如代碼邏輯中有if條件,它能生成兩個測試,從而走不通的分支。
但是,最大的缺點是“收費軟件,不開源”,這就決定了我們沒法用它,除非是特別需要。另外測試用過程中還發(fā)現了一些其他問題,比如對于繼承,重載之類的問題,它解決的也不是很好,往往識別不了需要調用的方法。
雖然無法使用,但還是可以借鑒。
五 、打造代碼自動生成最佳方案
既然市場上的插件都不是特別合適,就決定寫一個適合自己項目的插件(暫時命名JCode5)。有興趣的也可以自己試試。
1. 插件安裝
idea插件市場下載,搜索JCode5
2. 插件使用
插件有三個功能
- 生成測試代碼,也就是生成單元測試。
- 生成json數據,通常用來生成測試數據,比如model。用來參數化測試。
- 增加測試方法,隨著業(yè)務開發(fā),類可能增加一下功能方法,這個時候相應的可以增加測試方法
定位到需要測試的類,快捷鍵或菜單定位到generater,如下,選擇JCode5.
生成測試類
目前支持三個選項,后續(xù)會逐漸完善
另外兩個功能類似,直接嘗試使用一下就行。
生成的結果---類+json數據
@ParameterizedTest
@ValueSource(strings = {"/com/cq/common/JCode5/testExtend.json"})
public void testExtendTest(String str) {
JSONObject arg= TestUtils.getTestArg(str);
Integer i = arg.getInteger("Integer");
// 識別泛型活著集合類
ListstringList = JSONObject.parseArray(arg.getString("List "),String.class);
String stringArg = arg.getString("String");
String stringArg1 = arg.getString("String");
String stringArg0 = arg.getString("String");
// 識別四個方法,包括父類調用、其他方法調用
when(testService.testBase(any(Integer.class))).thenReturn(stringArg);
when(testService.testMuti(any(List.class),any(Integer.class))).thenReturn(stringList);
when(testService.getStr(any(Integer.class))).thenReturn(stringArg0);
when(testService.testOther(any(Student.class))).thenReturn(stringArg1);
jCode5.testExtend(i);
//todo verify the result
}
如上除了生成基本的代碼,另外會生成測試數據,它會將該方法所需要的測試數據全都生成在一個json文件當中,完全實現
“數據和代碼的分離”
如testExtend.json:
{
"Integer":1,
"String":"test",
"List":[
"test"
]
} 補充判定語句
這一塊前期考慮對于不同的方法有不同的校驗,所以目前想的還是開發(fā)者自己去寫驗證代碼。
注意事項
在自動生成完代碼之后,雖然可以運行,但如我們前面提到的,為了寫單元測試而寫的單元測試是沒什么價值的,我們的最終目的是為了寫一個好的測試。代碼自動生成,但它終究能力有限,所以還是需要我們自己再去驗證,比如
- 該插件生成的代碼需要junit5和mockito的支持,使用時需要引入相關的依賴
- 增加assert校驗邏輯,看是不是想要的結果,目前插件不會自動生成assertEquals等斷言代碼。
- 運用參數化測試能力,復制一份生成的json文件并修改輸入數據,多組測試
3. 插件實現介紹
主要的實現思路,參考了dubbo的SPI的源碼,也就是自動實現自適應SPI那部分,簡單點說就是反射獲取代碼邏輯,然后生成測試代碼。
4. 后期規(guī)劃
mock數據可定制,目前的想法是
固定值比如目前的String: test、Integer和boolean: 0、1
測試者使用配置模版,比如txt文件包含keyValue對
使用Faker,對于name、email、phone這種特定傾向的數據進行特色自動生成
自動分支測試,這一塊的想法目前主要針對if來做,需要一定的時間。
其他
六、 寫在最后
對于代碼自動生成,還是有很多東西可以做的,但有些問題還尚待解決,希望能盡最大努力解放我們的雙手,也能提高我們單元測試的質量。
已在我們項目中使用此模式增加135個測試用例(除去mock的單模塊達到70%):速度比集成測試(pandora、spring等)提升一個等級。代碼的覆蓋率相對可觀。
當前題目:談一談單元測試
地址分享:http://fisionsoft.com.cn/article/cccisee.html


咨詢
建站咨詢
