利用 Stub 隔絕依賴 | 單元測試的藝術 第 3 版 | 閱讀筆記

「單元測試的藝術」讀書會 - 利用 Stub 隔絕依賴 (The Art of Unit Testing, 3e - Breaking Dependencies with Stubs) 閱讀筆記。

在實作單元測試時,為了有效測試特定情境和條件,必須利用 stub 對給定的函式或模組進行隔絕依賴,目的是為了讓測試更加穩定,避免造成不穩定的測試結果。

本文將會討論 stub 的目的、使用情境,以及如何透過不同的注入技術來隔絕依賴。

依賴有哪些

依賴 (dependency) 有哪些?

如何造假:Stub vs Mock

為了隔絕依賴,我們需要使用一些造假的方法,這些造假的方法統稱為 test double 或 fake,可再根據目的分為 stub 和 mock:

整理相關名詞:

類型 定義 目的 範例
test double 或 fake stub 和 mock 的總稱 - -
stub 假的輸入,模擬實作並取代原本的實作 隔離 incoming dependency 假造的測試資料、物件或函式
mock 假的輸出,模擬實作並取代原本的實作 隔離 outgoing dependency 呼叫假的服務、寫入假的資料庫
spy 監控呼叫,模擬實作但不取代原本的實作 記錄互動 是否被正確呼叫

利用 Stub 隔絕依賴的方法

列出三種利用 stub 隔絕依賴的方法:函式注入、模組注入、物件導向注入。

函式注入

利用函式注入 (functional injection) 的技術來隔絕依賴,可分為以下兩種:function parameter 與 partial application。

Function Parameter

將依賴包裝成函式,把結果傳進 SUT。這個解法的好處是最為簡便,有效降低測試的複雜度。

舉例來說,checkValentinesDay 函式會檢查今天是否為情人節,若今天是 2 月 14 日,就回傳字串 情人節快樂,若不是則回傳 今天不是情人節。對於 checkValentinesDay 來說,今天的日期是一個依賴,我們可以將取得日期的函式當成參數傳入 checkValentinesDay 函式中,這樣就可以隔絕依賴。

const checkValentinesDay = (today) => {
  return today === '2/14' ? '情人節快樂' : '今天不是情人節';
};
describe('checkValentinesDay', () => {
  it('2/12 should not be Valentines Day', () => {
    const getToday = () => '2/12';
    const today = getToday();
    expect(checkValentinesDay(today)).toBe('今天不是情人節');
  });

  it('2/14 should be Valentines Day', () => {
    const getToday = () => '2/14';
    const today = getToday();
    expect(checkValentinesDay(today)).toBe('情人節快樂');
  });
});

Partial Application

利用 partial application (或稱 currying、higher-order factory function) 將依賴包裝成函式,把函式傳進 SUT。相較前一方法更為精簡。

const checkValentinesDay = (fn) => {
  return fn() === '2/14' ? '情人節快樂' : '今天不是情人節';
};
describe('checkValentinesDay', () => {
  it('2/12 should not be Valentines Day', () => {
    const getToday = () => '2/12';
    expect(checkValentinesDay(getToday)).toBe('今天不是情人節');
  });

  it('2/14 should be Valentines Day', () => {
    const getToday = () => '2/14';
    expect(checkValentinesDay(getToday)).toBe('情人節快樂');
  });
});

模組注入

一個模組(module)通常包含多個方法,開發者在實作測試時不見得需要對所有方法進行模擬,可能只需要模擬特定方法以指定輸入來得到特定的輸出,這時就可以使用模組注入 (modular injection) 的技術。

舉例來說,bakeUtils.js 這隻檔案包含以下幾個函式 bakeChocolatePuddingbakeLemonTartbakeMatchaRollbakeAllCakes,預設會匯出 bakeAllCakes 這個函式。

const bakeChocolatePudding = () => 'Chocolate Pudding is baked.';

const bakeLemonTart = () => 'Lemon Tart is baked.';

const bakeMatchaRoll = () => 'Matcha Roll is baked.';

const bakeAllCakes = () =>
  'Chocolate Pudding, Lemon Tart and Matcha Roll are all baked.';

export default bakeAllCakes;
export { bakeChocolatePudding, bakeLemonTart, bakeMatchaRoll };

實作測試如下,利用 jest.mock 模擬 bakeUtils module,並且用 jest.requireActual('./bakeUtils') 取得真實的 bakeUtils 這個 module,帶入不需要模擬的函式,便能夠保留不想被取代的實作細節

import bakeAllCakes, { bakeMatchaRoll } from './bakeUtils';

jest.mock('./bakeUtils', () => {
  const originalModule = jest.requireActual('./bakeUtils');

  return {
    __esModule: true,
    ...originalModule,
    default: jest
      .fn()
      .mockReturnValue('Chocolate Pudding and Matcha Roll are all baked.'),
  };
});

因此,當呼叫 bakeAllCakes 時,就會執行 mock 的部份而回傳假造的字串「Chocolate Pudding and Matcha Roll are all baked.」,而非原本實作的結果。

describe('bakeAllCakes', () => {
  it('should bake Chocolate Pudding and Matcha Roll', () => {
    expect(bakeAllCakes()).toBe(
      'Chocolate Pudding and Matcha Roll are all baked.'
    );
  });
});

然而,當呼叫 bakeMatchaRoll,由於仍是帶入原本的實作細節,因此並沒有改變輸出的字串。

describe('bakeMatchaRoll', () => {
  it('should bake Matcha Roll', () => {
    expect(bakeMatchaRoll()).toBe('Matcha Roll is baked.');
  });
});

這樣只 mock 模組的某部份的方式,便能讓開發者任意的控制模組個別部份的行為,以及減少不必要的模擬,以便進行測試。

物件導向注入

物件導向注入 (object-oriented injection) 是指透過建立物件並注入依賴,以達到隔絕依賴的目的。物件導向注入有以下幾種技術:建構子、注入物件、提取共同介面。

建構子

改寫前面的例子 checkValentinesDay 函式,將取得今天日期的函式注入到建構子 (constructor) 中,並且在 checkValentinesDay 函式中呼叫這個函式。

class ValentinesDayChecker {
  constructor(getToday) {
    this.getToday = getToday;
  }

  checkValentinesDay() {
    const today = this.getToday();
    return today === '2/14' ? '情人節快樂' : '今天不是情人節';
  }
}
describe('checkValentinesDay', () => {
  it('2/12 should not be Valentines Day', () => {
    const getToday = () => '2/12';
    const checker = new ValentinesDayChecker(getToday);
    expect(checker.checkValentinesDay()).toBe('今天不是情人節');
  });

  it('2/14 should be Valentines Day', () => {
    const getToday = () => '2/14';
    const checker = new ValentinesDayChecker(getToday);
    expect(checker.checkValentinesDay()).toBe('情人節快樂');
  });
});

利用 constructor 的缺點是:(1) 當依賴過多時,建構子會變得很長,且不易閱讀;(2) 和依賴緊密耦合,不易測試。解法是拆解依賴,將依賴注入到 provider 中,並透過 provider 來注入依賴。

注入物件

將依賴當成函式注入建構子雖然能隔絕依賴,但可能會有過於冗長與耦合度過高的問題,以下改用 provider 會更有彈性。

const getToday = () => {
  const today = new Date();
  const month = today.getMonth() + 1;
  const day = today.getDate();
  return `${month}/${day}`;
};

function TimeProvider(fakeDay) {
  this.getToday = function () {
    return getToday;
  };
}

class ValentinesDayChecker {
  constructor(timeProvider) {
    this.getToday = timeProvider.getToday;
  }

  checkValentinesDay() {
    const today = this.getToday();
    return today === '2/14' ? '情人節快樂' : '今天不是情人節';
  }
}
function FakeTimeProvider(fakeDay) {
  this.getToday = function () {
    return fakeDay;
  };
}

describe('checkValentinesDay', () => {
  it('2/12 should not be Valentines Day', () => {
    const checker = new ValentinesDayChecker(FakeTimeProvider('2/12'));
    expect(checker.checkValentinesDay()).toBe('今天不是情人節');
  });

  it('2/14 should be Valentines Day', () => {
    const checker = new ValentinesDayChecker(FakeTimeProvider('2/14'));
    expect(checker.checkValentinesDay()).toBe('情人節快樂');
  });
});

這樣照樣造句的方式稱為 duck typing。

提取共同介面

有彈性是好的,但是必須要有規範來避免錯誤,這時可以透過提取共同介面來定義依賴的行為,並注入依賴。以下以 TypeScript 為例,定義 TimeProvider 介面,並且讓 FakeTimeProvider 實作這個介面。

interface TimeProvider {
  getToday: () => string;
}
class FakeTimeProvider implements TimeProvider {
  constructor(private fakeDay: string) {}

  getToday() {
    return this.fakeDay;
  }
}

class ValentinesDayChecker {
  constructor(private timeProvider: TimeProvider) {}

  checkValentinesDay() {
    const today = this.timeProvider.getToday();
    return today === '2/14' ? '情人節快樂' : '今天不是情人節';
  }
}
describe('checkValentinesDay', () => {
  it('2/12 should not be Valentines Day', () => {
    const checker = new ValentinesDayChecker(new FakeTimeProvider('2/12'));
    expect(checker.checkValentinesDay()).toBe('今天不是情人節');
  });

  it('2/14 should be Valentines Day', () => {
    const checker = new ValentinesDayChecker(new FakeTimeProvider('2/14'));
    expect(checker.checkValentinesDay()).toBe('情人節快樂');
  });
});

利用共用介面的方式來定義依賴,可以讓開發者更容易理解依賴的行為,並且提高程式碼的可讀性;而與 duck typing 的差異在於 duck typing 是在執行時期檢查,而 common interface 是在編譯時期檢查。

參考資料


The Art of Unit Testing Unit Test front end testing Jest 單元測試 自動化測試 閱讀筆記 讀書會 sharing

Summer Tang
Sponsored
Summer Tang 一對一諮詢
Summer|TSMC 主任工程師|擁有 15 年資深 Web 架構經驗,職涯歷經跨國頂尖企業與新創。專精於高流量企業級應用開發,核心技術涵蓋微前端、效能優化、測試自動化與 SEO。曾出版多本技術著作,並多次擔任 MOPCON、WebConf 等大型技術年會講者。
了解更多 →