Snapshot TestingVitestTesting Libraryjsdom前端測試

Vitest Snapshot Testing 教學:快照測試實戰與避坑

從 Vitest + Testing Library + jsdom 出發,掌握 Snapshot Testing 適用時機、更新流程、PR diff 審查與 brittle snapshots 避坑策略。

· 6 分鐘閱讀

你會學到如何在真實專案中把 Snapshot 當成「變更偵測工具」而不是「萬能驗證工具」,並建立可長期維護的審查流程。


前言

很多團隊第一次導入 Snapshot Testing 時,都有兩個極端。第一種是「全都 snapshot」,結果每次改 UI 都像地震,review 幾十個 .snap 差異,最後只想按 -u 全部更新。第二種是「只用一般斷言」,每次改版都擔心漏掉某個 DOM 結構變更。

其實 Snapshot 的定位很單純:它擅長抓「輸出有沒有改」,不擅長判斷「改得對不對」。只要把這個邊界先講清楚,Snapshot 會非常有用,尤其在展示型元件、序列化資料、產生器輸出這些場景。

這篇會用 Vitest 為主軸,帶你從可執行的最小設定開始,接著走完基本用法、更新流程、diff 審查,以及實務上最容易踩雷的 brittle snapshots。重點不是 API 清單,而是讓你能在團隊流程裡落地。

先把環境設對:Vitest + Testing Library + jsdom 最小設定

只安裝 vitest 通常不夠。你如果要測 React 元件,還需要 DOM 環境與測試工具。

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

建立 vitest.config.ts

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom', // 讓 render() 有瀏覽器 API
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
  },
});

建立 setup 檔:

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

package.json 建議先有這兩個指令:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

這樣你就有可執行的基線:

  1. vitest 負責 runner 與斷言
  2. jsdom 提供 DOM 環境
  3. Testing Library 提供元件互動與查詢 API

Snapshot 基本用法:先從小而穩定的輸出開始

以下用一個展示型元件示範。重點是輸出結構穩定、狀態有限,這種元件最適合 Snapshot。

// src/components/Badge.tsx
import type { ReactNode } from 'react';

type BadgeVariant = 'neutral' | 'success' | 'danger';

interface BadgeProps {
  variant?: BadgeVariant;
  children: ReactNode;
}

export function Badge({ variant = 'neutral', children }: BadgeProps) {
  return (
    <span className={`badge badge--${variant}`} data-variant={variant}>
      {children}
    </span>
  );
}
// src/components/Badge.test.tsx
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { Badge } from './Badge';

describe('Badge snapshot', () => {
  it('neutral variant 的輸出應維持一致', () => {
    const { container } = render(<Badge>Draft</Badge>);
    expect(container.firstChild).toMatchSnapshot();
  });

  it('danger variant 的輸出應維持一致', () => {
    const { container } = render(<Badge variant="danger">Delete</Badge>);
    expect(container.firstChild).toMatchSnapshot();
  });
});

第一次執行 npm run test:run 會建立 .snap 檔。後續執行若輸出改變,測試就會失敗,並在終端顯示差異。

Snapshot 更新流程:-u 是工具,不是快捷逃生門

Vitest 官方可用的更新方式是:

# 更新所有失敗的 snapshot
npx vitest -u

# 或完整參數
npx vitest --update

# 限定檔案更新(建議在 PR 上更精準)
npx vitest -u src/components/Badge.test.tsx

這段流程建議固定成團隊習慣:

  1. 先跑 vitest run 看失敗內容
  2. 確認變更是預期行為(不是 accidental change)
  3. 再執行 vitest -u 更新
  4. 在 PR 上逐段 review .snap diff

實務上最重要的是第 2 和第 4 步。Snapshot 失敗只代表「有改」,不保證「改得對」。

如何看 diff:先看終端輸出,再決定要不要落檔

很多人會以為 Vitest 會固定產生 *.actual.**.expected.* 檔案。這不是通用預設行為。一般情況下,你會先在終端看到 mismatch diff,搭配 Git 的差異檢視即可完成大多數審查。

如果團隊真的需要把差異輸出到檔案,通常要另外搭配工具或自訂流程,不應該當作 Snapshot 預設機制來教。對教學文來說,講清楚這個邊界,比塞一個不穩定行為更重要。

Snapshot 不只測元件:資料轉換也很適合

相較於直接 fetch('/api/users'),在 Node/Vitest 測試環境更穩定的示範是「測純函式輸出」。

// src/utils/normalizeUsers.ts
export interface ApiUser {
  id: number;
  first_name: string;
  last_name: string;
  is_active: boolean;
}

export function normalizeUsers(input: ApiUser[]) {
  return input.map((user) => ({
    id: user.id,
    fullName: `${user.first_name} ${user.last_name}`,
    status: user.is_active ? 'active' : 'inactive',
  }));
}
// src/utils/normalizeUsers.test.ts
import { describe, expect, it } from 'vitest';
import { normalizeUsers } from './normalizeUsers';

describe('normalizeUsers snapshot', () => {
  it('資料轉換格式應維持一致', () => {
    const input = [
      { id: 1, first_name: 'Wei', first_name_alt: undefined, last_name: 'Lin', is_active: true },
      { id: 2, first_name: 'An', first_name_alt: undefined, last_name: 'Wang', is_active: false },
    ].map(({ first_name_alt, ...rest }) => rest); // 移除不必要欄位,保持範例單純

    expect(normalizeUsers(input)).toMatchSnapshot();
  });
});

這種寫法有兩個優點:

  1. 不依賴網路或 runtime API,測試可重現
  2. Snapshot 只關注轉換結果,責任邊界清楚

Component Testing 與 Snapshot 的分工

Snapshot 適合驗「結構有沒有意外改動」,互動邏輯仍要靠行為斷言。下面示範同一個元件兩種測試同時存在。

// src/components/ToggleCard.test.tsx
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { ToggleCard } from './ToggleCard';

describe('ToggleCard', () => {
  it('點擊按鈕會切換展開狀態', () => {
    render(<ToggleCard title="方案說明">細節內容</ToggleCard>);

    const button = screen.getByRole('button', { name: '方案說明' });
    expect(button).toHaveAttribute('aria-expanded', 'false');

    fireEvent.click(button);
    expect(button).toHaveAttribute('aria-expanded', 'true');
    expect(screen.getByText('細節內容')).toBeVisible();
  });

  it('預設狀態的 DOM 結構保持一致', () => {
    const { container } = render(<ToggleCard title="方案說明">細節內容</ToggleCard>);
    expect(container.firstChild).toMatchSnapshot();
  });
});

這裡特別注意:fireEvent 要明確 import,避免文章示例無法直接執行。

何時適合用 Snapshot、何時不適合

適合:

  1. 展示型元件:變化少、結構固定、意外改動成本高
  2. 純資料輸出:parser、formatter、schema 轉換
  3. 文本生成器:Markdown/HTML/JSON 的最終產物

不適合:

  1. 高動態 UI:每次 render 都含隨機值、時間戳、nonce
  2. 大型頁面整棵 DOM:快照過大,review 成本失控
  3. 商業規則判斷:應以明確行為斷言為主,不該被 Snapshot 取代

如何避免 brittle snapshots

brittle snapshots 的典型症狀是「一改 className 或包一層 div 就爆一排」。可用這幾個做法降低脆弱度:

  1. 降低 snapshot 範圍:只 snapshot 關鍵區塊,不要整頁
  2. 固定不穩定輸入:時間、亂數、環境值先 mock
  3. 一個測試只對應一個語意:不要在同一 snapshot 混太多狀態
  4. 搭配行為斷言:互動與商業規則不要只靠 snapshot

如果每次改版都在「大量更新 snapshot」,通常代表測試粒度太粗,應先拆小再繼續。

團隊落地流程:把 Snapshot review 變成可執行規範

你可以直接採用這個 PR 流程:

  1. 開發者提交程式碼與 .snap 變更
  2. Reviewer 先看元件/邏輯差異,再看 .snap 差異
  3. 每個 snapshot 變更都要能對應到需求或 bugfix
  4. 無法解釋的快照改動一律退回

這比「看到 snapshot fail 就 -u」多花一點時間,但長期能大幅降低 regression。

常見問題 / 注意事項

Q1:Inline Snapshot 比 .snap 檔好嗎? 看規模。小型輸出用 Inline Snapshot 很直覺;輸出較大時,獨立 .snap 檔通常更容易閱讀。

Q2:Snapshot 檔要不要 commit? 要。.snap 是測試資產的一部分,不進版控就失去比對基準。

Q3:可以只用 Snapshot,不寫一般測試嗎? 不建議。Snapshot 只能告訴你「變了」,不能完整驗證行為是否正確。

Q4:CI 失敗時可以直接 -u 後重跑嗎? 先確認差異是否符合需求,再更新。未審查就更新,等同把錯誤一起固化。

總結

Snapshot Testing 最有價值的地方,不是省掉思考,而是把「輸出改變」快速浮上檯面。你把定位抓對,Snapshot 就會是穩定的回歸防線;定位抓錯,它只會變成噪音來源。

這篇的實務重點可以收斂成四句:

  1. 先把 Vitest + Testing Library + jsdom 設好,再談元件 Snapshot。
  2. 更新指令以 npx vitest -u / npx vitest --update 為主。
  3. mismatch 先看終端 diff,不要假設一定有 *.actual.* / *.expected.*
  4. Snapshot 與行為斷言要分工,並建立 PR diff 審查流程。

下一步建議:先挑 1-2 個展示型元件導入 Snapshot,搭配既有互動測試,跑一輪 PR review,再決定是否擴大到整個 design system。