📋 目錄
你會學到如何在真實專案中把 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"
}
}
這樣你就有可執行的基線:
vitest負責 runner 與斷言jsdom提供 DOM 環境- 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
這段流程建議固定成團隊習慣:
- 先跑
vitest run看失敗內容 - 確認變更是預期行為(不是 accidental change)
- 再執行
vitest -u更新 - 在 PR 上逐段 review
.snapdiff
實務上最重要的是第 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();
});
});
這種寫法有兩個優點:
- 不依賴網路或 runtime API,測試可重現
- 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、何時不適合
適合:
- 展示型元件:變化少、結構固定、意外改動成本高
- 純資料輸出:parser、formatter、schema 轉換
- 文本生成器:Markdown/HTML/JSON 的最終產物
不適合:
- 高動態 UI:每次 render 都含隨機值、時間戳、nonce
- 大型頁面整棵 DOM:快照過大,review 成本失控
- 商業規則判斷:應以明確行為斷言為主,不該被 Snapshot 取代
如何避免 brittle snapshots
brittle snapshots 的典型症狀是「一改 className 或包一層 div 就爆一排」。可用這幾個做法降低脆弱度:
- 降低 snapshot 範圍:只 snapshot 關鍵區塊,不要整頁
- 固定不穩定輸入:時間、亂數、環境值先 mock
- 一個測試只對應一個語意:不要在同一 snapshot 混太多狀態
- 搭配行為斷言:互動與商業規則不要只靠 snapshot
如果每次改版都在「大量更新 snapshot」,通常代表測試粒度太粗,應先拆小再繼續。
團隊落地流程:把 Snapshot review 變成可執行規範
你可以直接採用這個 PR 流程:
- 開發者提交程式碼與
.snap變更 - Reviewer 先看元件/邏輯差異,再看
.snap差異 - 每個 snapshot 變更都要能對應到需求或 bugfix
- 無法解釋的快照改動一律退回
這比「看到 snapshot fail 就 -u」多花一點時間,但長期能大幅降低 regression。
常見問題 / 注意事項
Q1:Inline Snapshot 比 .snap 檔好嗎?
看規模。小型輸出用 Inline Snapshot 很直覺;輸出較大時,獨立 .snap 檔通常更容易閱讀。
Q2:Snapshot 檔要不要 commit?
要。.snap 是測試資產的一部分,不進版控就失去比對基準。
Q3:可以只用 Snapshot,不寫一般測試嗎? 不建議。Snapshot 只能告訴你「變了」,不能完整驗證行為是否正確。
Q4:CI 失敗時可以直接 -u 後重跑嗎?
先確認差異是否符合需求,再更新。未審查就更新,等同把錯誤一起固化。
總結
Snapshot Testing 最有價值的地方,不是省掉思考,而是把「輸出改變」快速浮上檯面。你把定位抓對,Snapshot 就會是穩定的回歸防線;定位抓錯,它只會變成噪音來源。
這篇的實務重點可以收斂成四句:
- 先把
Vitest + Testing Library + jsdom設好,再談元件 Snapshot。 - 更新指令以
npx vitest -u/npx vitest --update為主。 - mismatch 先看終端 diff,不要假設一定有
*.actual.*/*.expected.*。 - Snapshot 與行為斷言要分工,並建立 PR diff 審查流程。
下一步建議:先挑 1-2 個展示型元件導入 Snapshot,搭配既有互動測試,跑一輪 PR review,再決定是否擴大到整個 design system。