隔離框架 | 單元測試的藝術 第 3 版 | 閱讀筆記

「單元測試的藝術」讀書會 - 隔離框架 (The Art of Unit Testing, 3e - Isolation Frameworks) 閱讀筆記。

投影片

前言:善用測試框架寫測試,讓測試更簡單、迅速、精簡、好維護

這本書的前幾個章節,關於實作測試程式的 stub、mock 或是 utility function,我們都是用手刻的方式實作的,這個章節要聊的是 isolation framework,isolation framework 是指可以動態建立 stub、mock 以及提供各種工具函式的 library,用它就不用手刻太多東西,寫起測試來會更簡單、迅速、精簡、好維護。順道一提,稱呼它們為 isolation framework 的原因是因為這些 library 可以將工作單元 (unit of work) 與它的 dependency 隔離,而隔離的方法不外乎就是偽造 stub 或是 mock。因此,比起稱為常聽到的「mocking framework」,稱為「isolation framework」更為貼切。這在我常用的 JavaScript 測試框架裡面,如 Jest 的世界裡,mock 往往同時代表 stub 與 mock 的概念,這就是很容易混淆的地方,但在這個章節會將它們區分開來。

測試框架的類型:Loosely Typed vs Strongly Typed

在我個人的實作經驗裡面,以 JavaScript 為主要開發語言的專案,在實作上會分為兩種風格:較為鬆散的 (loosely typed) 和較為嚴謹的 ( strongly typed) 兩種,主要的分水嶺在於有沒有使用 TypeScript 這個強型別的語言。同樣的,在寫測試的時候,我們也會依照 loosely typed 或 strongly typed 這兩種風格來決定使用哪一種 isolation framework。

然而,選用哪一種 isolation framework,主要取決於要模擬 (fake) 的依賴的類型:

模組模擬 (Modular Faking)

關於模擬可以分為幾種類型:module、function 與 Object-oriented fake,接下來會依照這些類型分別利用 isolation framework 來實作如何模擬 incoming dependency (stub) 和 outgoing dependency (mock)。

關於 module 的模擬,舉例來說,在這本書的例子 Password Verifier 有兩個依賴,如下圖所示:

Password Verifier 有兩個依賴

圖檔來源

實作 verifyPassword

import { getLogLevel } from './configuration-service';
import { debug, info } from './logger';

const log = (text) => {
  const logLevel = getLogLevel();

  switch (logLevel) {
    case 'info':
      info(text);
      break;
    case 'debug':
      debug(text);
      break;
    default:
      break;
  }
};

const verifyPassword = (input, rules) => {
  const failed = rules
    .map((rule) => rule(input))
    .filter((result) => result === false);

  if (failed.length === 0) {
    log('PASSED');
    return true;
  }

  log('FAIL');
  return false;
};

export { verifyPassword };

實作 getLogLevel

import configs from './app-config.json';

const getLogLevel = () => configs.logLevels;

export { getLogLevel };

實作 configs

{
  "logLevel": "info"
}

實作 logger 的 debuginfo

const debug = (text) => {
  console.log(`DEBUG: ${text}`);
};

const info = (text) => {
  console.log(`INFO: ${text}`);
};

export { debug, info };

實作測試程式。

import { verifyPassword } from './password-verifier';
import { getLogLevel } from './configuration-service';
import { debug, info } from './logger';

jest.mock('./logger');
jest.mock('./configuration-service');

const { stringMatching } = expect;

describe('password verifier', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  it('should call info with PASSED when no rules', () => {
    getLogLevel.mockReturnValue('info');

    verifyPassword('anything', []);

    expect(info).toHaveBeenCalledWith(stringMatching(/PASS/));
  });

  it('should call debug with PASSED when no rules', () => {
    getLogLevel.mockReturnValue('debug');

    verifyPassword('anything', []);

    expect(debug).toHaveBeenCalledWith(stringMatching(/PASS/));
  });
});

說明:

函式模擬 (Function Faking)

先前都是用手刻 mock 的方式模擬 info,然後新增一個變數 logged 來查看有沒有正確的呼叫 info

test('given logger and passing scenario', () => {
  let logged = '';
  const mockLog = { info: (text) => (logged = text) };
  const verify = makeVerifier([], mockLog);

  verify('any input');

  expect(logged).toMatch(/PASSED/);
});

改寫如下,利用 Jest 的 jest.fn 來取代手刻的 mock,以及利用 toHaveBeenCalledWith 來驗證是否有正確的呼叫 info,也就是將監控的機制交給 Jest,而不是自己實作,這樣可以讓測試程式更為簡潔,並且不用擔心模擬的函式是否有被呼叫到而必須自行驗證,能節省開發者的時間與精力,專注在實作核心的測試邏輯上。

test('given logger and passing scenario', () => {
  const mockLog = { info: jest.fn() };
  const verify = makeVerifier([], mockLog);

  verify('any input');

  expect(mockLog.info).toHaveBeenCalledWith(stringMatching(/PASS/));
});

介面模擬 (Interface Faking)

剛剛提到的是利用 jest.fn 來模擬單一函式,但如果想要模擬用介面來實作的模組呢?舉例來說,利用 TypeScript 來實作介面,IComplicatedLogger 介面有四個函式,分別是 infodebugwarnerror 如下:

interface IComplicatedLogger {
  info(text: string, method: string);
  debug(text: string, method: string);
  warn(text: string, method: string);
  error(text: string, method: string);
}

我們當然可以手刻一個 FakeLogger 來實作 IComplicatedLogger 介面,然後在實作測試程式時,在 verify 函式中使用 FakeLogger

describe('working with long interfaces', () => {
  describe('password verifier', () => {
    class FakeLogger implements IComplicatedLogger {
      debugText = '';
      debugMethod = '';
      errorText = '';
      errorMethod = '';
      infoText = '';
      infoMethod = '';
      warnText = '';
      warnMethod = '';

      debug(text: string, method: string) {
        this.debugText = text;
        this.debugMethod = method;
      }

      error(text: string, method: string) {
        this.errorText = text;
        this.errorMethod = method;
      }
    }

    test('verify, w logger & passing, calls logger with PASS', () => {
      const mockLog = new FakeLogger();
      const verifier = new PasswordVerifier2([], mockLog);

      verifier.verify('anything');

      expect(mockLog.infoText).toMatch(/PASSED/);
    });
  });
});

這樣實作的問題在於,除了按照介面實際實作起來很冗長很費時之外,每當介面有變更,像是定義新的函式或是修改參數,我們都要修改測試程式,而且修改的東西還滿多的,耗時費力。

改成 jest.fn 變得更簡潔易懂好維護。

import stringMatching = jasmine.stringMatching;

describe('working with long interfaces', () => {
  describe('password verifier', () => {
    test('verify, w logger & passing, calls logger with PASS', () => {
      const mockLog: IComplicatedLogger = {
        info: jest.fn(),
        warn: jest.fn(),
        debug: jest.fn(),
        error: jest.fn(),
      };

      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify('anything');

      expect(mockLog.info).toHaveBeenCalledWith(stringMatching(/PASS/));
    });
  });
});

利用 jest.fn 協助我們將模擬和追蹤,的確可以省下許多工作,但也許可以有更好的解法?畢竟用列舉的方式一一實作,還是會遇到當介面變更時,必須大範圍調整測試程式的問題。

改用 substitute.js 這個 strongly typed isolation framework,它可以協助動態建立模擬物件,並且可以根據介面的變更,自動生成新的函式,更能專注在實作核心的測試邏輯上。在概念上等同於我們可以自己實作 helper function 來做這件事情,但是框架幫我們做了就可以直接來用,很方便。

import { Substitute, Arg } from '@fluffy-spoon/substitute';

describe('working with long interfaces', () => {
  describe('password verifier', () => {
    test('verify, w logger & passing, calls logger w PASS', () => {
      const mockLog = Substitute.for<IComplicatedLogger>();

      const verifier = new PasswordVerifier2([], mockLog);
      verifier.verify('anything');

      mockLog.received().info(
        Arg.is((x) => x.includes('PASSED')),
        'verify'
      );
    });
  });
});

動態模擬行為 (Dynamic Stubbing Behavior)

關於動態模擬行為,我們在做 stub 的時候,可能會需要依據不同的狀況來給予不同的回傳值或是做不同的事情,最簡單的動態模擬行為可分為兩種:模擬回傳值與模擬拋出例外或內含其他操作

這個還滿簡單的,就是簡單提一下。

再次以 Password Verifier 為例,這次要模擬 isUnderMaintenance 的回傳值,來決定傳入 info 的字串是為 Under MaintenancePASSED

動態模擬行為

圖檔來源

若 Password Verifier 會依據 MaintenanceWindow 取得 isUnderMaintenance 的值,來決定傳入 info 的字串是為 Under MaintenancePASSED,這時候就可以利用 Jest 的 mockReturnValueOnce 來模擬 isUnderMaintenance 的回傳值。

interface MaintenanceWindow {
  isUnderMaintenance(): boolean;
}
describe('working with substitute', () => {
  test('verify, with logger, calls logger', () => {
    const stubMaintWindow: MaintenanceWindow = {
      isUnderMaintenance: jest
        .fn()
        .mockImplementationOnce(() => true)
        .mockImplementationOnce(() => false),
    };

    const mockLog = Substitute.for<IComplicatedLogger>();

    const verifier = new PasswordVerifier3([], mockLog, stubMaintWindow);

    verifier.verify('anything');

    mockLog.received().info(
      Arg.is((s) => s.includes('Maintenance')),
      'verify'
    );
  });
});

由於在模擬介面時,更好的作法是使用 strongly typed isolation framework,這裡改用 substitute.js 來模擬 MaintenanceWindow 介面。

describe('working with substitute', () => {
  test('verify, during maintanance, calls logger', () => {
    const stubMaintWindow = Substitute.for<MaintenanceWindow>();
    stubMaintWindow.isUnderMaintenance().returns(true);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);

    verifier.verify('anything');

    mockLog.received().info('Under Maintenance', 'verify');
  });

  test('verify, outside maintanance, calls logger', () => {
    const stubMaintWindow = Substitute.for<MaintenanceWindow>();
    stubMaintWindow.isUnderMaintenance().returns(false);
    const mockLog = Substitute.for<IComplicatedLogger>();
    const verifier = makeVerifierWithNoRules(mockLog, stubMaintWindow);

    verifier.verify('anything');

    mockLog.received().info('PASSED', 'verify');
  });
});

雖然本書是建議依照開發語言的特性來選擇 isolation framework,像是 TypeScript 就選擇 strongly typed isolation framework,像是 substitute.js;而像是純 JavaScript 就選擇 loosely typed isolation framework,像是 Jest,這樣可以讓測試程式更為簡潔易懂好維護。但是在我個人的開發經驗來看,這只是其中一個考量點,我們可能還需要考量其他的因素,像是專案可能已經有在用 Jest 來實作測試,而且 Jest 也可以做到模擬介面的事情,只是沒這麼乾淨,但是少一個 3rd-party library dependency 很可能可以讓專案少一個之後重構上絆腳石。我們在 Enzyme adapter package 上就看過類似的例子,由於 Enzyme 不再支援 React 16 之後的版本,因此並無官方提供的 adapter,React 17 以上建議使用非官方提供的 adapter,這在 React 升版與是否會破壞原先使用 Enzyme 的測試上,是很難取捨的議題,相關資訊可參考 Enzyme is dead. Now what?

寫測試…到底要用手刻?還是用框架?

我們可以思考用框架有什麼好跟不好的地方。

優點

缺點

我的經驗上還是會傾向使用框架,原因是:

總結

參考資料


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