你好,單元測試 | 單元測試的藝術 第 3 版 | 閱讀筆記

「單元測試的藝術」讀書會 - 你好,單元測試 (The Art of Unit Testing, 3e - A First Unit Test) 閱讀筆記。本文包含:測試結構與命名規範、拋出與捕捉錯誤、移除重複的程式碼、快照難以聚焦問題所在、分類測試。

計時器

從看個實際範例開始 (✪ω✪) 如下程式碼所示,計時器 <Timer> component 會接收一個 waitTime 的 prop,並且在 waitTime 秒數結束後顯示提示訊息。本文接下來的說明主要都會以此 component 與其測試為例。

The Basics of Unit Testing - The Art of Unit Testing, 3e

const Timer = ({ waitTime = 3 }) => {
  const [seconds, setSeconds] = useState(waitTime);
  const intervalIDRef = useRef(null);

  const startTimer = useCallback(() => {
    if (waitTime <= 0) {
      throw new Error('waitTime must be greater than 0.');
    }

    intervalIDRef.current = setInterval(
      () => setSeconds((prev) => prev - 1),
      timerTickInterval // timerTickInterval is a constant value 1000
    );
  }, []);

  const stopTimer = useCallback(() => {
    clearInterval(intervalIDRef.current);
    intervalIDRef.current = null;
  }, []);

  useEffect(() => {
    startTimer();
    return () => clearInterval(intervalIDRef.current);
  }, []);

  useEffect(() => {
    if (seconds === 0) {
      stopTimer();
    }
  }, [seconds]);

  return (
    <div>{seconds === 0 ? `Time\'s Up` : `Remaining seconds: ${seconds}`}</div>
  );
};

點此看 demo

實作測試如下,在這裡使用 Jest 和 React Testing Library 來撰寫測試程式。在測試程式中,利用 jest.useFakeTimers 來模擬時間,並且使用 jest.advanceTimersByTime 來前進時間,以便測試 <Timer> component 的行為。最後,使用 jest.useRealTimers 來還原真實時間。

在這段測試程式碼中,分為兩個情境來測試 <Timer> component:

在情境 1 中,測試 waitTime 為 3 秒時,經過 1 秒後是否顯示剩下 2 秒的提示訊息;經過 3 秒後是否顯示 Time\'s Up 的提示訊息;在元件即將移除時是否清除計時器。在情境 2 中,測試 waitTime 為 -1 時是否拋出錯誤。

describe('Timer component', () => {
  beforeAll(() => {
    jest.useFakeTimers();
  });

  afterAll(() => {
    jest.useRealTimers();
  });

  const advanceTimersByTime = (time) => {
    act(() => {
      jest.advanceTimersByTime(time);
    });
  };

  describe('waitTime is provided with integer greater than or equal to zero', () => {
    let timerComponent;
    const renderTimerComponent = (props) => render(<Timer {...props} />);

    beforeEach(() => {
      timerComponent = renderTimerComponent({ waitTime: 3 });
    });

    // 經過 1 秒後是否顯示剩下 2 秒的提示訊息
    it('should show remaining 2 seconds when after 1 second', () => {
      const { getByText } = timerComponent;

      advanceTimersByTime(1000);

      expect(getByText('Remaining seconds: 2')).toBeInTheDocument();
    });

    // 經過 3 秒後是否顯示 Time's Up 的提示訊息
    it(`should show Time\'s Up when after 3 seconds`() => {
      const { getByText } = timerComponent;

      advanceTimersByTime(3000);

      expect(getByText(`Time\'s Up`)).toBeInTheDocument();
    });

    // 在元件即將移除時是否清除計時器
    it('cleans up the timer on unmount', () => {
      const { unmount } = timerComponent;
      const spyOnClearInterval = jest.spyOn(global, 'clearInterval');

      unmount();

      expect(spyOnClearInterval).toHaveBeenCalledTimes(1);
    });
  });

  describe('when waitTime is provided with negative integer', () => {
    // 測試 `waitTime` 為 -1 時是否拋出錯誤
    it('should throws exception with error message when waitTime is -1', () => {
      // 稍後改用 toThrowError
      try {
        render(<Timer waitTime={-1} />);
      } catch (e) {
        expect(e.message).toContain('waitTime must be greater than 0.');
      }
    });
  });
});

測試結構與命名規範

目的:

說明:

拋出與捕捉錯誤

<Timer> component 中,如果傳入的 waitTime 小於 0,由於這個情況是不合法的,無法順利倒數時間,因此會拋出錯誤。

if (waitTime <= 0) {
  throw new Error('waitTime must be greater than 0.');
}

在測試中透過 try...catch 來捕捉錯誤,並且驗證錯誤訊息是否符合預期。

describe('when waitTime is provided with negative integer', () => {
  it('should throw exception with error message when waitTime is -1', () => {
    try {
      render(<Timer waitTime={-1} />);
    } catch (e) {
      expect(e.message).toContain('waitTime must be greater than 0.');
    }
  });
});

try...catch 看起來有點冗長,Jest 提供 toThrowError 來簡化這個過程。我滿喜歡這個解法的,除了少寫一些程式碼、讓測試更為簡潔之外,這樣的表示方式也讓測試更容易閱讀和維護。

describe('when waitTime is provided with negative integer', () => {
  it('should throws exception with error message when waitTime is -1', () => {
    expect(() => render(<Timer waitTime={-1} />)).toThrowError(
      'waitTime must be greater than 0.'
    );
  });
});

拋出錯誤 vs 測試失敗

比較「拋出錯誤」與「測試失敗」的差異。

- 使用情境 優點 缺點
拋出錯誤 實作含有拋出錯誤的功能 能包含例外處理 測試實作細節,缺乏彈性
測試失敗 功能不符合預期的行為與表現 檢測核心邏輯 -

移除重複的程式碼

在精簡程式碼方面,可以考慮以下作法:(1) 利用 before* and after* hook,(2) 實作 factory method 或稱為 helper function,以及 (3) parameterized test 來減少重複的程式碼。

before* and after* Hook

在實作測試時,我們常遇到需要在進行測試前做一些準備工作,並且在測試結束後做一些收尾與清理,這時可以先整理好是否為重複設定或一次性的工作,再分別使用 before* and after* hook 來包裝,以確保測試環境的一致性和安全性,避免 flaky test,並且提高測試效率:

Factory Method / Helper Function

實作 factory method 或稱為 helper function 能用來減少重複的程式碼。例如:實作 advanceTimersByTime 來包含模擬時間前進的程式碼區塊,這樣可以讓測試更容易閱讀和維護。

const advanceTimersByTime = (time) => {
  act(() => {
    jest.advanceTimersByTime(time);
  });
};
advanceTimersByTime(input * 1000);

然而,helper function 真的好用嗎?在我的實務經驗上,如果程式碼夠簡單易懂,是不用 helper function,helper function 反而會造成誤解而必須查閱。舉例如下,givenTodayshouldBeValentinesDay 是 helper function,這兩個 function 用來設定今天的日期與驗證是否為情人節,照理說可以讓測試更容易閱讀和維護,但也許不知情的開發者無法理解這兩個 function 的用途,因此可能會造成查閱的問題,還不如直接寫在測試程式中簡單易懂 (更多說明)。總結來說,重構與精簡程式碼應優先考量「可讀性」。

重構前:

describe('今天是情人節嗎?', function () {
  let holiday = null;
  let mockGetToday = null;

  beforeEach(() => {
    holiday = new Holiday();
    mockGetToday = jest.fn();
    holiday.getToday = mockGetToday;
  });

  it('今天是情人節 2/14', () => {
    mockGetToday.mockReturnValue(new Date('2024/02/14'));
    expect(res).toBe('情人節快樂');
  });

  it('今天不是是情人節 4/14', () => {
    mockGetToday.mockReturnValue(new Date('2024/04/14'));
    expect(res).toBe('今天不是情人節');
  });
});

重構後:

describe('今天是情人節嗎?', function () {
  let holiday = null;
  let mockGetToday = null;

  beforeEach(() => {
    holiday = new Holiday();
    mockGetToday = jest.fn();
    holiday.getToday = mockGetToday;
  });

  it('今天是情人節 2/14', () => {
    givenToday('2021-02-14');
    shouldBeValentinesDay(holiday.checkValentinesDay());
  });

  it('今天不是是情人節 4/14', () => {
    givenToday('2024/04/14');
    shouldNotBeValentinesDay(holiday.checkValentinesDay());
  });

  const givenToday = (today) => mockGetToday.mockReturnValue(new Date(today));

  const shouldBeValentinesDay = (res) => {
    expect(res).toBe('情人節快樂');
  };

  const shouldNotBeValentinesDay = (res) => {
    expect(res).toBe('今天不是情人節');
  };
});

因此,我多用 before* and after* hook 而少用 helper function,除非程式碼太複雜或重複性太高,才會考慮使用 helper function。

Parameterized Test

parameterized test (參數化測試程式) 可用 test.eachit.each 來實現,以避免相似的測試所造成的重複程式碼的問題,但可能會因為錯誤訊息太通用,而有可讀性的問題。舉例來說,以下是一個參數化測試程式,測試 <Timer> component 在不同的 waitTime 下是否能正確顯示提示訊息。注意,實作 parameterized test 時,要區分不同的測試情境,例如:waitTime 為 3 或 -1 在使用的情境和意義就是不同的 (合法 vs 不合法的時間),區分開來會更容易除錯,千萬不要一股腦通通塞進同一個陣列裡。

test.each([5, 1])(
  `should show Time's Up when after different waitTime`,
  (input) => {
    const { getByText } = render(<Timer waitTime={input} />);

    advanceTimersByTime(input * 1000);

    expect(getByText(`Time\'s Up`)).toBeInTheDocument();
  }
);

快照難以聚焦問題所在

snapshot 的測試包含太多實作細節,導致出錯時難以立即確認問題所在。常見的問題如對特定元件做快照,但出錯時很難馬上確認是哪裡出了問題,像是:某個屬性改名、class name 改變、或是元件結構改變等。

解法:

False Positive

既然提到彈性,就不得不聊聊 false positive。false positive 是指測試錯誤地報告了一個問題,即使實際上沒有問題。這可能是因為測試本身有問題,或者是因為測試環境或測試資料有問題。false positive 可能會導致不必要的工作或混淆。

舉例來說,以下兩段測試程式都是在確認訊息是否正確 (在意義上是要確認一樣的目標),但是第一段測試程式使用 toContain,第二段測試程式使用 toBe,這兩者的差異在於 toContain 會比對字串是否包含特定字串,而 toBe 會比對字串是否完全相同。若錯誤訊息稍作修改呢,如加上或移除句點?在本質上並沒有太大差別,但就會因為這樣的比對差異,造成第二段測試程式失敗而報錯,這就是由於測試本身有問題而導致 false positive 的情況。

expect(e.message).toContain('waitTime must be greater than 0');
expect(e.message).toBe('waitTime must be greater than 0');

分類測試

Jest 無法直接對測試做分類,但可以用 (1) 帶入參數 --testPathPattern 或 (2) 建立不同的設定檔來達成。

帶入參數 --testPathPattern

舉例來說,帶入 --testPathPattern="Timer(\.test|\.refactor\.test)\.js$" 表示 Timer.test.jsTimer.refactor.test.js 都符合條件。

yarn test --testPathPattern=Timer\.test\.js$

得到結果,確定執行兩支檔案。

PASS  src/Timer/Timer.test.js
PASS  src/Timer/Timer.refactor.test.js

...

Test Suites: 2 passed, 2 total

其他相似的解法還有:

建立不同的設定檔

舉例來說,jest.config 是預設的設定檔,jest.config.integration.js 是整合測試的設定檔,jest.config.unit.js 是單元測試的設定檔。針對不同測試環境,可執行不同的前置指令,而針對不同的前置指令會帶入不同的設定檔,因此可分別執行帶有 integrationunit 字串的測試檔案,作為分類測試的解法。

// jest.config.integration.js
var config = require('./jest.config');
config.testRegex = 'integration\\.js$';
module.exports = config;

// jest.config.unit.js
var config = require('./jest.config');
config.testRegex = 'unit\\.js$';
module.exports = config;

執行測試的前置指令。

// package.json
"scripts": {
  "unit": "jest -c jest.config.unit.js",
  "integration": "jest -c jest.config.integration.js"
}

依照不同的測試需求選用不同的前置指令,例如:yarn unityarn integration

相關閱讀

參考資料


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