snapshot-test-refactorer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Snapshot 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 coupled
typescript
// ❌ 不佳:完整组件快照
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
undefined
markdown
undefined

Snapshot Refactoring Checklist

快照重构检查清单

For each snapshot test, ask:
  1. What is being tested?
    • If unclear → Replace with specific assertions
  2. Does it test behavior or implementation?
    • Implementation → Refactor to behavior test
  3. How often does this change?
    • Frequently → Use targeted assertions
    • Rarely → Snapshot OK
  4. Can I describe what should pass/fail?
    • No → Snapshot is too broad
  5. Would a visual test be better?
    • UI appearance → Use screenshot testing
针对每个快照测试,思考:
  1. 测试的目标是什么?
    • 若不明确 → 替换为针对性断言
  2. 测试的是行为还是实现细节?
    • 实现细节 → 重构为行为测试
  3. 该测试的变更频率如何?
    • 频繁变更 → 使用针对性断言
    • 极少变更 → 可以使用快照
  4. 能否清晰描述测试的通过/失败条件?
    • 不能 → 快照范围过广
  5. 视觉测试是否更合适?
    • UI外观 → 使用截图测试

Refactoring Steps

重构步骤

  1. Run snapshot test, let it fail
  2. Look at the diff
  3. Extract what actually matters
  4. Write assertion for that specific thing
  5. Delete snapshot
  6. Repeat for next snapshot
undefined
  1. 运行快照测试,使其失败
  2. 查看差异内容
  3. 提取真正重要的部分
  4. 针对该部分编写断言
  5. 删除快照
  6. 对下一个快照重复上述步骤
undefined

Example 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
undefined
markdown
undefined

Snapshot 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

审查流程

  1. Run
    jest -u
    to update
  2. Review EVERY changed snapshot
  3. Verify change is intentional
  4. If unsure, ask for review
  5. Consider if assertion would be better
  1. 运行
    jest -u
    进行更新
  2. 审查每一处变更的快照
  3. 验证变更是否为有意的
  4. 若不确定,请求他人审查
  5. 考虑是否使用断言更合适

Reduce Snapshot Size

缩小快照体积

  • Use
    .toMatchObject()
    for partial matches
  • Extract only relevant data
  • Use serializers to remove noise
  • Consider inline snapshots
undefined
  • 使用
    .toMatchObject()
    进行部分匹配
  • 仅提取相关数据
  • 使用序列化器移除干扰项
  • 考虑使用内联快照
undefined

Best Practices

最佳实践

  1. Inline snapshots: More visible and reviewable
  2. Small snapshots: Snapshot only what matters
  3. Stable data: Remove timestamps, IDs
  4. Clear intent: Test name explains what's captured
  5. Visual regression: For UI appearance
  6. Regular review: Quarterly snapshot audit
  7. Specific assertions: Prefer over snapshots
  1. 内联快照:更易查看和审查
  2. 小型快照:仅快照关键内容
  3. 稳定数据:移除时间戳、ID等不稳定内容
  4. 明确意图:测试名称说明快照捕获的内容
  5. 视觉回归测试:用于UI外观测试
  6. 定期审查:每季度进行快照审计
  7. 针对性断言:优先使用而非快照

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使用视觉回归测试
  • 已记录维护指南
  • 已建立审查流程