2018-08-05 沒有測試用例的代碼,根本不應該跑在服務器上

原文地址

什么是單元測試?

image

Wikipedia 對單元測試的定義:

在計算機編程中,單元測試(Unit Testing)又稱為模塊測試,是針對程序模塊(軟件設計的最小單位)來進行正確性檢驗的測試工作。

在實際測試中,一個單元可以小到一個方法,也可以大到包含多個類。從定義上講,單元測試和集成測試是有嚴格的區(qū)分的,但是在實際開發(fā)中它們可能并沒有那么嚴格的界限。如果專門追求單元測試必須測試最小的單元,反而容易造成多余的測試并且不易維護。換句更嚴謹一點的說法,我們要考慮測試的場景再去選擇不同粒度的測試。

單元測試和集成測試即可以手工執(zhí)行,也可以是程序自動執(zhí)行。但現在一般提到單元測試,都是指自動執(zhí)行的測試。所以我們下面提到的單元測試,沒有特別注明,都是泛指自動執(zhí)行的單元測試或集成測試。

單元測試入門

下面我們先看兩個案例,感受一下單元測試到底是什么樣子的。

例子 1:生命游戲單元測試

我們先看一個很簡單的例子,實現一個康威生命游戲。如果不了解康威生命游戲的話,可以看 Wikipedia 的介紹。假設我們實現時定義這樣的接口:

public interface Game {
 void init(int[][] shape) ;      // 初始化游戲 board 
    void tick();                    // 行進到在一個回合
    int[][] get();                  // 獲取當前游戲 board }</pre>

生命游戲有好幾條規(guī)則,為了測試我們的實現是否正確,我們可以針對生命游戲的每個規(guī)則,寫一個單元測試。下面測試的是復活的規(guī)則。

public void testRelive() {
    int[][] shape = {{0, 0, 1}, {0, 0, 0}, {1, 0, 1}};
    Game g = new GameImplSample(shape);
    g.tick();
    // 自己死亡,周圍3個存活狀態(tài),復活
    assertEquals(1, g.get()[1][1]); }</pre>

例子 2:訂單退款集成測試

我們在看一個稍微復雜一些的例子,測試的是訂單退款的過程。

    // 創(chuàng)建訂單、支付,然后退款
    Order order = createOrder(OrderSource.XR_DOCTOR);
    order = fullPay(order, PayType.WECHAT_JS);
    OrderItem item = _doItemRefund(order, 1, false);

    // 檢查退款中狀態(tài)
    OrderWhole orderWholeRefunding = findOrderWhole(order.getOrderNo());
    isTrue(orderWholeRefunding.getRefundStatus().equals(
        OrderRefundStatus.PARTIAL_REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getStatus().equals(
        RefundStatus.REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getItemId().get().equals(
        item.getId()));

    // 構建退款的回調信息
    List<Payment> payments = findPayments(order.getId());
    List<Refund> refunds = findRefunds(order.getId());
    wxRefundNotify(payments.get(0), refunds.get(0), WxRefundStatus.SUCCESS);

    // 檢查退款后狀態(tài)
    OrderWhole orderWholeFinish = assertRefund(order, FULL_PAID, 
        PARTIAL_REFUND_OK, RefundStatus.SUCCESS, RefundMode.ITEM, false);
    isTrue(orderWholeFinish.getRefundFee() == item.getPaidPrice());
    isTrue(orderWholeFinish.getIncomes().stream()
        .filter(i -> i.getAmount() < 0).count() == 1); }</pre>

單元測試執(zhí)行

單元測試有很多種執(zhí)行方式:

  • 在 IDE 中執(zhí)行

  • 通過 mvn 或者 gradle 運行

  • 在 CI 中執(zhí)行

不論什么方式,單元測試都應該很容易就能運行,并給出一個測試結果。當然,單元測試運行速度得快,一般是在秒級的,太慢的話就不能及時獲得反饋了。

為什么要寫單元測試?

單元測試的好處

  • 確保代碼滿足需求或者設計規(guī)格。 使用單元測試來測試代碼,可以通過構造數據和前置條件,確保測試覆蓋到需要測試的邏輯。而手工測試或 UI 測試則無法做到,并且往往更復雜。

  • 快速定位并解決問題。 單元測試因為測試范圍比較小,可以比較容易的定位到問題;而手工測試,常常需要耗費不少時間去定位問題。

  • 確保代碼永遠滿足需求規(guī)格。 一旦需要對實現進行修改,單元測試可以確保代碼的正確性,極大的降低各種修改和重構的風險。特別是避免那些在意想不到之處出現的 BUG。

  • 簡化系統(tǒng)集成。 單元測試確保了系統(tǒng)或模塊本身的正確性,集成時更不容易出錯。

  • 提高代碼質量和可維護性。 不可測試的代碼,其本身的抽象性、模塊性、可維護性是有些問題的。例如不符合單一職責、接口隔離等設計原則,或者依賴了全局變量。可測試的代碼,往往其質量相對會高一些。

  • 提供文檔和說明。 單元測試本身就是接口使用方法的很好的案例。

持續(xù)集成和持續(xù)交付

2010 年前后,大部分互聯(lián)網公司的系統(tǒng)部署還是通過手工的方式進行的,往往要在半夜上線系統(tǒng)。但是之后持續(xù)集成、持續(xù)交付的理念不斷推廣,部署過程越來越靈活、順暢。而單元測試則是持續(xù)集成和持續(xù)交付里重要的一環(huán)。

持續(xù)集成就是 Continuous Integration(CI),也就是指從開發(fā)上傳代碼、自動構建和測試、最后反饋結果的過程。

image

更進一步,如果自動構建和測試后,會自動發(fā)布到測試環(huán)境或預發(fā)布環(huán)境,執(zhí)行更多測試(集成測試、自動化 UI 測試等),甚至最后直接發(fā)布,那這一過程就是持續(xù)交付(Continuous Delivery,CD)。業(yè)內有不少公司,比如亞馬遜、Esty,可以做到每天幾十甚至成百上千次生產環(huán)境部署,就是因為有比較完善的持續(xù)交付環(huán)境。

CI 已經是互聯(lián)網行業(yè)必備標準,CD 也在互聯(lián)網行業(yè)有了越來越多的實踐,但是如果沒有單元測試這一環(huán)節(jié),CI 和 CD 的過程是有缺陷的。

怎么寫單元測試?

JUnit 簡介

基本上每種語言和框架都有不錯的單元測試框架和工具,例如 Java 的 JUnit、Scala 的 ScalaTest、Python 的 unittest、JavaScript 的 Jest 等。上面的例子都是基于 JUnit 的,我們下面就簡單介紹下 JUnit。

  • JUnit 里面每個 @Test 注解的方法,就是一個測試。@Ignore 可以忽略一個測試。@Before、@BeforeClass、@After、@AfterClass 可以在測試執(zhí)行前后插入一些通用的操作,比如初始化和資源釋放等等。

  • 除了 assertEquals,JUnit 也支持不少其他的 assert 方法。例如 assertNull、assertArrayEquals、assertThrows、assertTimeout 等。另外也可以用第三方的 assert 庫比如 Spring 的 Assert 或者 AssertJ。

  • 除了可以測試普通的代碼邏輯,JUnit 也可以進行異常測試和時間測試。異常測試是測試某段代碼必須拋指定的異常,時間測試則是測試代碼執(zhí)行時間在一定范圍內。

  • 也可以對測試進行分組。例如可以分成 contractTest 、mockTest 和 unitTest,通過參數指定執(zhí)行某個分組的測試。

這里就不做過多介紹了,想了解更多 JUnit 的可以去看 極客學院的 JUnit 教程 等資料。其他的單元測試框架,基本功能都是大同小異。

使用測試 Double

狹義的單元測試,我們是只測試單元本身。即使我們寫的是廣義的單元測試,它依然可能依賴其他模塊,比如其他類的方法、第三方服務調用或者數據庫查詢等等,造成我們無法很方便的測試被測系統(tǒng)或模塊。這時我們就需要使用測試 Double 了。

如果細究的話,測試 Double 分成好多種,比如什么 Dummies、Fakes 等等。但我認為我們只要弄清兩類就可以了,也就是 Stub 和 Mock。

Stub

Stub 指那些包含了預定義好的數據并且在測試時返回給調用者的對象。Stub 常被用于我們不希望返回真實數據或者造成其他副作用的場景。

我們契約測試生成的、可以通過 spring cloud stubrunner 運行的 Stub Jar 就是一個 Stub。我們可以讓 Stub 返回預設好的假數據,然后在單元測試里就可以依賴這些數據,對代碼進行測試。例如,我們可以讓用戶查詢 Stub 根據參數里的用戶 ID 返回認證用戶和未認證用戶,然后我們就可以測試調用方在這兩種情況下的處理邏輯了。

當然,Stub 也可以不是遠程服務,而是另外一個類。所以我們經常說要針對接口編程,因為這樣我們就可以很容易的創(chuàng)建一個接口的 Stub 實現,從而替換具體的類。

 public String get(String userId) {
        return "Mock user name";
    } } public class UserServiceTest {
    // UserService 依賴 NameService,會調用其 get 方法
    @Inject
    private UserService userService;    
    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        userService.setNameService(new StubNameService());
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
    } }

不過這樣要實現很多 Stub 也是很麻煩的,現在我們已經不需要自己創(chuàng)建 Stub 了,因為有了各種 Mock 工具。

Mock

Mocks 指那些可以記錄它們的調用信息的對象,在測試斷言中我們可以驗證 Mocks 被進行了符合期望的調用。

Mock 和 Stub 的區(qū)別在于,Stub 只是提供一些數據,它并不進行驗證,或者只是基于狀態(tài)做一些驗證;而 Mock 除了可以做 Stub 的事情,也可以基于調用行為進行驗證。比如說,Mock 可以驗證 Mock 接口被調用了不多不少正好兩次,并且調用的參數是期望的數值。

Java 里最常用的 Mock 工具就是 Mockito 了。我們來看一個簡單的例子,下面的 UserService 依賴 NameService。當我們測試 UserService 的時候,我們希望隔離 NameService,那么就可以創(chuàng)建一個 Mock 的 NameService 注入到 UserService 中(在 Spring 里只需要用 @Mock 和 @InjectMocks 兩個注解就可以完成了)。

 @InjectMocks
    private UserService userService;
    @Mock
    private NameService nameService;

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        Mockito.when(nameService.getUserName("SomeId")).thenReturn("Mock user name");
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
        Mockito.verify(nameService).getUserName("SomeId");
    } }

注意上面最后一行,是驗證 nameService 的 getUserName 被調用,并且參數為 "SomeId"。更多關于 Mockito 的內容,可以參考 Mockito 的文檔(

http://static.javadoc.io/org.mockito/mockito-core/2.9.0/org/mockito/Mockito.html)。

契約測試

契約測試會給每個服務生成一個 Stub,可以用于調用方的單元/集成測試。例如,我們需要測試預約服務的預約操作,而預約操作會調用用戶服務,去驗證用戶的一些基本信息,比如醫(yī)生是否認證等。

所以,我們可以通過傳入不同的用戶 ID,讓契約 Stub 返回不同狀態(tài)的用戶數據,從而驗證不同的處理流程。例如,正常的預約流程的測試用例可能是這樣的。

        ids = {"com.xingren.service:user-client-stubs:1.0.0:stubs:6565"})public class BookingTest {
    // BookingService 會調用用戶服務,獲取醫(yī)生認證狀態(tài)后進行不同的處理
    @Inject 
    private BookingService bookingService;
    @Test
    public void testBooking() {
        BookingForm form = new BookingForm(
            1,                      // doctorId
            1,                      // scheduleId
            1001);                  // patientId
        BookVO res = bookingService.book(form);
        assertTrue(res.id > 0);
        assertTrue(res.payStatus == PayStatus.UN_PAY);
    } }

注意上面的 AutoConfigureStubRunner 注解就是設置并啟動了用戶服務 Stub,當然在測試的時候,我們需要把服務調用接口的 baseUrl 設置為http://localhost:6565。關于契約測試的更多內容,請參考微服務環(huán)境下的集成測試探索一文。

TDD

簡單說下 Test Driven Development,也就是 TDD。左耳朵耗子就寫了一篇TDD并不是看上去的那么美,我就直接引用其介紹了。

其開發(fā)過程是從功能需求的test case開始,先添加一個test case,然后運行所有的test case看看有沒有問題,再實現test case所要測試的功能,然后再運行test case,查看是否有case失敗,然后重構代碼,再重復以上步驟。

其實嚴格的 TDD 流程實用性并不高,左耳朵耗子本身也是持批判態(tài)度。但是對于接口定義比較明確的模塊,先寫單元測試再寫實現代碼還是有很大好處的。因為目標清晰,而且可以立刻得到反饋。

如何設計單元測試?

單元測試設計方法

單元測試用例,和普通測試用例的設計,沒有太多不同,常見的就是等價類劃分、邊界值分析等。而測試用例的設計其實也是開發(fā)者應該掌握的基本技能。

等價類劃分

把所有輸入劃分為若干分類,從每個分類中選取少數有代表性的數據做為測試用例。

例如,一個方法計算輸入參數的絕對值的倒數,如果是輸入是 0,則拋異常。那么對這個方法寫測試的話,就應該有三個等價類,輸入是負數、0 以及正數。所以我可以選取一個負數、一個正數以及 0 來設計三個測試用例。

再舉個例子,某個方法是根據醫(yī)生的認證狀態(tài),發(fā)送不同的消息。那么等價類可能有三種,未認證、普通認證但無權威認證、普通認證且權威認證,某些情況下可能還會包括無普通認證但有威認證。

邊界值分析

邊界值是指劃分等價類后,在邊界附近的一些輸入數據,這些輸入往往是最容易出錯的。

例如,對于上面計算絕對值的倒數的例子,那么邊界值就包括 Integer.min、-1、0、1、Integer.max 等。再舉個例子,文本框輸入范圍為 1 - 255 個字符,那么等價類按輸入長度劃分有三類 0、1 到 255、大于 255,而邊界值則包括 0、1、2、254、255、256 等。

其他類似于空數組、數組的第一個和最后一個、報表的第一行和最后一行等等,也是屬于邊界值,需要特別關注。

判定表法

當我們由多個輸入數據時,可以將這些數據的等價類的組合以表格的形式列舉出來,然后設計測試用例。下面是一個例子(沒有完全列舉)。

用例 醫(yī)生是否設置需要確認 醫(yī)生是否設置免費咨詢 醫(yī)生是否已經確認患者 患者是否已經完善信息 期望結果
用例A 患者可以咨詢醫(yī)生
用例B 患者不能咨詢醫(yī)生
用例C 患者可以咨詢醫(yī)生
用例D 患者不能咨詢醫(yī)生
用例E 患者不能咨詢醫(yī)生

其他方法

除了上面提到的幾種,測試設計方法還有幾種常用的:

  • 場景法。場景法是根據模塊實際使用的場景,例如 API 的實際調用方法、系統(tǒng)的實際需求場景和處理邏輯創(chuàng)建的用例。這種方法比較直觀,并且用例貼近實際需求的,不可忽視。

  • 錯誤推測。錯誤推測其實就是憑直覺,考慮最容易出錯的情況來設計用例。例如,我們直到新用戶、重復請求、并發(fā)、弱網、大數據量等情況都是非常容易出錯的,那么可以針對性的設計用例。錯誤推測需要測試設計者比較熟悉業(yè)務邏輯,并且經驗豐富。

其他還有因果圖、正交法等方法,這里就不說了。

覆蓋率

如果按照前面的用例設計方法,可能會設計出很多用例。我們不可能也沒有必要把每一個用例都寫成單元測試。

怎么確認用例是否足夠呢?一個很重要的參考指標就是代碼覆蓋率。

覆蓋率指標

常用的覆蓋率指標有四種:

  • 語句覆蓋:每條語句至少執(zhí)行一次。

  • 分支覆蓋:每個分支至少有一次為真、一次為假。

  • 條件覆蓋:每個分支的每個條件至少有一次為真、一次為假。

  • 路徑覆蓋:對所有的分支、循環(huán)等可能的路徑,至少都要覆蓋一次。

我們以這個簡單的代碼為例,看看這四種覆蓋率到底是什么意思。

 // X } // Y if (c || d) {
    // X }
  • 語句覆蓋。只需要一個測試用例,讓 a && bc || d 都為真,系統(tǒng)會依次執(zhí)行 X、Y、Z 三個的代碼段,就能做到語句覆蓋。

  • 分支覆蓋。至少需要兩個測試用例,讓 a && bc || d 都各為真假,例如用例1 a && b 為真和 c || d 為假,用例2 則反過來,既可讓兩個條件分支都各為真一次,為假一次。

  • 條件覆蓋。至少需要四個測試用例,條件 a 和 b 的四種組合都要執(zhí)行一次,條件 c 和 d 的四種組合也都要執(zhí)行一次。

  • 路徑覆蓋。至少需要八個測試用例,條件 a、b、c 和 d 的所有組合都要執(zhí)行一次。

可以看到,要做到條件覆蓋甚至路徑覆蓋,會需要非常多的測試用例。一般情況,對于復雜的邏輯,單元測試做到分支覆蓋就不錯了,必要的話再做更多完全的覆蓋。

Jacoco 覆蓋

Jacoco 的覆蓋率略有不同,這里簡單說一下。

  • 指令覆蓋(Instructions),覆蓋所有的 Java 代碼指令。

  • 分支覆蓋(Branches),和上面的分支覆蓋基本是一樣的。

  • 圈復雜度覆蓋(Cyclomatic Complexity),可以認為就是路徑覆蓋率。

  • 語句覆蓋(Lines),和上面的語句覆蓋基本是一樣的。

  • 方法覆蓋(Methods),覆蓋所有的方法。

  • 類覆蓋(Classes),覆蓋所有的類。

怎么寫有效的單元測試?

到現在,相信大家對怎么寫單元測試應該有一定概念了。但是很多人也會有疑問:

  • 單元測試耗費太多時間,會不會降低生產效率?

  • 單元測試會不會很難維護?比如修改代碼時還總是需要修改單元測試。

關于第一個問題,相信大家應該都能理解,如果我們在開發(fā)時發(fā)現 BUG,那么解決它是很容易的;但是一旦到了集成、驗收甚至上線之后,那么要解決它就要花費比較大的代價了。業(yè)界很早就有共識,并且有不少數據可以證明,有效的單元測試雖然要花費更多編碼時間,但是可以很大的減少項目的集成、測試和維護成本。

注意上面提到很重要一點是,單元測試必須是有效的,如果我們發(fā)現單元測試很難維護,那往往是因為我們沒有寫出有效的單元測試。

不是所有的代碼都需要單元測試

寫單元測試我們也需要考慮投入產出比,例如下面這些情況,寫單元測試的投入產出比可能會較差。

  • 短期的或者一次性的項目,例如 Demo、數據更新腳本。

  • 業(yè)務簡單的,不含太多邏輯的模塊。例如獲取或者查找一個數據,或者沒有分支條件的業(yè)務邏輯等。

  • UI 層,相對而言比較難做單元測試,除非 UI 本身就有比較復雜的邏輯(其實某些 UI 框架也提供了單元測試工具)。

那么那些情況下要寫單元測試呢?簡單來說,就是兩類。

  • 邏輯復雜、不容易理解、容易出錯的模塊。例如,計算閏年的方法、訂單下單等。

  • 公共模塊或者核心的業(yè)務模塊。

即使對于需要寫單元測試的模塊,我們也應該關注最核心最重要的測試用例,而沒必要單純的追求覆蓋率,或者追求條件覆蓋甚至路徑覆蓋,一般做到分支覆蓋就可以了。另外一個有效的方法是,對于出現的每一個 BUG,添加一個單元測試。

單元測試應該是穩(wěn)定的

這里穩(wěn)定的第一個含義是,單元測試不應該經常需要修改。如果單元測試經常因為底層實現邏輯的變動而需要修改,那一定不是好的單元測試。也就是說,被測單元的接口應該是穩(wěn)定的、設計良好的、易于擴展的。

穩(wěn)定的第二個含義是,單元測試的結果應該是穩(wěn)定的。如果在不同的環(huán)境、不同的情況運行單元測試,會返回不同的結果,那就不是好的單元測試。如果測試需要依賴特定的數據、文件等,那需要有前置的初始化腳本確保依賴的數據、文件在所有環(huán)境都存在并且是一致的。

單元測試應該是灰盒測試

單元測試應該覆蓋核心邏輯的各種分支、邊界及異常,但是避免涉及易變的實現邏輯。也就是說,我們不應該把單元測試當成完全的白盒測試,但也不是黑盒測試,而應該把它當成介于白盒和黑盒之間的灰盒測試。

被測代碼應該是抽象良好的

如果我們發(fā)現一段代碼很難編寫單元測試,常常是因為這段代碼沒有符合良好的抽象規(guī)范,比如沒有使用 DI、不符合單一職責原則、或者依賴了全局的公共變量和方法等等。我們可以考慮優(yōu)化這段代碼,再來嘗試單元單元測試。

談談到底什么是抽象,以及軟件設計的抽象原則 介紹了軟件抽象的原則,這里就不再重復了。

編碼時就應該同時寫好單元測試

這樣我們才能在調試時就發(fā)揮單元測試的優(yōu)勢,對代碼的任何修改都能得到即時反饋。如果是后面再補充單元測試,一方面對實現可能已經不太熟悉了,編寫測試的代價更大了;另一方面,單元測試能發(fā)揮的作用也變小了。不過即使這樣,對那些需要長遠維護的項目,編寫單元測試也還是很有用的。

單元測試的代碼質量也很重要

單元測試也是代碼,也是需要不斷維護的。所以我們不應該隨隨便便的去寫單元測試,而是要把他們也當成普通代碼一樣,要做到高質量、模塊化、可維護。

為什么要寫單元測試之終極原因

終極原因是,作為一名優(yōu)秀的工程師,如果被 QA 和產品經理 Challenge 有 BUG,能忍嗎?而我們工程師當然要用工程師 Style 的測試方法,那就是自動化的單元測試了,不是嗎?

參考

Software Testing Anti-patterns:

http://blog.codepipes.com/testing/software-testing-antipatterns.html

全文完


以下文章您可能也會感興趣:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容