你好,單元測試 | 單元測試的藝術 第 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

Summer Tang
Sponsored
Summer Tang 一對一諮詢
Summer|TSMC 主任工程師|擁有 15 年資深 Web 架構經驗,職涯歷經跨國頂尖企業與新創。專精於高流量企業級應用開發,核心技術涵蓋微前端、效能優化、測試自動化與 SEO。曾出版多本技術著作,並多次擔任 MOPCON、WebConf 等大型技術年會講者。
了解更多 →