snapshot-test-refactorer
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSnapshot Test Refactorer
快照测试重构工具
Replace brittle snapshots with meaningful, maintainable assertions.
将脆弱的快照替换为有意义、可维护的断言。
Problems with Snapshot Tests
快照测试存在的问题
typescript
// ❌ Bad: Full component snapshot
test("renders UserProfile", () => {
const { container } = render(<UserProfile user={mockUser} />);
expect(container).toMatchSnapshot();
});
// Problems:
// 1. Fails on any change (even whitespace)
// 2. No clear intent
// 3. Hard to review diffs
// 4. Doesn't test behavior
// 5. Implementation coupledtypescript
// ❌ 不佳:完整组件快照
test("renders UserProfile", () => {
const { container } = render(<UserProfile user={mockUser} />);
expect(container).toMatchSnapshot();
});
// 存在的问题:
// 1. 任何变更(甚至空白字符)都会导致测试失败
// 2. 测试意图不明确
// 3. 难以审查差异
// 4. 未测试实际行为
// 5. 与实现细节耦合Refactoring Strategy
重构策略
typescript
// ✅ Good: Specific assertions
test("renders UserProfile with user data", () => {
render(<UserProfile user={mockUser} />);
// Test what matters
expect(screen.getByText(mockUser.name)).toBeInTheDocument();
expect(screen.getByText(mockUser.email)).toBeInTheDocument();
expect(screen.getByRole("img")).toHaveAttribute("src", mockUser.avatar);
});
test("shows edit button for own profile", () => {
render(<UserProfile user={mockUser} isOwnProfile={true} />);
expect(
screen.getByRole("button", { name: "Edit Profile" })
).toBeInTheDocument();
});
test("hides edit button for other profiles", () => {
render(<UserProfile user={mockUser} isOwnProfile={false} />);
expect(
screen.queryByRole("button", { name: "Edit Profile" })
).not.toBeInTheDocument();
});typescript
// ✅ 推荐:针对性断言
test("renders UserProfile with user data", () => {
render(<UserProfile user={mockUser} />);
// 测试关键内容
expect(screen.getByText(mockUser.name)).toBeInTheDocument();
expect(screen.getByText(mockUser.email)).toBeInTheDocument();
expect(screen.getByRole("img")).toHaveAttribute("src", mockUser.avatar);
});
test("shows edit button for own profile", () => {
render(<UserProfile user={mockUser} isOwnProfile={true} />);
expect(
screen.getByRole("button", { name: "Edit Profile" })
).toBeInTheDocument();
});
test("hides edit button for other profiles", () => {
render(<UserProfile user={mockUser} isOwnProfile={false} />);
expect(
screen.queryByRole("button", { name: "Edit Profile" })
).not.toBeInTheDocument();
});Inline Snapshots for Data
数据的内联快照
typescript
// ❌ Bad: External snapshot file
test("formats user data", () => {
const result = formatUser(mockUser);
expect(result).toMatchSnapshot();
});
// ✅ Good: Inline snapshot (visible in code)
test("formats user data", () => {
const result = formatUser(mockUser);
expect(result).toMatchInlineSnapshot(`
{
"displayName": "John Doe",
"initials": "JD",
"memberSince": "2020-01-01",
}
`);
});typescript
// ❌ 不佳:外部快照文件
test("formats user data", () => {
const result = formatUser(mockUser);
expect(result).toMatchSnapshot();
});
// ✅ 推荐:内联快照(在代码中可见)
test("formats user data", () => {
const result = formatUser(mockUser);
expect(result).toMatchInlineSnapshot(`
{
"displayName": "John Doe",
"initials": "JD",
"memberSince": "2020-01-01",
}
`);
});Partial Snapshots
部分快照
typescript
// ❌ Bad: Snapshot entire API response
test("fetches user", async () => {
const response = await api.getUser("123");
expect(response).toMatchSnapshot();
});
// ✅ Good: Test important parts
test("fetches user with required fields", async () => {
const response = await api.getUser("123");
expect(response).toMatchObject({
id: "123",
email: expect.stringContaining("@"),
role: expect.any(String),
});
// Snapshot only stable, important data
expect({
name: response.name,
role: response.role,
}).toMatchInlineSnapshot(`
{
"name": "John Doe",
"role": "USER",
}
`);
});typescript
// ❌ 不佳:快照整个API响应
test("fetches user", async () => {
const response = await api.getUser("123");
expect(response).toMatchSnapshot();
});
// ✅ 推荐:仅测试关键部分
test("fetches user with required fields", async () => {
const response = await api.getUser("123");
expect(response).toMatchObject({
id: "123",
email: expect.stringContaining("@"),
role: expect.any(String),
});
// 仅快照稳定的关键数据
expect({
name: response.name,
role: response.role,
}).toMatchInlineSnapshot(`
{
"name": "John Doe",
"role": "USER",
}
`);
});Serializer for Unstable Data
不稳定数据的序列化器
typescript
// Remove unstable fields before snapshot
expect.addSnapshotSerializer({
test: (val) => val && typeof val === "object" && "createdAt" in val,
serialize: (val) => {
const { createdAt, updatedAt, ...rest } = val;
return JSON.stringify(rest, null, 2);
},
});
// Now timestamps won't break tests
test("creates user", async () => {
const user = await createUser({ name: "Test" });
expect(user).toMatchInlineSnapshot(`
{
"id": "123",
"name": "Test",
"role": "USER"
}
`);
// createdAt automatically removed
});typescript
// 快照前移除不稳定字段
expect.addSnapshotSerializer({
test: (val) => val && typeof val === "object" && "createdAt" in val,
serialize: (val) => {
const { createdAt, updatedAt, ...rest } = val;
return JSON.stringify(rest, null, 2);
},
});
// 现在时间戳不会导致测试失败
test("creates user", async () => {
const user = await createUser({ name: "Test" });
expect(user).toMatchInlineSnapshot(`
{
"id": "123",
"name": "Test",
"role": "USER"
}
`);
// createdAt会被自动移除
});Snapshot Trimming Strategy
快照精简策略
typescript
// Before: 500 line snapshot
expect(component).toMatchSnapshot();
// After: Focus on critical parts
const criticalElements = {
header: screen.getByRole("banner").textContent,
mainAction: screen.getByRole("button", { name: /submit/i }).textContent,
errorMessage: screen.queryByRole("alert")?.textContent,
};
expect(criticalElements).toMatchInlineSnapshot(`
{
"errorMessage": null,
"header": "Welcome",
"mainAction": "Submit",
}
`);typescript
// 之前:500行的快照
expect(component).toMatchSnapshot();
// 之后:聚焦关键部分
const criticalElements = {
header: screen.getByRole("banner").textContent,
mainAction: screen.getByRole("button", { name: /submit/i }).textContent,
errorMessage: screen.queryByRole("alert")?.textContent,
};
expect(criticalElements).toMatchInlineSnapshot(`
{
"errorMessage": null,
"header": "Welcome",
"mainAction": "Submit",
}
`);Visual Regression Alternative
视觉回归测试替代方案
typescript
// Instead of DOM snapshot, use visual regression
test("Profile component appearance", async ({ page }) => {
await page.goto("/profile");
// Visual snapshot (Playwright)
await expect(page).toHaveScreenshot("profile.png", {
maxDiffPixels: 100,
});
});typescript
// 替代DOM快照,使用视觉回归测试
test("Profile component appearance", async ({ page }) => {
await page.goto("/profile");
// 视觉快照(Playwright)
await expect(page).toHaveScreenshot("profile.png", {
maxDiffPixels: 100,
});
});When Snapshots Are Acceptable
快照适用场景
typescript
// ✅ OK: Error messages (rarely change)
test("validates email format", () => {
const errors = validateEmail("invalid");
expect(errors).toMatchInlineSnapshot(`
[
"Email must contain @",
"Email must contain domain",
]
`);
});
// ✅ OK: API response structure (stable contract)
test("user API response structure", async () => {
const response = await api.getUser("123");
expect(Object.keys(response).sort()).toMatchInlineSnapshot(`
[
"createdAt",
"email",
"id",
"name",
"role",
"updatedAt",
]
`);
});
// ✅ OK: Serialized data format
test("exports user to JSON", () => {
const json = exportUserToJSON(user);
expect(json).toMatchInlineSnapshot(`
{
"email": "john@example.com",
"name": "John Doe",
"version": "1.0",
}
`);
});typescript
// ✅ 适用:错误信息(极少变更)
test("validates email format", () => {
const errors = validateEmail("invalid");
expect(errors).toMatchInlineSnapshot(`
[
"Email must contain @",
"Email must contain domain",
]
`);
});
// ✅ 适用:API响应结构(稳定契约)
test("user API response structure", async () => {
const response = await api.getUser("123");
expect(Object.keys(response).sort()).toMatchInlineSnapshot(`
[
"createdAt",
"email",
"id",
"name",
"role",
"updatedAt",
]
`);
});
// ✅ 适用:序列化数据格式
test("exports user to JSON", () => {
const json = exportUserToJSON(user);
expect(json).toMatchInlineSnapshot(`
{
"email": "john@example.com",
"name": "John Doe",
"version": "1.0",
}
`);
});Refactoring Process
重构流程
markdown
undefinedmarkdown
undefinedSnapshot Refactoring Checklist
快照重构检查清单
For each snapshot test, ask:
-
What is being tested?
- If unclear → Replace with specific assertions
-
Does it test behavior or implementation?
- Implementation → Refactor to behavior test
-
How often does this change?
- Frequently → Use targeted assertions
- Rarely → Snapshot OK
-
Can I describe what should pass/fail?
- No → Snapshot is too broad
-
Would a visual test be better?
- UI appearance → Use screenshot testing
针对每个快照测试,思考:
-
测试的目标是什么?
- 若不明确 → 替换为针对性断言
-
测试的是行为还是实现细节?
- 实现细节 → 重构为行为测试
-
该测试的变更频率如何?
- 频繁变更 → 使用针对性断言
- 极少变更 → 可以使用快照
-
能否清晰描述测试的通过/失败条件?
- 不能 → 快照范围过广
-
视觉测试是否更合适?
- UI外观 → 使用截图测试
Refactoring Steps
重构步骤
- Run snapshot test, let it fail
- Look at the diff
- Extract what actually matters
- Write assertion for that specific thing
- Delete snapshot
- Repeat for next snapshot
undefined- 运行快照测试,使其失败
- 查看差异内容
- 提取真正重要的部分
- 针对该部分编写断言
- 删除快照
- 对下一个快照重复上述步骤
undefinedExample Refactoring
重构示例
typescript
// ❌ Before: Brittle 200-line snapshot
test("renders dashboard", () => {
const { container } = render(<Dashboard user={user} />);
expect(container).toMatchSnapshot();
});
// ✅ After: Multiple focused tests
describe("Dashboard", () => {
test("displays welcome message with user name", () => {
render(<Dashboard user={user} />);
expect(screen.getByText(`Welcome back, ${user.name}!`)).toBeInTheDocument();
});
test("shows user stats", () => {
render(<Dashboard user={user} stats={mockStats} />);
expect(screen.getByText(`${mockStats.orders} orders`)).toBeInTheDocument();
expect(screen.getByText(`$${mockStats.revenue}`)).toBeInTheDocument();
});
test("displays quick actions", () => {
render(<Dashboard user={user} />);
expect(
screen.getByRole("button", { name: "New Order" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "View Reports" })
).toBeInTheDocument();
});
test("shows empty state when no recent activity", () => {
render(<Dashboard user={user} recentActivity={[]} />);
expect(screen.getByText("No recent activity")).toBeInTheDocument();
});
});typescript
// ❌ 之前:脆弱的200行快照
test("renders dashboard", () => {
const { container } = render(<Dashboard user={user} />);
expect(container).toMatchSnapshot();
});
// ✅ 之后:多个聚焦的测试
describe("Dashboard", () => {
test("displays welcome message with user name", () => {
render(<Dashboard user={user} />);
expect(screen.getByText(`Welcome back, ${user.name}!`)).toBeInTheDocument();
});
test("shows user stats", () => {
render(<Dashboard user={user} stats={mockStats} />);
expect(screen.getByText(`${mockStats.orders} orders`)).toBeInTheDocument();
expect(screen.getByText(`$${mockStats.revenue}`)).toBeInTheDocument();
});
test("displays quick actions", () => {
render(<Dashboard user={user} />);
expect(
screen.getByRole("button", { name: "New Order" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "View Reports" })
).toBeInTheDocument();
});
test("shows empty state when no recent activity", () => {
render(<Dashboard user={user} recentActivity={[]} />);
expect(screen.getByText("No recent activity")).toBeInTheDocument();
});
});Automated Conversion Script
自动转换脚本
typescript
// scripts/convert-snapshots.ts
import * as fs from "fs";
import * as path from "path";
function convertSnapshotToAssertions(testFile: string): string {
let content = fs.readFileSync(testFile, "utf-8");
// Replace toMatchSnapshot() with specific assertions
content = content.replace(
/expect\((.+?)\)\.toMatchSnapshot\(\)/g,
(match, element) => {
return `// TODO: Replace with specific assertions
// expect(${element}).to... `;
}
);
return content;
}typescript
// scripts/convert-snapshots.ts
import * as fs from "fs";
import * as path from "path";
function convertSnapshotToAssertions(testFile: string): string {
let content = fs.readFileSync(testFile, "utf-8");
// 将toMatchSnapshot()替换为针对性断言
content = content.replace(
/expect\((.+?)\)\.toMatchSnapshot\(\)/g,
(match, element) => {
return `// TODO: 替换为针对性断言
// expect(${element}).to... `;
}
);
return content;
}Maintenance Strategy
维护策略
markdown
undefinedmarkdown
undefinedSnapshot Maintenance Guidelines
快照维护指南
When to Update Snapshots
何时更新快照
✅ Update when:
- Intentional design change
- New feature added
- Bug fix that changes output
- Refactoring that changes structure
❌ Don't update when:
- "Jest said to update"
- Test is failing
- Don't understand the change
- Too lazy to investigate
✅ 更新场景:
- 有意的设计变更
- 新增功能
- 修复导致输出变更的Bug
- 变更结构的重构
❌ 禁止更新场景:
- "Jest提示更新"
- 测试失败时
- 不理解变更内容
- 不愿排查问题
Review Process
审查流程
- Run to update
jest -u - Review EVERY changed snapshot
- Verify change is intentional
- If unsure, ask for review
- Consider if assertion would be better
- 运行进行更新
jest -u - 审查每一处变更的快照
- 验证变更是否为有意的
- 若不确定,请求他人审查
- 考虑是否使用断言更合适
Reduce Snapshot Size
缩小快照体积
- Use for partial matches
.toMatchObject() - Extract only relevant data
- Use serializers to remove noise
- Consider inline snapshots
undefined- 使用进行部分匹配
.toMatchObject() - 仅提取相关数据
- 使用序列化器移除干扰项
- 考虑使用内联快照
undefinedBest Practices
最佳实践
- Inline snapshots: More visible and reviewable
- Small snapshots: Snapshot only what matters
- Stable data: Remove timestamps, IDs
- Clear intent: Test name explains what's captured
- Visual regression: For UI appearance
- Regular review: Quarterly snapshot audit
- Specific assertions: Prefer over snapshots
- 内联快照:更易查看和审查
- 小型快照:仅快照关键内容
- 稳定数据:移除时间戳、ID等不稳定内容
- 明确意图:测试名称说明快照捕获的内容
- 视觉回归测试:用于UI外观测试
- 定期审查:每季度进行快照审计
- 针对性断言:优先使用而非快照
Output Checklist
输出检查清单
- Brittle snapshots identified
- Refactored to specific assertions
- Inline snapshots where appropriate
- Unstable data removed (serializers)
- Partial snapshots for data structures
- Visual regression for UI
- Maintenance guidelines documented
- Review process established
- 已识别脆弱快照
- 已重构为针对性断言
- 已在合适场景使用内联快照
- 已移除不稳定数据(通过序列化器)
- 已为数据结构使用部分快照
- 已为UI使用视觉回归测试
- 已记录维护指南
- 已建立审查流程