「單元測試的藝術」讀書會 - 用 stubs 解除相依 (The Art of Unit Testing, 3e - Breaking dependencies with stubs) 閱讀筆記。

解除相依性

在測試 SUT ( stand for system under test),可能會遇到一些外部的相依,讓測試變得不可靠而且脆弱。這個時候我們就需要想辦法跟這些我們無法控制的元件/系統或是服務解除相依,讓這些東西可以在我們的掌控之下。

這邊依據 SUT 跟該服務的互動方式區分為 Outgoing dependencyIncoming dependency

---
title: Outgoing dependency
---
flowchart LR
    UST{{"Unit 
    of 
    work"}}
    test((Test))
    Dep{{"Dependency"}}
    test--Entry point-->UST
    UST--Exit point-->Dep

這個類型的相依通常是 API 通知、打 webhook、寫 log 或是存資料到 DB。這些會帶有 通知呼叫 或是 發送 的行為通常就是 outgoing 相依的特徵,這些行為都有把流程(或資料) 帶出 這個 unit of work 因此每一個都是一個 exit point。

---
title: Incoming dependency
---
flowchart LR
    UST{{"Unit 
    of 
    work"}}
    test((Test))
    Dep{{"Dependency"}}
    test--Entry point-->UST
    Dep--"Data or 
    behavior"-->UST
    UST -->|Exit point|E((( )))

這個類型的相依通常是 API 呼叫、從 DB 取值、從檔案拿取資料或是從網路上取得回應。這些會帶有 讀取撈取 或是 取得回應 的行為通常就是 incoming 相依的特徵,這些行為會把外部的資料或是行為 帶入 這個 unit of work,並將帶入的東西做後續處理。

為什麼要用 Stubs

在瞭解什麼是 stubs 之後就不難理解為什麼要用 stubs 了,通常在執行單元測試時有幾個重要的基本前提,如以下條件:

  • 單元測試必須是可靠的
    • 每次的輸入與輸出要是個必然的結果,不能有機率性。
  • 單元測試必須是簡單的
    • 必須不相依其他系統的搭建就能執行,像是需要檔案,或是需要網路環境或是任何第三方線上服務那這就是過於複雜的測試。
  • 單元測試必須是快速的
    • 每個單元測試在執行時必須是可以快速完成,需要是毫秒等級的回應速度

若你的單元測試不符合上述條件,通常表示這個應該不屬於單元測試的範疇,可以能是整合測試,請移駕到它適合的地方執行。

使用 Stubs 的姿勢

為了解除 incoming 類型的相依,本書提出了以下三大類型的手法來解除相依性。

以下是演示各種解法的範例程式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// file: password-verifier-time00.js

const moment = require('moment');
const SUNDAY = 0, SATURDAY = 6;

const verifyPassword = (input, rules) => {
    const dayOfWeek = moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

這隻程式的主要目的就是驗證登入時的密碼,但是有個業務邏輯是這個系統假日不運作,因此只要遇到假日就直接把登入者擋在外面(請不要問為什麼這段邏輯做在這邊)。 這隻範例程式有個明顯的問題,就是他的時間判定方式是直接在方法裡面抓取時間,外部沒有方式影響時間,因此這個方法雖然可以很好的完成業務需求,但是在測試上卻無法讓我們掌控想測試的情境。

Parameter Injection

這算是最為簡潔易懂的相依解除手法。

1
2
3
4
5
6
7
8
9
const verifyPassword = (input, rules, getDayFn) => {
    const dayOfWeek = getDayFn();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    //more code goes here...
    //return list of errors found..
    return [];
};

我們修改了方法簽章,讓它把當下時間傳入,而不是在方法裡面直接抓值。藉由這樣的改動時間由外部傳入我們就可以掌控各種我們想測試的情境了,如下的測試方法:

1
2
3
4
5
6
7
describe('verifier - dummy function', () => {
    test('on weekends, throws exceptions', () => {
        const alwaysSunday = () => SUNDAY;
        expect(()=> verifyPassword3('anything',[], alwaysSunday))
            .toThrow("It's the weekend!");
    });
});

Modular Injection

這個 Modular 的手法應該是比較專屬 Javascript 的解法,不過這個方式要做的 code change 算比較大幅度。可以看一下以下的修改方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// file: password-verifier-time00-modular.js

const originalDependencies = {    
    moment: require(moment),    
};
let dependencies = { ...originalDependencies };
const inject = (fakes) => {          
    Object.assign(dependencies, fakes);
    return function reset() {                    
        dependencies = { ...originalDependencies };
    }
};
const SUNDAY = 0; const SATURDAY = 6;

const verifyPassword = (input, rules) => {
    const dayOfWeek = dependencies.moment().day();
    if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
        throw Error("It's the weekend!");
    }
    // more code goes here...
    // return list of errors found..
    return [];
};

module.exports = {
    SATURDAY,
    verifyPassword,
    inject
};

這邊我們先用一個物件把引入的套件給包起來,並在模組外面揭露出去,讓外部使用可以替換掉實做。

而測試程式這邊可以這樣寫,可以發現我們利用包裝這一層引入自己的實做,把第三方 Modular 給隔離開來。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const { inject, verifyPassword, SATURDAY } = require('./password-verifier-time00-modular');

const injectDate = (newDay) => {  
    const reset = inject({       
        moment: function () {
            //we're faking the moment.js module's API here.
            return {
                day: () => newDay
            }
        }
    });
    return reset;
};

describe('verifyPassword', () => {
    describe('when its the weekend', () => {
        it('throws an error', () => {
            const reset = injectDate(SATURDAY);   

            expect(() => verifyPassword('any input'))
                .toThrow("It's the weekend!");

            reset();   
        });
    });
});

不過這樣做的缺點也滿明顯的,我們的實做與測試跟第三方套件的實做綁的比較死,而且可讀性也被犧牲了,若有其他更適合的方式應該優先考慮其他的作法。

Object-oriented Injection

我們以 class 包裝原先的方法,但這只是第一步,若只有作到這層包裝的話基本上跟 function parameter 差不了太多。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class PasswordVerifier {
    constructor(rules, dayOfWeekFn) {
        this.rules = rules;
        this.dayOfWeek = dayOfWeekFn;
    }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.dayOfWeek())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}

這邊我們在取得時間上多墊一層方法,讓我們可以在測試時可以替換掉

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import moment from "moment";

const RealTimeProvider = () =>  {
    this.getDay = () => moment().day()
};

const SUNDAY = 0, MONDAY=1, SATURDAY = 6;
class PasswordVerifier {
    constructor(rules, timeProvider) {
        this.rules = rules;
        this.timeProvider = timeProvider;
    }

    verify(input) {
        if ([SATURDAY, SUNDAY].includes(this.timeProvider.getDay())) {
            throw new Error("It's the weekend!");
        }
        const errors = [];
        //more code goes here..
        return errors;
    };
}

我們可以為 RealTimeProvider 提供相同的方法簽章與方法名稱來達成抽換效果,就像依賴反轉。測試的寫法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function FakeTimeProvider(fakeDay) {
    this.getDay = function () {
        return fakeDay;
    }
}

describe('verifier', () => {
    test('class constructor: on weekends, throws exception', () => {
        const verifier = 
            new PasswordVerifier([], new FakeTimeProvider(SUNDAY));

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

就由這個方式我們就可以把取時間的方式替換成我們自己的測試情境需要的實做,這個實做方式通常我們會叫做 duck typing 。不過這個作法若是看習慣編譯式語言應該會很不習慣,因為實做這個方法簽章的人並沒有明確宣告他也有實做同一個界面(其實我們也沒有特地為這個方法做出抽象化的界面),但是只要提供相同的方法簽章,似乎就能成功辨識並且抽換實做。

不過若是使用 typescript 我們可以寫得更像編譯式語言:

1
2
3
export interface TimeProviderInterface {
    getDay(): number;
}

我們先為這個方法抽處出同樣的方法簽章,接著原本取得時間的方法就可以這樣寫:

1
2
3
4
5
6
7
8
import * as moment from "moment";
import {TimeProviderInterface} from "./time-provider-interface";

export class RealTimeProvider implements TimeProviderInterface {
    getDay(): number {
        return moment().day();
    }
}

而類別我們就改為依賴這個界面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export class PasswordVerifier {
    private _timeProvider: TimeProviderInterface;

    constructor(rules: any[], timeProvider: TimeProviderInterface) {
        this._timeProvider = timeProvider;
    }

    verify(input: string):string[] {
        const isWeekened = [SUNDAY, SATURDAY]
            .filter(x => x === this._timeProvider.getDay())
            .length > 0;
        if (isWeekened) {
            throw new Error("It's the weekend!")
        }
         // more logic goes here
        return [];
    }
}

這樣的寫法就很類似 Java 或是 C# ,而測試的寫法看起來也很雷同了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class FakeTimeProvider implements TimeProviderInterface{
    fakeDay: number;
    getDay(): number {
        return this.fakeDay;
    }
}

describe('password verifier with interfaces', () => {
    test('on weekends, throws exceptions', () => {
        const stubTimeProvider = new FakeTimeProvider();
        stubTimeProvider.fakeDay = SUNDAY;
        const verifier = new PasswordVerifier([], stubTimeProvider);

        expect(() => verifier.verify('anything'))
            .toThrow("It's the weekend!");
    });
});

藉由這個寫法我們依舊達成了自由替換寫法的目的,讓我們可以想測什麼情境就測什麼。

本章小結

  • 有兩種解耦相依的類型 stubmock
  • Mock 解耦 outcoming 類型的相依
  • Stub 解耦 incoming 類型的相依
  • 避免使用 modular injection
  • Object as parameter aka duck typing,在 javascript 可以利用語言的特性,實做相同簽章的方法來替換原先的實做內容,進而把該相依解耦
  • Common interface as parameter 這個手法類型常見的物件導向類型的語言作法,好處是可以在編譯時期就抓到可能的錯誤,但你會需要像是 typescript 的支援做到。

其實本書提到的避免使用 modular injection 作法,應該是說若沒有使用測試套件的功能那就會讓更動幅度變大,這樣的話就很不建議使用這種方式解耦,你可以重構程式變成 Object as parameter 或是 Common interface as parameter 這樣可以讓你的程式碼跟測試可以不會因為第三方模組綁的太深,導致可讀性跟強健性變低。

Reference