閱讀筆記:The Art of Unit Testing Chapter 6 Unit testing asynchronous code

intro

還記得依賴(dependency)的定義嗎?所有無法、很難控制的要素都是依賴,所以非同步功能(async function)自然也是一種依賴——畢竟我們無法控制非同步功能的完成時間。

有鑒於此不可控性,本章提供兩種實作技巧來讓我們寫出良好的單元測試:

  1. 分離進入點(extracting an entry point)
  2. 採用配接器模式(adapter pattern)分離非同步的部分

6.1 Dealing with async data fetching

非同步功能為單元測試帶來的困擾是——產生結果所需的時間、結果是成功或失敗——這些都是我們無法控制的部分。以下方的 isWebsiteAlive 為例:

const fetch = require('node-fetch');

const isWebsiteAlive = async () => {
  try {
    const resp = await fetch('http://example.com');
    if (!resp.ok) {
      // exit point 1
      throw resp.statusText;
    }
    const text = await resp.text();
    if (text.includes('illustrative')) {
      // exit point 2
      return { success: true, status: 'ok' };
    }
    // exit point 3
    throw 'text missing';
  } catch (err) {
    // exit point 4
    return { success: false, status: err };
  }
};
  1. 我們無法控制 await fetch("http://example.com") 何時回應,測試可能變得耗時
  2. 我們無法控制 http://example.com 這個服務是否在線上 ,執行測試時,我們無法控制要進入哪一個退出點
  3. 承上,因為無法控制網站的上線與否,當測試亮紅燈時,我們要檢查到底是網站掛掉,還是測試壞掉——多來幾次以後,我們就不再信賴這個測試,也不會信任執行結果了

綜上所述,為了能寫出穩固、能全心信賴的單元測試,在實作功能時請透過以下技巧製造接縫(seam,可複習第三章的筆記):

  1. 分離進入點(extracting and entry point):意思是把一個單元裡「純邏輯」的部分獨立成一個功能,並把這個功能當成新的進入點,對其執行單元測試
  2. 採用配接器模式(adapter patter):工程師只要遵守定義好的介面,就能替換掉非同步的部分

6.2 Making our code unit-test friendly

Extracting an entry point

我們可以將 isWebsiteAlive 裡,只進行邏輯判斷(純)的部份抽出(throwIfResponseNotOK / processFetchContent / processFetchError):

const fetch = require('node-fetch');

const throwIfResponseNotOK = (resp) => {
  if (!resp.ok) {
    throw resp.statusText;
  }
};
const processFetchContent = (text) => {
  const included = text.includes('illustrative');
  if (included) {
    return { success: true, status: 'ok' };
  }
  return { success: false, status: 'missing text' };
};
const processFetchError = (err) => {
  return { success: false, status: err };
};

// Await version
const isWebsiteAlive = async () => {
  try {
    const resp = await fetch('http://example.com');
    // exit point 1
    throwIfResponseNotOK(resp);
    const text = await resp.text();
    // exit point 2
    return processFetchContent(text);
  } catch (err) {
    // exit point 3
    return processFetchError(err);
  }
};

雖然我們依舊無法控制 const resp = await fetch("http://example.com"); 的結果,但能透過單元測試確保「這三個新單元會根據 resp 內容採取正確的行動」:

describe('throwIfResponseNotOK', () => {
  it('should not throw an error if response is ok', () => {
    const response = { ok: true };
    expect(() => throwIfResponseNotOK(response)).not.toThrow();
  });
  it('should throw an error with response status text if response is not ok', () => {
    const response = { ok: false, statusText: 'Not Found' };
    expect(() => throwIfResponseNotOK(response)).toThrowError('Not Found');
  });
});

describe('processFetchContent', () => {
  it("should return success true and status 'ok' when text includes 'illustrative'", () => {
    const text = 'This is illustrative content';
    expect(processFetchContent(text)).toEqual({
      //
      success: true,
      status: 'ok',
    });
  });
  it("should return success false and status 'missing text' when text does not include 'illustrative'", () => {
    const text = 'Some random content';
    expect(processFetchContent(text)).toEqual({
      //
      success: false,
      status: 'missing text',
    });
  });
});

describe('processFetchError', () => {
  test('should return an object with success false and provided error message', () => {
    const errorMessage = 'Failed to fetch';
    expect(processFetchError(errorMessage)).toEqual({
      success: false,
      status: 'Failed to fetch',
    });
  });
});

以上就是用「分離進入點」的概念重構 isWebsiteAlive,以便提升測試覆蓋率的方法。

Extract adapter pattern

配接器模式說白了,就是把「呼叫非同步功能的介面」與「功能的實作方式」分開來。這種設計的好處是,我們能在測試時使用同步(synchronous)的假模組、假功能、假實例。

module solution

第一種作法:以模組作為介面,把非同步功能獨立到另一包檔案中。

比如把 isWebsiteAlive 中的 await fetch 拆進 network.js 中,變成一個獨立的功能 fetchUrlText。剩下的就留在 index.js 裡:

// network.js
const fetchUrlText = async (url) => {
  const resp = await fetch(url);
  if (resp.ok) {
    const text = await resp.text();
    return { ok: true, text: text };
  }
  return { ok: false, text: resp.statusText };
};
// index.js
const { fetchUrlText } = require('./network');

const processFetchSuccess = (text) => {
  const included = text.includes('illustrative');
  if (included) {
    return { success: true, status: 'ok' };
  }
  return { success: false, status: 'missing text' };
};

const processFetchFail = (err) => {
  return { success: false, status: err };
};

const isWebsiteAlive = async () => {
  try {
    const result = await fetchUrlText('http://example.com');
    if (!result.ok) {
      return processFetchFail(result.text);
    }
    const text = result.text;
    return processFetchSuccess(text);
  } catch (err) {
    throw new Error(err);
  }
};

在測試 isWebsiteAlive 時,我們就能透過 jest.mock().mockResolvedValue() 控制非同步功能 fetchUrlText() 回傳的值,進而決定我們在每一個單元測試中要通過哪一個退出點:

jest.mock('./network');

const stub = require('./network');
const { isWebsiteAlive } = require('./index');

describe('isWebsiteAlive', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  it('should return success: true, status: ok when fetchUrlText returns ok and text include `illustrative`', async () => {
    // arrange
    stub.fetchUrlText.mockResolvedValue({
      ok: true,
      text: 'This is illustrative text',
    });
    // act
    const result = await isWebsiteAlive();
    // assert
    expect(result).toEqual({ success: true, status: 'ok' });
  });
  it('should return success: false, status: missing text when fetchUrlText returns ok but text not include `illustrative`', async () => {
    // arrange
    stub.fetchUrlText.mockResolvedValue({
      ok: true,
      text: 'Some random text',
    });
    // act
    const result = await isWebsiteAlive();
    // assert
    expect(result).toEqual({ success: false, status: 'missing text' });
  });
  it('should throw success: false and status with error text when fetchUrlText return ok: false', async () => {
    // arrange
    stub.fetchUrlText.mockResolvedValue({
      ok: false,
      text: 'Failed to fetch',
    });
    // act
    const result = await isWebsiteAlive();
    // assert
    expect(result).toEqual({ success: false, status: 'Failed to fetch' });
  });
});

function parameter solution

第二種作法:以參數作為介面來傳入非同步功能——這樣在測試時,我們就能透過參數傳入假的同步功能。

比如以下重構後的 isWebsiteAlive 就允許我們透過參數注入 fetchUrlText()

const processFetchSuccess = (text) => {
  const included = text.includes('illustrative');
  if (included) {
    return { success: true, status: 'ok' };
  }
  return { success: false, status: 'missing text' };
};

const processFetchFail = (err) => {
  return { success: false, status: err };
};

const isWebsiteAlive = async (fetchUrlText) => {
  const result = await fetchUrlText('http://exa-mple.com');
  if (!result.ok) {
    return processFetchFail(result.text);
  }
  const text = result.text;
  return processFetchSuccess(text);
};

在撰寫測試時,就能透過工廠功能 getStubResult() 控制環境了:

const { isWebsiteAlive } = require('./index');

// arrange
const getStubResult = (ok, text) => () => ({
  ok,
  text,
});

describe('isWebsiteAlive', () => {
  it('should return success: true, status: ok when fetchUrlText returns ok and text include `illustrative`', async () => {
    // act
    const result = await isWebsiteAlive(
      getStubResult(true, 'This is illustrative text')
    );
    // assert
    expect(result).toEqual({ success: true, status: 'ok' });
  });
  it('should return success: false, status: missing text when fetchUrlText returns ok but text not include `illustrative`', async () => {
    // act
    const result = await isWebsiteAlive(
      getStubResult(true, 'Some random text')
    );
    // assert
    expect(result).toEqual({ success: false, status: 'missing text' });
  });
  it('should throw success: false and status with error text when fetchUrlText return ok: false', async () => {
    // act
    const result = await isWebsiteAlive(
      getStubResult(false, 'Failed to fetch')
    );
    // assert
    expect(result).toEqual({ success: false, status: 'Failed to fetch' });
  });
});

interface based solution

第三種作法:以物件導向風格進行開發時,可以透過定義介面(interface)來分離實作細節——當我們想為該單元撰寫測試時,只要根據介面規則實作假實例即可。

WebsiteVerifier 為例,我們會定義以下三種介面:

export interface FetchAdapter {
  fetchUrlText(url: string): Promise<FetchResult>;
}

export type FetchResult = {
  ok: boolean;
  text: string;
};

export type WebsiteAliveResult = {
  success: boolean;
  status: string;
};

在正式環境裡,我們會根據 FetchAdapter 實作一個「真」的 NetworkAdapter 實例,並在建構 class WebsiteVerifier 時,傳入這個 NetworkAdapter 作為參數:

import type { FetchAdapter, FetchResult } from './types';

export class NetworkAdapter implements FetchAdapter {
  async fetchUrlText(url: string): Promise<FetchResult> {
    const resp = await fetch(url);
    if (resp.ok) {
      const text = await resp.text();
      return { ok: true, text: text };
    }
    return { ok: false, text: resp.statusText };
  }
}
import type { FetchAdapter, WebsiteAliveResult } from './types';

export class WebsiteVerifier {
  network: FetchAdapter;

  constructor(network: FetchAdapter) {
    this.network = network;
  }

  isWebsiteAlive = async (): Promise<WebsiteAliveResult> => {
    try {
      const result = await this.network.fetchUrlText('http://example.com');
      return result.ok
        ? this.processFetchSuccess(result.text)
        : this.processFetchFail(result.text);
    } catch (err: any) {
      throw new Error(err);
    }
  };

  processFetchSuccess = (text: string): WebsiteAliveResult => {
    const included = text.includes('illustrative');
    return included
      ? { success: true, status: 'ok' }
      : { success: false, status: 'missing text' };
  };

  processFetchFail = (err: any): WebsiteAliveResult => {
    return { success: false, status: err };
  };
}

但是——在為 class WebsiteVerifier 寫測試時,我們大可直接根據 interface FetchAdapter 實作一個測試專用的 StubNetworkAdapter 來控制 fetchUrlText() 的回傳結果,進而為所有的情境撰寫對應測試:

import type { FetchAdapter, FetchResult } from './types';
import { describe, it, expect } from '@jest/globals';
import { WebsiteVerifier } from './index';

class StubNetworkAdapter implements FetchAdapter {
  ok: boolean;
  text: string;

  constructor(ok: boolean, text: string) {
    this.ok = ok;
    this.text = text;
  }

  fetchUrlText(): Promise<FetchResult> {
    return this.ok
      ? Promise.resolve({ ok: this.ok, text: this.text })
      : Promise.reject(new Error(this.text));
  }
}

const getVerifierWithStubAdapter = (
  ok: boolean,
  text: string
): WebsiteVerifier => {
  const stubAdapter = new StubNetworkAdapter(ok, text);
  return new WebsiteVerifier(stubAdapter);
};

describe('WebsiteVerifier', () => {
  it('should return success: true, status: ok when fetchUrlText returns ok and text include `illustrative`', async () => {
    // arrange
    const verifier = getVerifierWithStubAdapter(
      true,
      'This is illustrative text'
    );
    // act
    const result = await verifier.isWebsiteAlive();
    // assert
    expect(result).toEqual({ success: true, status: 'ok' });
  });
  it('should return success: false, status: missing text when fetchUrlText returns ok but text not include `illustrative`', async () => {
    // arrange
    const verifier = getVerifierWithStubAdapter(true, 'Some random text');
    // act
    const result = await verifier.isWebsiteAlive();
    // assert
    expect(result).toEqual({ success: false, status: 'missing text' });
  });
  it('should throw success: false and status with error text when fetchUrlText return ok: false', async () => {
    // arrange
    const verifier = getVerifierWithStubAdapter(false, 'Failed to fetch');
    try {
      // act
      await verifier.isWebsiteAlive();
    } catch (e: any) {
      // assert
      expect(e.message).toMatch(/Failed to fetch/);
    }
  });
});

我們甚至不需要 jest 的隔離 api 就能搞定所有退出點的單元測試 👍

6.3 Dealing with timers

在給有計時器(timer)的單元寫測試時,可以考慮以猴子補丁(monkey patch)或直接借用 jest api 來化解 setTimeout() / setInterval() 的非同步特徵。

Monkey patching

猴子補丁(monkey patch)指的是工程師能在執行一段程式時,「動態地修改其功能」的行為。此修改的影響是一時而非永久。

Monkey patching is a way for a program to extend or modify supporting system software locally (affecting only the running instance of the program).

當我們在給呼叫計時器的單元寫測試時,可以運用猴子補丁技巧來暫時修改 setTimeout() 的行為,並且在測試結束後將它回復原狀。

比如以下範例,我們在測試時暫時移除了 setTimeout() 的非同步特性,並在測試結束後執行復原:

const calculate1 = (x, y, resultCallback) => {
  setTimeout(() => {
    resultCallback(x + y);
  }, 1000);
};

describe('calculate1', () => {
  describe('in monkey patching style', () => {
    // arrange
    let originalTimeOut;
    beforeEach(() => {
      originalTimeOut = setTimeout;
      setTimeout = (cb) => cb();
    });
    afterEach(() => (setTimeout = originalTimeOut));

    it('should return calculate result after 1 second', () => {
      // act, assert
      calculate1(1, 2, (result) => expect(result).toBe(3));
    });
  });
});

Faking timer with Jest

Jest 提供隔離 api useFakeTimers() / useRealTimers() 來讓工程師決定要在測試時使用假或真的計時器。如果我們想讓測試儘速執行完畢,可選擇呼叫 useFakeTimers() 讓 Jest 來覆蓋計時器的預設行為。

for setTimeout()

以下列 snippet 為例,我們讓 Jest 控制計時器後,不需要「真的等待一秒過去」才能看到單元執行完畢。想驗證 setTimeout() 是否有如期被呼叫,也能搭配 toHaveBeenCalledTimes() 來進行驗證:

const { calculate1 } = require('./index');

describe('calculate1', () => {
  describe('in jest isolation api style', () => {
    // arrange
    beforeEach(() => {
      jest.clearAllTimers();
      jest.useFakeTimers();
    });
    it('should return calculate result', () => {
      // act, assert
      calculate1(1, 2, (result) => expect(result).toBe(3));
    });
    it('should call setTimeout once', () => {
      // arrange
      jest.spyOn(global, 'setTimeout');
      // act
      calculate1(1, 2);
      // assert
      expect(setTimeout).toHaveBeenCalledTimes(1);
    });
  });
});

for setInterval()

當單元包含 setInterval() 時,可搭配 jest.advanceTimersToNextTimer() 來推進計時器。在下列範例中,我們對 jest.advanceTimersToNextTimer() 傳入參數 2,代表計時器要被觸發兩次——因此 results 也該出現兩組結果:

const calculate2 = (getInputsFn, resultFn) => {
  setInterval(() => {
    const { x, y } = getInputsFn();
    resultFn(x + y);
  }, 1000);
};

describe('calculate2', () => {
  beforeEach(() => {
    jest.clearAllTimers();
    jest.useFakeTimers();
  });
  it('should execute getInputsFn/resultFn at interval', () => {
    // arrange
    let xInput = 1;
    let yInput = 2;
    const results = [];
    const resultFn = (r) => results.push(r);
    const inputFn = () => ({ x: xInput++, y: yInput++ });

    // act
    calculate2(inputFn, resultFn);
    jest.advanceTimersToNextTimer(2);

    // assert
    expect(results[0]).toBe(3);
    expect(results[1]).toBe(5);
  });
});

6.4 Dealing with common events

📢 溫馨提示:為了順利執行下列測試,請記得安裝 jest-environment-jsdom,因為此套件在 Jest 28 以後不再是預設安裝內容

Error: ...
As of Jest 28 "jsdom" is no longer shipped by default, make sure to install it separately.

另外,在 Jest 28 以後也支援「指定個別測試檔案的環境」。過去只能透過 jest.config 中的 testEnvironment 為所有的測試指定一種環境(nodejsdom)。而現在,我們能在「需要操作 DOM 的測試檔案」加入下方註解來指定該檔案的測試環境:

/**
 * @jest-environment jsdom
 */

Dealing with event emitters

想測試一個包含 window.dispatchEvent() 的單元時,可以在 beforeEach 中掛載事件監聽器、在 afterEach 移除之,並檢查假功能 handler 是否有如期被呼叫。參考以下範例:

/**
 * @jest-environment jsdom
 */

const emitMessageEvent = (detail: string) => {
  const event = new CustomEvent('message', { detail });
  window.dispatchEvent(event);
};

describe('emitMessageEvent', () => {
  let handler: jest.Mock;
  beforeEach(() => {
    handler = jest.fn();
    window.addEventListener('message', handler);
  });
  afterEach(() => {
    window.removeEventListener('message', handler);
    handler.mockReset();
  });
  it('should dispatch a custom event with the correct detail', () => {
    // arrange
    const detail = 'Test message';
    // act
    emitMessageEvent(detail);
    // assert
    const [event] = handler.mock.calls[0];
    expect((event as CustomEvent).detail).toBe(detail);
  });
});

Dealing with click events

當我們想測試「一個 DOM 的點擊事件是否有正常運作」時,可以把重點放在「事件派送後,檢查預期結果是否出現」。

以下方 index.htmlindex.js 為例,兩者搭配起來的行為是「當使用者點擊 id="myButton" 按鈕後,畫面上的 id="myResult" 要出現 Clicked! 字樣」:

<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>File to Be Tested</title>
    <script src="./index.js"></script>
  </head>
  <body>
    <div>
      <h1>A simple button</h1>
      <button id="myButton">Click Me</button>
      <div id="myResult">Waiting...</div>
    </div>
  </body>
</html>
// index.js

function onMyButtonClick() {
  const resultDiv = document.getElementById('myResult');
  resultDiv.innerText = 'Clicked!';
}

window.addEventListener('load', () => {
  document
    .getElementById('myButton')
    .addEventListener('click', onMyButtonClick);
});

module.exports = {
  onMyButtonClick,
};

在撰寫測試時,我們可直接執行 onMyButtonClick(),再檢查元素 id="myResult"innerText 是否有變為預期內容:

/**
 * @jest-environment jsdom
 */

const fs = require('fs');
const path = require('path');
const { onMyButtonClick } = require('./index');

const setHtmlContent = () => {
  const html = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf8');
  document.documentElement.innerHTML = html;
};
const getTargetDiv = () => document.getElementById('myResult');

describe('Button click behavior', () => {
  test('myButton innerText should change after clicking', () => {
    // arrange
    setHtmlContent();
    const target = getTargetDiv();
    // act
    onMyButtonClick();
    // assert
    expect(target.innerText).toBe('Clicked!');
  });
});

此概念類似上方提過的「分離進入點(extracting and entry point)」,我們不一定要在測試中鉅細靡遺地重現使用者的行為,重點是某個功能被觸發後,是否有產生預期結果

What we care about is that the click has actually done something useful other than triggering.

以上便是本書第六章的筆記內容,恭喜你搞懂如何測試那些帶有非同步功能的單元了。

參考文件