利用 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