前端工程 程式開發

用 Storybook Test-Runner 進行 Visual、Interaction、Snapshot Test

在先前介紹 Crypto-Arsenal 的遷移計畫時,曾經提及我們要增加 Storybook 的應用。其中除了建立文件給未來的工程師外,也包含一併處理 Visual、Interaction 以及 Snapshot 測試。在這篇文章中,將介紹 Storybook Test-Runner 以及我們的用法。

Visual/Interaction/Snapshot 測試

在開始前,我想簡單說明這三類測試的意思。

  • Visual Test:視覺上的測試,透過自動擷圖比對的方式,確保視覺上沒有出現差異
  • Interaction Test:互動上的測試,透過自動操作,確保使用者操作能夠正確運作
  • Snapshot Test:執行上的測試,透過比對實際執行產生的 DOM Tree,確保修改的範圍

不難理解,這三者的測試在不同角度上關注(幾乎)相同的事。Visual Test 與 Snapshot Test 關注人類與電腦角度的不變性,而在互動的不同階段中,我們也可能需要測試這些階段中的中間狀態。

為什麼是 Test Runner

遷移計畫中提及,我們從 Storyshots + Lost-Pixel 遷移到 Test-Runner,最大理由即是 Storyshots 遭到棄用,同時 Test-Runner 能夠在單一環境處理三種測試,這有助於降低開發時的工作量。

設定方法

以下介紹在 Next.js 14 中使用 Test Runner 的作法,到安裝 Storybook 之前的步驟將省略。需要完整程式碼的可以到此 Repo
這個的設定目標是:

  1. 對元件的特定狀態執行 Visual Test 與 Snapshot Test
  2. 測試 Interaction 正確性

安裝 Test-Runner

將 Test-Runner 新增成 Dev Dependency:

yarn add --dev @storybook/test-runner

新增啟動測試的指令到 package.json,由於 Test-Runner 是設計成可以無配置執行,目前不需要添加其他設置。

{
    ...
    "script": {
    "test-storybook": "test-storybook"
    }
    ...
}

如果未曾使用過 Playwright 的話,需要額外安裝 @playwright/test 並下載 Headless Browser Binary:

yarn add --dev @playright/test
yarn playwright install

撰寫 Story

在 Test-runner 中,相當仰賴 Interaction AddonPlay function 來模擬操作(雖然即便沒有 Play function,Test-runner 仍然可以驗證渲染成功與否,但相對價值就差了許多)。
建立以下元件以及 Story:

// Button.tsx
import { useState } from "react";

export function Button({ onClick }: { onClick: (cnt: number) => void }) {
  const [count, setCount] = useState(0);
  return (
    <button
      onClick={() => {
        setCount(count + 1);
        onClick(count + 1);
      }}
    >
      {count}
    </button>
  );
}
import type { Meta, StoryObj } from "@storybook/react";
import { fn, userEvent, within } from "@storybook/test";

import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Button",
  component: Button,
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    onClick: fn(),
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.click(canvas.getByRole("button"));
  },
};

回到 Storybook 中,進入 Interaction 頁籤確認 Play function 的運作。

加入 Interaction 測試

在前面新增的 Button 元件中,每點擊一次就會讓數字加一,並將新的數字傳遞給 onClick,可以在 Play function 中增加對此的驗證。

...
play: async ({ args, canvasElement }) => {
  const canvas = within(canvasElement);

  await userEvent.click(canvas.getByRole("button"));
  await expect(args.onClick).toHaveBeenLastCalledWith(1);
  await userEvent.click(canvas.getByRole("button"));
  await expect(args.onClick).toHaveBeenLastCalledWith(1); // should be 2
},
...

這裡刻意使第二個 expect 中出錯,並執行 yarn test-storybook 後可以檢查到這個錯誤。

設定 Snapshot 與 Visual Test

Snapshot 與 Visual 測試需要比較多處理,首先安裝 jest-image-snapshot

yarn add -D jest-image-snapshot

接著在 .storybook 中新增 test-runner.ts

import { type TestRunnerConfig } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";

const config: TestRunnerConfig = {
  setup() {
    expect.extend({ toMatchImageSnapshot });
  },
  async preVisit(page) {
    if (await page.evaluate(() => !("takeSnapshot" in window))) {
      await page.exposeBinding("takeSnapshot", async ({ page }) => {
        const elementHandler = await page.$("#storybook-root");
        const innerHTML = await elementHandler?.innerHTML();
        expect(innerHTML).toMatchSnapshot();
      });
    }

    if (await page.evaluate(() => !("takeScreenshot" in window))) {
      await page.exposeBinding("takeScreenshot", async ({ page }) => {
        const image = await page.locator("#storybook-root").screenshot();
        expect(image).toMatchImageSnapshot();
      });
    }
  },
};

export default config;

這段程式碼主要在於將 Snapshot 與 Visual Test 的 API 暴露到 Playwright 環境,以利 Play function 使用。
由於目前 Test-Runner 的限制,似乎無法在 Prepare 階段暴露函數,因此這裡選擇在 Previsit 中檢查是否存在後暴露。

加上 Snapshot 與 Visual Test 呼叫

回到 Story,分別呼叫 window.takeSnapshotwindow.takeScreenshot 兩次(使用 TypeScript 時,可能需要將此兩個函數加入到 Global Declaration:

...
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    await window.takeSnapshot?.();
    await window.takeScreenshot?.();

    await userEvent.click(canvas.getByRole("button"));
    await expect(args.onClick).toHaveBeenLastCalledWith(1);

    await window.takeSnapshot?.();
    await window.takeScreenshot?.();
  },
...

接著執行 yarn test-storybook -u 後,會自動產生 Snapshot 檔案。

測試 Snapshot Test 運作

回到元件,增加不影響視覺的屬性:

...
    <button
      onClick={() => {
        setCount(count + 1);
        onClick(count + 1);
      }}
      data-testid="button" // <--
    >
      {count}
    </button>
...

再次執行 yarn test-storybook,出現 Snapshot 錯誤。

測試 Visual Test 運作

撤回變更,在 .storybook/preview.ts 引入 globals.css 來在不影響 DOM 的條件下引起視覺變化。

import "../app/globals.css"
...

再次執行 yarn test-storybook,出現 Image Snapshot 錯誤。

到此,我們已經設定好 Interaction Test、Visual Test 以及 Sanpshot Test。

結語

在官方的教學中,Snapshot 和 Visual Test 被放置到 Post visit,這使得 Snapshot 與 Visual Test 無法被任意的呼叫,而只能取得最終狀態。研究過後找到了這個方式,使我們可以在需要的時候建立 Snapshot,以更好的配合測試需求。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *