閱讀筆記:The Art of Unit Testing Chapter 3 Breaking dependencies with stubs

Intro

我們在第一章學到單元的退出點(exit point)有三種類型:

  1. 回傳值
  2. 改變狀態
  3. 呼叫依賴(dependency)

依賴指的是「我們無法或很難控制的部分」,時間、非同步功能、檔案系統(file system)、網路——或是任何設定麻煩、跑起來非常耗時的要素——這些都算。在第二章我們為「回傳值」與「改變狀態」這兩種退出點寫了測試,在這一章則會討論如何測試那些會呼叫「進入型依賴」的單元。

(筆記備註:本書原文提到第二章討論了「回傳值」與「改變狀態」這兩種測試,但第二章的例子基本上沒有處理到改變狀態的部分⋯⋯🌚)

3.1 Types of dependencies

依賴可分為兩種類型:進入型(incoming dependency)、出走型(outgoing dependency)。

two types of dependencies

進入型

此類依賴會為單元提供資訊,扮演一個單元的「起點」。當我們要為需要這類依賴的單元撰寫測試時,會以稱為 stubs 的假資料代替依賴。

常見進入型依賴:從資料庫撈出的內容、來自 api 的回應(response)、從硬碟讀取的檔案內容。

出走型

代表一個單元的退出點(exit point),執行到這裡就代表該單元「結束」了。執行單元測試時,會以稱為 mock 的假資料替代出走型依賴,並測試該 mock 是否有被呼叫。順帶一提,因為 mock 代表退出點,所以一個單元測試一次只應關注一個 mock。

常見出走型依賴:呼叫 logger 印出結果、將內容保存到資料庫、寄信、呼叫 api 等。

名詞整理

本書以假資料(fake)取代原始依賴的「位置」來決定名稱。如果假資料要扮演供給資訊的起點,那就稱之 stub。如果假資料要在退出點發揮作用,那就稱之 mock。而 fake 是用來囊括 stub 與 mock 的泛稱。就這樣,沒有其他名字了。

3.2 Reasons to use stubs

const moment = require("moment");

const SUNDAY = 0;
const SATURDAY = 6;

const verifyPassword = (input, rules) => {
  const currentDay = moment().day();
  if ([SATURDAY, SUNDAY].includes(currentDay)) {
    throw Error("It's the weekend!");
  }

  const errors = [];
  rules.forEach((rule) => {
    const result = rule(input);
    if (!result.passed) {
      errors.push(`error ${result.reason}`);
    }
  });
  return errors;
};

以上方在週末就會拋錯的 verifyPassword 功能為例——如果工程師在單元測試中不使用 stub 來控制 currentDay 的值,寫出來的就不是「好」的單元測試。

原因是,一個良好的單元測試不應被執行環境影響結果。不論我們在什麼日子執行測試,只要 verifyPassword 的實作沒有變,單元測試的結果就該維持一致,而不是在週間綠燈、週末紅燈。

為了確保單元測試的品質,我們可以選擇重構(本章主要內容),或是透過 jest 的 isolation api 來控制 currentDay 的值(請看本書第五章)。

3.3 Generally accepted design approaches to stubbing

3.3.1 Stubbing out time with parameter injection

我們可以選擇重構,將 currentDay 透過參數傳入——這能降低單元測試的變異性(variability)。現在提供 currentDay 的責任落在呼叫此單元的人身上,我們就能輕鬆地控制測試情境了:

const verifyPassword2 = (input, rules, currentDay) => {
  if ([SATURDAY, SUNDAY].includes(currentDay)) {
    throw Error("It's the weekend!");
  }

  const errors = [];
  rules.forEach((rule) => {
    const result = rule(input);
    if (!result.passed) {
      errors.push(`error ${result.reason}`);
    }
  });
  return errors;
};

const SUNDAY = 0;
const SATURDAY = 6;
const MONDAY = 1;

describe("verifyPassword2", () => {
  test("on weekends, should throw exceptions", () => {
    expect(() => verifyPassword2("anything", [], SUNDAY)).toThrow(
      "It's the weekend!",
    );
  });
});

currentDay 參數化總共有三個意義:

  1. 我們能控制單元測試的情境
  2. 我們使用「依賴反轉(dependency inversion)」的概念來重構這個功能
  3. 現在 verifyPassword2 是個純函數(pure function)了

Dependency inversion (D from SOLID)

中文維基翻譯為「依賴反轉」。核心概念是:高層次的模組不依賴低層次模組的實作細節——依賴關係被反轉——反該由低層次模組配合高層次模組的抽象需求。終極目標是解除模組之間的耦合

Pure function

維基百科的定義如下:

  1. 此功能的回傳值只受參數影響——如果參數沒變,回傳值就不會變
  2. 此功能不會產生副作用(side effects),不會影響到它範圍外的任何東西

3.3.2 Dependencies, injections, and control

整理到目前為止出現的一些名詞與其定義:

3.4 Functional injection techniques

除了透過參數傳入依賴外,也可透過傳入功能,或是將原先的 verifyPassword 柯里化(currying)來避免把依賴寫死在功能中。

Factory functions are functions that return other functions, pre-configured with some context.

以下列 snippet 為例,我們將原本的密碼驗證功能拆成「產生驗證功能」與「密碼驗證」兩階段。呼叫 makeVerifier 時我們需要傳入 rulesgetCurrentDayFn 這兩項參數,而 makeVerifier 會回傳「已經指定好 rulesgetCurrentDayFn 的密碼驗證功能(theVerifier)」。

// the arrange
const SATURDAY = 6;
const SUNDAY = 0;
function makeVerifier(rules, getCurrentDayFn) {
  const theVerifier = (input) => {
    if ([SATURDAY, SUNDAY].includes(getCurrentDayFn())) {
      throw new Error("It's the weekend!");
    }

    const errors = [];
    rules.forEach((rule) => {
      const result = rule(input);
      if (!result.passed) {
        errors.push(`error ${result.reason}`);
      }
    });
    return errors;
  };
  return theVerifier;
}

describe("verifier", () => {
  test("factory method: on weekends, throws exceptions", () => {
    const alwaysSunday = () => SUNDAY;
    const verifyPassword = makeVerifier([], alwaysSunday);
    // the act and assert
    expect(() => verifyPassword("anything")).toThrow("It's the weekend!");
  });
});

以上重構讓單元測試變的俐落,但個人認為這波改動也降低了密碼驗證功能的可讀性。我偏向透過參數來執行依賴注入。

3.5 Modular injection techniques

以下是本書關於模組化注入(modular injection)的實作說明。首先,功能部分要追加 api 來允許模組注入:

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 currentDay = dependencies.moment().day();
  if ([SATURDAY, SUNDAY].includes(currentDay)) {
    throw Error("It's the weekend!");
  }
  // more code goes here...
  // return list of errors found..
  return [];
};

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

並且測試後要復原模組狀態:

const {
  inject,
  verifyPassword,
  SATURDAY,
} = require('./password-verifier-time00-modular');

const injectDate = (newDay) => {
  const reset = inject({
    moment: () => ({
      // we're faking the moment.js module's API here.
      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();
    });
  });
});

結論:不要自找麻煩。請選擇透過參數注入或柯里化來反轉依賴。

3.6 Moving towards objects with constructor functions

3.7 Object oriented injection techniques

3.7.1 Constructor injection

以下是重構為物件導向風味的 PasswordVerifier 功能。現在我們能透過建構子(constructor)注入依賴了:

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

注意——在下列單元測試中,我們都透過 makeVerifier 這個工廠功能(factory function)來建立驗證密碼的實例,而不是直接在每一道測試中呼叫 new PasswordVerifier(rules, getCurrentDayFn)

const { PasswordVerifier } = require('./password-verifier');

describe('refactored with constructor', () => {
  const makeVerifier = (rules, getCurrentDayFn) => {
    return new PasswordVerifier(rules, getCurrentDayFn);
  };

  test('class constructor: on weekends, throws exceptions', () => {
    // arrange
    const alwaysSunday = () => 'SUNDAY';
    const verifier = makeVerifier([], alwaysSunday);
    // act and assert
    expect(() => verifier.verify('anything')).toThrow("It's the weekend!");
  });

  test('class constructor: on weekdays, with no rules, passes', () => {
    // arrange
    const alwaysMonday = () => 'MONDAY';
    const verifier = makeVerifier([], alwaysMonday);
    // act
    const result = verifier.verify('anything');
    // assert
    expect(result.length).toBe(0);
  });
});

多包一層工廠功能(抽象層)的好處是:日後即使 PasswordVerifier 的實作方式變了,我們也不需要逐一調整單元測試的內容,而是更新 makeVerifier 就好。

更多關於抽象層的說明可以參考 第五章筆記 Consider abstracting away direct dependencies

3.7.2 Injecting an object instead of a function

3.7.3 Extracting a common interface

除了功能(function),實例(instance)也能作為依賴被注入。

首先我們實作了 class PasswordVerifier,並定義好介面 TimeProvider。每個試圖建立 class PasswordVerifier 實例的人,都必須傳入一個根據 interface TimeProvider 實踐(implements)的實例。

export interface TimeProvider {
  getDay(): number;
}

const SUNDAY = 0;
const SATURDAY = 6;

class PasswordVerifier {
  rules: any[];

  timeProvider: TimeProvider;

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

  verify(input: any): string[] {
    // saturday, sunday
    if ([6, 0].includes(this.timeProvider.getDay())) {
      throw new Error("It's the weekend!");
    }
    const errors: string[] = [];
    this.rules.forEach((rule) => {
      const result = rule(input);
      if (!result.passed) {
        errors.push(`error ${result.reason}`);
      }
    });
    return errors;
  }
}

當我們要為 class PasswordVerifier 撰寫單元測試時,只要捏出一個實踐 interface TimeProvider 的測試用實例就好:

class FakeTimeProvider implements TimeProvider {
  fakeDay: number;

  getDay(): number {
    return this.fakeDay;
  }
}

describe('password verifier with interfaces', () => {
  test('on weekends, throws exceptions', () => {
    // arrange
    const stub = new FakeTimeProvider();
    stub.fakeDay = 0;
    const verifier = new PasswordVerifier([], stub);
    // act and assert
    expect(() => verifier.verify('anything')).toThrow("It's the weekend!");
  });
});

而在我們正式使用 PasswordVerifier 時,就搭配當下使用的函式庫實作一個「真」的實例即可:

import moment from 'moment';

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

summary

總結本章內容——如果我們想透過重構來建立可靠的單元測試,可參考以下作法將依賴自單元中分離出來:

當然,不是每一次都能這麼順利地執行重構,有些遺留程式碼(legacy code)或許真的碰不得——如果是這種狀況,建議活用 jest 的 isolation api 來幫忙控制依賴回傳的值(詳見 第五章筆記 5.3 Functional mocks and stubs, dynamically第五章筆記 5.5 Stubbing behavior dynamically)。

參考文件