單元測試如何保證了易用的API

Ender發表於2017-11-11

備註:
文章來源於我的這個答案:TDD的意義. 因為原題目被鎖了,所以搬移出來.
本文不強烈區分TDD單元測試, 雖然前者是方法論, 後者是實踐這一方法論的具體技術. 不過大多數場景都是用單元測試來踐行TDD的.

一般而言TDD的好處是以輸出為導向及早發現問題,以及方便重構(單元測試保證).
我理解,還有一個比較重要的意義是: 客觀上強制了程式設計師寫出更加友好的介面 方便測試和聯調.

問題

這裡我以c++舉例,需求就用最簡單的: 實現一個單例類(比如說一個讀取資料庫的單例).
好,拿到這個需求了,考慮到c++11之後static本身就是多執行緒安全的,所以實現一個單例模式就很簡單了,如下:

// 手打不保證編譯ok
class SingleDb {
public:
    int getMoney(){...}
    SinlgeDb(const SingleDb &) = delete;
    void operator=(const SingleDb &) = delete;
    static SingleDb &get() {
        static SingleDb db;
        return db;
    }
};

ok, 單例類的主體工作基本上就完成了,程式碼中直接可以用SingleDb::get()就可以獲得這個單例,再補充和業務相關的讀取等成員函式即可.
好了,本來這樣就ok了,但是老闆現在要求大家每個新功能都要求寫單元測試,客戶端程式設計師(這裡的客戶端指代的是使用這個單例模式的程式設計師)呼叫了這個API之後就不爽了,因為他想要對他自己的業務程式碼進行單元測試,但是在讀資料庫的時候無法進行打樁測試。具體遇到的問題如下:

struct Client{
 int doSth() const {
    SingleDb &db = SingleDb::get();
    if(db.getMoney() < 0) return -1;
  }
};

完全無法測試,因為我們在寫單元測試的時候無法控制db.getMoney()的輸出進行控制. 因為需要做如下改造:

API適配

資料庫的物件不透過靜態成員函式獲取,而修改成注入的方式,這樣方便構造輸入.
資料庫單例類沒有抽象基類,無法用構造類(或者說是Mock物件)替換, 需改改寫成有繼承體系的類.
實現:
針對以上兩點,修改之後的樣子

struct DbBase {
    virtual int getMoney() = 0;
};
class SingleDb: public DbBase {
// 沒啥變化
};

//客戶端
struct Client {
    DbBase &db;
    explicit Client(DbBase &db): db(db) {}
    int doSth() const {
        if(db.getMoney() < 0) return -1;
    }
};

使用(單元測試)

這樣處理之後,單例類已經變成了一個更加易於單元測試的類了,以一個比較簡單的單元測試框架作為例子給出(catch+fakeit)

TEST_CASE("", "")
{
    using namespace fakeit;
    Mock mock;
    when(Method(mock, getMoney())).Return(-1);
    Client c(mock.get());
    REQUIRE(c.doSth() == -1);
}

這實際上是為了單元測試而把API的介面改了,不僅僅是更加易於單元測試了. 更有意義的是,把介面提升至抽象類,以後想擴充套件實現類,直接就新增繼承類即可,單元測試都不用動(因為傳入的是抽象基類).
或者哪一天用到的單元測試框架沒人維護了需要切換單元測試程式碼,那業務程式碼根本也不需要動,因為抽象介面已經固定. 不用Mock框架,自己打個樁都能單元測試, 例子如下:

// 構造打樁繼承類
struct DataBaseMock : public DbBase
int getMoney() {return -1;}
};

// 測試
...
DataBaseMock db;
Client c(&db);
REQUIRE(c.doSth() == -1);

總結:

實際上,撰寫一個好的API的好處本身又是另外一個話題了(不僅僅有助於單元測試),但是TDD這個開發模式能夠強迫程式設計師寫出一個更加易用的API.

相關文章