新聞中心
寫(xiě)在前面
為了寫(xiě)而寫(xiě)的單元測(cè)試沒(méi)什么價(jià)值,但一個(gè)好的單元測(cè)試帶來(lái)的收益是非常客觀的。問(wèn)題是怎么去寫(xiě)好單元測(cè)試?怎么去驅(qū)動(dòng)寫(xiě)好單元測(cè)試?

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


咨詢
建站咨詢
