如何寫好單元測試

93發表於2018-09-04

本文主要闡述單元測試(UT)的重要性,以及解釋一些常見的困惑,以幫助我們寫出質量更高的 UT。至於類似 Mocha 怎麼用,斷言庫怎麼用之類的問題,建議看官方文件。 原文在此

一、為什麼需要寫 UT

我發現很多朋友意識不到單元測試的重要性。在談如何寫好 UT 之前,我想先說說測試的必要性,這有利於提高我們寫 UT 的內驅力。

假如,張三負責開發介面,李四負責開發具體的業務。李四會呼叫張三開發的介面,但由於種種原因,張三開發的介面可能存在一些 bug 。

在沒有單元測試的情況下,這些 bug 往往都是由介面的使用者也就是李四發現。這無形中給李四增加了額外的工作量,因為保證介面質量的工作本該是寫介面的人也就是張三應該做的事兒。假設張三還是一個比較粗心的人,其中額外增加的時間成本會更大(實際開發中經常遇到)。

張三可能會很委屈的說:誰能保證程式碼永遠不會產生 bug 。

是的,沒人能夠絕對的保證程式碼的正確性。但張三需要對他所提供的這些介面有一些起碼的保證。他需要保證這些介面的確具備介面文件中所描述的那些功能,且能正常執行。

於是乎,張三含情脈脈的望著李四的眼睛說道:I promise.

哈哈哈,顯然,口頭上的保證永遠都是不靠譜的,我們需要白紙黑字的保證,也就是我們本文的主題:單元測試。

再回到上述的場景。假如張三為他所寫的那些介面寫了一些質量還不錯的測試用例。

李四可能會興奮的把張三公主抱了起來。

哈哈哈哈,原因如下:

  • 原因一:李四不用擔心介面存在一些簡單 bug 。要知道因為這些簡單的 bug ,李四需要向張三反饋,然後等待張三修改,張三很有可能還不會馬上去修改它,李四隻能等著做其他的事兒,等到張三說修好了之後,李四才能繼續原先的開發。天吶,這是多麼大的時間成本啊。

  • 原因二:李四可以通過測試用例瞭解介面的用法。在實際的開發中,介面文件可能並沒有很高的實時性,很多小的公司甚至沒有介面文件這一說。在這種情況下,介面的用法以及涉及到的資料結構就只能靠張三和李四的溝通了。然而,在寫了 UT 之後,這部分的溝通成本很大程度上是可以避免的。因為李四通過閱讀各個測試用例可以清晰的知道介面該如何使用以及能得到怎麼的結果。當然,前提是李四的 UT 寫的足夠好。

OK,我們再換一種場景。假如張三現在是某個開源庫的作者,李四是這個開源庫的使用者,兩人相互並不認識。可以想象,一旦李四遇到什麼 bug,他和張三的溝通成本無疑會更大。這就是為什麼社群總是強調開源專案的測試覆蓋率的原因。

小結一下,寫單元測試有如下幾點好處:

  • 對程式碼有白紙黑字的保證,避免了一些或是粗心或是難以察覺的 bug 。
  • 優秀的 UT 可以充當介面文件的作用,減少許多不必要的溝通成本。

此外還有一些好處在上述的例子中並未體現出來,在此不再贅述,各位自行感受:

  • 方便程式碼的重構。
  • 設計程式碼的思路更加清晰。

二、一些關於 UT 的困惑

通過跟身邊同事的交流以及些許切身的感受,我總結了如下幾個常見的關於 UT 的困惑。

2.1 UT 的本質是什麼?

UT 的本質:保證介面在具體的場景下能有符合預期的輸出(或是行為)。

解釋:由於程式碼的本質是對實際行為的抽象。所以理論上當我們的測試用例覆蓋了專案中的所有行為以及對應的所有場景時,我們是能夠絕對確保軟體的質量的。雖然這只是理想狀態,但是這卻是我們寫 UT 的初衷。夢想總是要有的,對吧?

2.2 什麼是測試覆蓋率?

很多大公司都會要求專案有足夠的測試覆蓋率,在 CI(Continuous Integration) 的時候會有一個測試覆蓋率的閥門,如果測試覆蓋率低於這個閥門,就不讓提交程式碼。比如我們公司閥門是 90% 。

關於測試覆蓋率,我們大致瞭解下以下幾個常見的計算維度即可:

  • 行覆蓋率:可執行語句執行的比例。
  • 函式覆蓋率:函式被呼叫的比例。
  • 分支覆蓋率:判斷語句分支被執行的比例。

需要注意,這裡說的都是可測量的覆蓋率。另外還有一種是無法測量的覆蓋率,也就是上面所說的:測試覆蓋所有行為以及對應所有場景的比例。值得一提的是,後者才是我們真正追求的覆蓋率,當這個覆蓋率足夠高的時候,那些可觀測的覆蓋率必然也就低不了。作為一位優秀的開發者,我們需要弄清楚其中的主次關係。

2.3 需要為私有方法寫 UT 嗎?

關於這個問題,社群有著許多不同的看法,在 stackoverflow 上大家爭論的很火熱。在此,我也談談我自己的看法。

如下,寫或不寫,都有其各自的說法:

  • 寫:因為我們是測試驅動,所以我通過寫測試來更好的設計自己程式碼。何況很多核心的邏輯都是寫在私有方法中,那些暴露出來的介面只是呼叫了這些私有方法,我們不希望為某一個介面寫一大堆的測試用例,這讓測試程式碼顯得難以閱讀。

  • 不寫:這會佔據許多的工作量,我只需保證對外的介面有足夠覆蓋度即可。

而在我看來,這顯然不是一個非黑即白的問題。如果我是團隊的 Leader,我絕對不會強制要求團隊必須為所有的私有方法寫 UT ,這是一件很愚蠢的事情。如果我是一個普通的開發者,即使 Leader 說我們團隊可以不用為私有方法寫 UT,我可能還是會為某些私有方法寫 UT 。

我的原則是:如果該私有方法複雜度較高且比較重要(重要這個詞的理解就仁者見仁了),那麼我會為它寫 UT 。

2.4 什麼時候需要 mock 資料或是方法?

前面說了,UT 的本質就是預期介面在指定場景下的輸出或是行為。這裡的指定場景是我們通過 mock 的手段營造出來的。

在給某個介面寫 UT 的過程中,比較常見的情況是:輸入 a 會觸發 A 行為,輸入 b 會觸發 B 行為。這種情況比較簡單,我們只需寫兩個 case ,然後分別 mock 資料 a 和 資料 b ,然後再寫斷言語句來預期對應的行為即可。

除了 mock 輸入資料,我們常常還需要 mock 一些方法。我總結了下述兩種情況。

2.4.1 為了測試方便,所以我們需要 mock 某個方法。

這裡通過具體的例子會比較好理解。假設我們需要為下述介面寫 UT 。

// index.js
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const _ = require('underscore');

exports.refreshConfiguration = function(param) {
    return fs.readFileAsync(param.path)
        .then(JSON.parse)
        .then((val) => {
            return _.extend(val, param.config);
        })
        .catch((err) => {
            throw err;
        })
}
複製程式碼

這是一個更新配置資訊的介面。當引數都正常時,程式碼執行成功,我們能夠獲取到最新的配置資訊,這個配置資訊包含了 param 中已有的配置資訊和指定檔案中的配置資訊。於是便有了如下這個測試用例。

// mock/config.json
{
    "foo": "foo"
}

// test.js
const _ = require('underscore');
const assert = require('chai').assert;
const testModule = require('./index');

describe('refreshConfiguration', () => {
    const fakeParam = {
        path: './mock/config.json',
        config: { bar: 'bar' }
    };

    it('should be seccessed when everything is right', () => {
        testModule.refreshConfiguration(fakeParam)
            .then((ret) => {
                assert.deepEqual(ret, _.extend({ 
                    foo: 'foo' 
                }, fakeParam.config));
            });
    });
});
複製程式碼

如上,我們需要為這個測試用例新建一個測試檔案。但是,你可能覺得新建檔案很麻煩。於是,我們通過 mock fs.readFileAsync() 方法來實現同樣的目的。

//  rewire 是為 NodeJS 提供 mock 功能的第三方庫 
const rewire = require('rewire');
const _ = require('underscore');
const assert = require('chai').assert;
const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
const testModule = rewire('./index');

describe('refreshConfiguration', () => {
    let fakeParam;
    let fakeFileConfig;

    beforeEach(() => {
        fakeParam = {
            config: { bar: 'bar' }
        };
        fakeFileConfig = {
            foo: 'foo'
        };
    });

    it('should be seccessed when everything is right', () => {
        testModule.__set__('fs', {
            readFileAsync: () {
                return  Promise.resolve(JSON.stringify(fakeFileConfig));
            }
        });

        testModule.refreshConfiguration(fakeParam)
            .then((ret) => {
                assert.deepEqual(ret, _.extend(fakeFileConfig, fakeParam.config));
            });
    });
});
複製程式碼

我們只為這個介面寫了一個 case 。另外還有一中可能就是如果獲取配置檔案失敗後,這個介面應該 catch 到對應的 error 。你不妨動手試著寫寫這種情況下的 case 。

2.4.2 如果方法中有其他模組的介面,(理論上)必須 mock

還是上面的那個例子,refreshConfiguration 介面中有使用第三方模組 underscore 和 node 內建的 fs 模組。對於兩種模組對應的介面,如果沒有 mock 的必要,我們可以不用 mock ,因為我們預設他們是安全的有保障的。

但是,如果待測試的方法中有呼叫其他業務模組的介面,理論上來說,我們必須 mock 這些介面。這涉及到 UT 中很重要的一個概念:隔離(Isolation)。我們需要隔離與當前測試用例無關的方法,這樣的好處有兩點:

  1. 減少了寫 UT 的複雜度,只需專注於具體某個場景下的執行邏輯,其餘全部 mock 。

  2. 避免了各個測試模組間的相互依賴。這樣就不會出現某個介面出現了一個 bug ,導致一大堆的測試用例都跑不過的情況。

實際的開發中,由於種種原因我們可能沒法如此嚴格的遵守隔離的原則,但必須理解它,儘可能的避免一些「壞味道」。

2.5 如何理解和實踐 TDD(測試驅動開發)?

在習慣於寫 UT 之後,我才深切感受到 TDD 這種開發模式非常值得嘗試。

先簡單介紹下 TDD 是什麼。TDD(Test Dirven Development),又叫測試驅動開發。其特點是先寫測試用例,再進行開發。我最初聽說 TDD 的時候覺得非常的不可思議。先寫測試?再寫開發?這是效率是有多低啊?

哈哈哈,我也是有點後知後覺。

同樣,我們需要辯證的去看待 TDD 。它只是提供了一種思路:在某些情況下,我們可以先寫測試再進行具體的開發。而不是說我寫任意一行業務程式碼之前都需要先把對應的測試用例給寫了。

那麼,先測試後開發的開發模式有什麼好處呢?

在我們具體開發某個介面時,如果介面不是特別的簡單的話,我們是不會一股腦的就開始寫程式碼,而是先在腦中或是紙上設計程式碼。我們會分析這個介面有那些場景,有那些可能性,每一種場景對應的行為是什麼樣。這句話是不是有點熟悉(上文有提過,不熟悉的話你肯定沒認真看)。是的,我們可以將這些設計的過程在 UT 中體現出來,或者說,UT 能夠更好的實踐我們的程式碼設計

所以,對於某些複雜度較高的介面(甚至是一些私有方法),我們可以使用 TDD 進行開發。我建議所以的介面都通過 TDD 進行開發,反正這些介面的 UT 你是躲不掉的,就是早寫晚寫的問題。

三、推薦閱讀

相關文章