閱讀筆記:The Art of Unit Testing Chapter 7 Trustworthy tests

intro

本書第一章曾提過,好的單元測試應有三種特徵——好讀、值得信任、好維護。第二到六章說明了如何寫出「好讀」的測試,而這一章則會說明「信任感」在單元測試中扮演什麼角色。

何謂信任感

一個單元測試如果值得信任,就代表我們全心相信該測試每一次執行的結果。

We accept the test results with confidence.

當我們信任的測試紅燈時,我們會認真除錯,而不是機械性地再跑一次測試,期待結果變綠燈。當我們信任的測試綠燈時,我們便確信該單元 100% 功能正常,不需要再「手動檢查一下,以防萬一」。

為何測試會紅燈

錯的是測試

被檢驗的單元沒有任何功能瑕疵,反而是單元測試驗證的內容有誤,或誤會單元的使用方式,導致測試失敗——這就是寫壞的測試。可以透過「先寫測試,再實作」(測試驅動開發 / TDD)的開發流程來減少這類問題(參考第一章的筆記)。

我們也要避免在測試中使用邏輯,比如下列不良示範:

const makeGreeting = (name) => {
  return 'hello' + name;
};

describe('makeGreeting', () => {
  it('should return string hello with name', () => {
    const name = 'abc';
    const result = trust.makeGreeting(name);
    expect(result).toBe('hello' + name);
  });
});

如果一段邏輯已經有問題,那麼,把該邏輯照搬到單元測試裡只會讓問題繼續被埋沒。上述測試無法幫忙查出「輸出的 hello 與參數 name 之間並沒有空白」的問題。

直接驗證寫死的值,反而能正確反應該單元的問題:

describe('makeGreeting', () => {
  it('should return `hello ${name}`', () => {
    const result = trust.makeGreeting('abc');
    expect(result).toBe('hello abc');
  });
});

以下是另一個負面教材:

const isName = (input) => {
  return input.split(' ').length === 2;
};

describe('isName', () => {
  const namesToTest = ['firstOnly', 'first second', ''];
  it('correctly finds out if it is a name', () => {
    namesToTest.forEach((name) => {
      const result = trust.isName(name);
      if (name.includes(' ')) {
        expect(result).toBe(true);
      } else {
        expect(result).toBe(false);
      }
    });
  });
});

使用迴圈與條件判斷除了讓測試本身變得不易閱讀以外,當測試紅燈時,這種寫法也會提升除錯的困難度。Jest 雖然會提示出錯測試的名稱,但這並不會讓我們知道「是陣列中的哪一個項目導致測試失敗」。工程師只能耐著性子一行一行地讀,並在腦中運行每一組迴圈的執行結果。

結論:表面上看起來是少寫了幾行程式碼,但出包時的修繕成本比多寫幾組測試高太多了。

反覆無常的測試

指那些在程式碼或單元測試沒有任何改動時,執行結果便時好時壞的詭異測試。

反覆試探

以常理而言,測試中的依賴(不可控的部分)越多,測試時好時壞的可能性就越高。但這是 e2e 與系統測試(system testing)無法避免的一部分——畢竟我們執行這些測試,就是為了盡可能貼近真實情況。而現實世界中的依賴(real dependency / moving part)畢竟沒有那麼好控制:資料庫的內容會變、網路可能不穩、使用者的裝置版本可能出乎意料的舊 ⋯⋯。

flakiness chart

但對單元測試而言,結果反覆無常是不可容忍的。除了參考本書關於 stub/mock 的章節來增前對測試環境的控制外,對於那些實在無法收編的測試,請考慮刪除之。

不可信的綠燈

如果單元測試有可能因為本身的邏輯錯誤導致誤報(紅燈),那自然也會有一些理由導致單元測試給出虛假的綠燈。這類應失敗但未失敗的測試基本上:

  1. 根本沒有執行驗證(assert)
  2. 執行了模稜兩可的反面驗證,比如以下範例:
expect(() => someFunction()).not.toThrow(error);

這個測試只讓我們知道 someFunction 沒有拋錯,但我們也無法知道這個功能的「表現正常」到底是什麼。這個綠燈只是消極地確保「某功能沒有出錯」。

請使用 .toBe() .toEqual() 這類正面的比對功能(Matcher)和退出點一決勝負。

jojo-approach.png

其他注意事項

不要在一個單元測試裡執行多次驗證(expect())——當測試失敗時,你會需要多花時間確認到底是哪一組 expect() 失敗。

如果有複數種情境需要驗證,就為不同的情境撰寫對應的單元測試。這也能避免我們用太攏統的名稱來描述一個測試。比如以下範例:

describe('trigger', () => {
  it('should work as expect', () => {
    const callback = jest.fn();
    const result = trigger(1, 2, callback);
    expect(result).toBe(3);
    expect(callback).toHaveBeenCalledWith("I'm triggered");
  });
});

可以拆為兩組命名更具體的單元測試:

describe('trigger', () => {
  it('should run the callback', () => {
    const callback = jest.fn();
    trigger(1, 2, callback);
    expect(callback).toHaveBeenCalledWith("I'm triggered");
  });
  it('should return the sum from the given values', () => {
    const result = trigger(1, 2, jest.fn());
    expect(result).toBe(3);
  });
});

參考文件