java項目自動化單元測試

對於我們開發人員來說,單元測試一定不會陌生,但在各種原因下會被忽視,尤其是在我接觸到的項目中,提測階段發現各種各樣的問題,我覺得有必要聊一下單元測試。

爲了寫而寫的單元測試沒什麼價值,但一個好的單元測試帶來的收益是非常客觀的。問題是怎麼去寫好單元測試?怎麼去驅動寫好單元測試?

一 現狀

現狀一:多個項目完全沒有單元測試。

現狀二:開發人員沒有寫單元測試的習慣,或者由於趕業務記錄而沒有時間去寫。

現狀三:單元測試寫成了集成測試,比如容器、數據庫,導致單元測試運行時間長,失去了意義。

現狀四:太依賴集成測試。

在gitlab上隨便找兩個項目的測試情況,基本不考慮單元測試就合併發佈,形同虛設。

站在開發的角度講,導致以上問題的原因大概有以下幾點:

  1. 開發成本

    對於系統初期,可能要花很多時間去寫新業務,對於老系統又太過龐大,無法下手。

  2. 維護成本

    每修改相關的類,或者重構一次代碼,我們就要去修改相應的單元測試。

  3. ROI

​ 投入產出是不是正收益?可能無論是管理者還是我們開發自己都回質疑這個問題,所以有時候沒有強有力的動力。

二 怎麼解決

說來說去都是成本的問題,所以我們怎麼去解決成本呢?

那麼,我們一切從最開始說起:開發的成本

一個單元測試的傳統寫法,包含以下幾個方面:

  1. 測試數據 (被測數據,和依賴對象)
  2. 測試方法
  3. 返回值斷言
  • 示例
  @Test  
public void testAddGroup() {    
      // 數據    
      BuyerGroupDTO groupDTO = new BuyerGroupDTO();    
      groupDTO.setGmtCreate(new Date());    
      groupDTO.setGmtModified(new Date());    
      groupDTO.setName("中國");    
      groupDTO.setCustomerId(customerId);    
      // 方法    
      Result<Long> result = customerBuyerDomainService.addBuyerGroup(groupDTO);    
      // 返回值斷言    
      Assert.assertTrue(result.isSuccess());    
      Assert.assertNotNull(result.getData());  
  }

一個簡單的測試還好,但如果是一邏輯複雜,且入參數據複雜的時候,那寫起來其實挺頭痛的。怎麼解放我們程序員的雙手?

“工欲善其事必先利其器”

我們以最大的努力降低我們的開發成本,這就涉及到我們測試框架和工具的選擇問題

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);     
    List<String> stringList = JSONObject.parseArray(arg.getString("List<String>"),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: 語法特別優雅,對於容器類的模擬比較合適,且對於返回值爲空的函數調用也提供比較好的斷言。缺點是不能模擬靜態方法(3.4.x以上版本已支持)

EasyMock: 使用方法類似,但是更嚴格

PowerMock: 可以作爲Mockito的一個補充,比如要測試靜態方法,不過不支持junit5

Spock: 基於Groovy語言的單元測試框架

3、 數據庫層

這裏主要介紹一下H2數據庫,其基於內存來作爲對於關係型數據庫的模擬,運行完成自動釋放,達到隔離的目的。

主要配置:ddl文件路徑、dml文件路徑。這裏不作詳述。

但對於要不要集成數據庫,很難去定義,它的作用主要是用來驗證sql語法的問題,但是相對來說較重,建議可以用於輕量級的集成測試。

三 、Junit5和Mockito

後面講到的自動生成使用的框架和業界使用最多的都是MocKito,所以這裏重點介紹一下,包括使用時遇到的問題。

1 使用方法

  1. 分別單獨引入依賴,推薦引入最新版
<!-- junit5 -->
<dependency>  
    <groupId>org.junit.jupiter</groupId>  
    <artifactId>junit-jupiter</artifactId>  
    <version>5.7.2</version>  
    <scope>test</scope>
</dependency>
<!-- mockito -->
<dependency>  
    <groupId>org.mockito</groupId>  
    <artifactId>mockito-core</artifactId>  
    <version>3.9.0</version>  
    <scope>test</scope></dependency>
<!-- mockito 的junit5適配器 -->
<dependency>  
    <groupId>org.mockito</groupId>  
    <artifactId>mockito-junit-jupiter</artifactId>  
    <version>3.9.0</version>  
    <scope>test</scope>
</dependency>
  1. 使用spring-test全家桶
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-test</artifactId>  
    <scope>test</scope>  
    <version>2.5.0</version>
</dependency>

junit5的使用方法這裏就不多做介紹,主要說一下這個ArgumentsProvider接口,實現它就可以自定義參數化類,類似於自帶的ValueSource、EnumSource等。

2 、Mockito 主要註解介紹

先問爲什麼,爲什麼需要Mockito

因爲:現在的java項目幾乎離不開spring框架,而其最爲著名的就是IOC,所有的bean用容器來管理,所以這給我們單元測試帶來一個問題,如果要對bean做單元測試,就需要啓動容器,那麼帶來的時間的開銷將會很大。所以Mockito給我門帶來了一系列的解決方法,讓我們可以輕鬆的對bean 進行測試。

@Componentpublic 
class A {    
    @Autowired    
    private B b; // 完全mock    
    @Autowired    
    private C c; 
    // 需要執行方法    
    @Autowired 
    D d; 
    // 需要執行真實方法    
    public void func(){    }
}

@Component
class C {    
    @Autowired    
    private B b;    
    public void needExec(){    }
}

@Component
public class B {}

假設我們要對上面的A.func()進行單元測試。

@InjectMocks註解

表示需要注入bean的類,有兩種

  1. 被測試類,這種很容易理解,我們測試這個類,當然也需要向其注入bean。比如上面的A

  2. 被測試類中的,需要執行其真實的方法,但其裏面也要主要bean,也就是上面的C,我們需要測試neeExec方法,但我們不關係B的具體細節。現實中比如事物,併發鎖等。這一類需要Mockito.spy(new C())的形式,不然會報錯

@Mock

表示要mock的數據,也就是不真實執行其方法內容,只按照我們的規則執行,或者返回,比如使用when().thenReturn()語法。

當然也可以,執行真實方法,則需要when().thenCallRealMethod()方式。

@Spy


表示所有方法都走真實方式,比如有些工具類,轉換類,我們也寫成了bean的形式(嚴格來說這種需要寫成靜態工具類)。

@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常見問題

1. mock靜態方法

**
mockito3.4以後開始支持,之前的版本可以使用PowerMock輔助使用

2. Mockito版本和java版本兼容問題

**

報錯如下

Mockito cannot mock this class: xxxMockito can only mock non-private & non-final classes.

原因是2.17.0及之前的版本與java8是兼容的

但2.18之後需要使用java11,爲了在java8中使用Mockito,則需要引入另一個包

<dependency>    
    <groupId>net.bytebuddy</groupId>    
    <artifactId>byte-buddy</artifactId>    
    <version>1.12.6</version>
</dependency>

3. 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以後的版本才支持。

四、測試代碼自動生成

選好了框架,我們還是沒有解決我們的問題,“怎麼節約開發成本?” ,這一節我們來談這個問題,這也是我主要想表達的。

對於寫單元測試,一直以來是比較頭痛的事情,要組裝各種各樣的數據,可能還沒跑成功,就被一堆“xxxx不能爲null”的報錯搞煩了。因此我們有理由去設想,有沒有辦法去解決這件事情。

1、 業界和方案調研

在做這個事情之前,肯定是要調研有沒有現成的框架。答案是有,但很遺憾,沒有找到完全契合我想要的效果,我們來看一下這些插件:

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<Integer>() {{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<String> testMuti(List<Integer> a, Integer c) {        
        List<String> 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個方法,我們對比下各個插件生成的代碼。

1、TestMe

**

  
@Test    
void testTestExtend() {        
    when(testService.getStr(anyInt())).thenReturn("getStrResponse");        
    when(testService.testMuti(any(), anyInt())).thenReturn(Arrays.<String>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.<String>asList("String"));
        
    jCode5.testGeneric(new Person());    
}

1、生成的代碼基本符合邏輯,包括需要mock的bean的邏輯都生成了。

2、但它把最重要的一環,也就是數據省略了,只是單純的用了構造函數的形式。這顯然對於我們DDD模型不適應。

3、另外他沒用用到junit5的一些特性,比如參數化測試。

4、對於testExtend的方法,它只識別了3個方法。沒有識別父類的調用。

2、JunitGenerate

**

只能生成基礎的框架代碼,對於我想mock的邏輯、以及測試方法都沒有生成,用處不大。

@Test
public void testTestExtend() throws Exception {
    //TODO: Test goes here... 
}

3、Squaretest

生成的方法非常豐富,且一個非常厲害的一點,它能生成多個分支,比如代碼邏輯中有if條件,它能生成兩個測試,從而走不同的分支。

但是,最大的缺點是“收費軟件,不開源”,這就決定了我們沒法用它,除非是特別需要。另外測試用過程中還發現了一些其他問題,比如對於繼承,重載之類的問題,它解決的也不是很好,往往識別不了需要調用的方法。

雖然無法使用,但還是可以借鑑。

五、 打造代碼自動生成最佳方案

**

使用別人寫的插件(JCode5)。有興趣的也可以自己試試。

1、 插件安裝

idea插件市場下載,搜索JCode5

image

2、 插件使用

插件有三個功能

  1. 生成測試代碼,也就是生成單元測試。

  2. 生成json數據,通常用來生成測試數據,比如model。用來參數化測試。

  3. 增加測試方法,隨着業務開發,類可能增加一下功能方法,這個時候相應的可以增加測試方法

定位到需要測試的類,快捷鍵或菜單定位到generater,如下,選擇JCode5。

image

1、生成測試類

**

目前支持三個選項,後續會逐漸完善

image

另外兩個功能類似,直接嘗試使用一下就行。

2、生成的結果---類+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");     
    // 識別泛型活着集合類    
    List<String> stringList = JSONObject.parseArray(arg.getString("List<String>"),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<String>":[    "test"  ]
}

`

3、補充判定語句

這一塊前期考慮對於不同的方法有不同的校驗,所以目前想的還是開發者自己去寫驗證代碼。

注意事項

在自動生成完代碼之後,雖然可以運行,但如我們前面提到的,爲了寫單元測試而寫的單元測試是沒什麼價值的,我們的最終目的是爲了寫一個好的測試。代碼自動生成,但它終究能力有限,所以還是需要我們自己再去驗證,比如

  1. 該插件生成的代碼需要junit5和mockito的支持,使用時需要引入相關的依賴

  2. 增加assert校驗邏輯,看是不是想要的結果,目前插件不會自動生成assertEquals等斷言代碼。

  3. 運用參數化測試能力,複製一份生成的json文件並修改輸入數據,多組測試

3 、插件實現介紹

主要的實現思路,參考了dubbo的SPI的源碼,也就是自動實現自適應SPI那部分,簡單點說就是反射獲取代碼邏輯,然後生成測試代碼。

image

  1. mock數據可定製,目前的想法是

    1. 固定值比如目前的String: test、Integer和boolean: 0、1

    2. 測試者使用配置模版,比如txt文件包含keyValue對

    3. 使用Faker,對於name、email、phone這種特定傾向的數據進行特色自動生成

  2. 自動分支測試,這一塊的想法目前主要針對if來做,需要一定的時間。

  3. 其他

對於代碼自動生成,還是有很多東西可以做的,但有些問題還尚待解決,希望能盡最大努力解放我們的雙手,也能提高我們單元測試的質量。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章