Claude Code 测试策略实战:Vitest、Testing Library、Playwright 与 CI
用 Claude Code 设计单元、集成、E2E 与 CI 测试,减少脆弱用例。
测试策略不是“多写测试”这么简单,而是决定哪些行为用快速的单元测试保护,哪些边界需要集成测试,哪些真正影响注册、购买、咨询的路径才值得放到 E2E。
如果只对 Claude Code 说“帮我加测试”,它可能会生成只验证当前实现的测试、依赖 CSS 类名的选择器,或覆盖率好看但价值很低的断言。更稳的做法是明确测试层级、目标行为、可修改范围和要执行的命令。
本文参考了官方的 Claude Code common workflows、Vitest coverage、Testing Library queries,以及 Playwright 的 locators、assertions 和 CI 文档。
先画出测试金字塔
flowchart TB
E2E["E2E:注册、购买、付费 CTA"]
INT["集成:API、DB、表单、组件"]
UNIT["单元:计算、校验、权限"]
E2E --> INT --> UNIT
| 层级 | 比例参考 | 保护内容 | 工具 |
|---|---|---|---|
| 单元测试 | 60-70% | 纯逻辑、输入校验、权限判断 | Vitest |
| 集成测试 | 20-30% | 组件、API、数据库边界 | Vitest + Testing Library |
| E2E 测试 | 5-10% | 注册、购买、收益路径 | Playwright |
| CI 门禁 | 每个 PR | lint、类型、测试、报告 | GitHub Actions |
价格计算属于单元测试,结账按钮属于组件集成测试,从文章 CTA 到商品页的路径才适合 E2E。不要把所有东西都塞进 E2E,否则 CI 会变慢,失败原因也更难定位。
最小可运行配置
npm i -D vitest @vitest/coverage-v8 jsdom \
@testing-library/react @testing-library/jest-dom @testing-library/user-event \
@playwright/test
npx playwright install --with-deps
{
"scripts": {
"test": "vitest --run",
"test:watch": "vitest",
"test:coverage": "vitest --run --coverage",
"test:e2e": "playwright test"
}
}
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});
// test/setup.ts
import "@testing-library/jest-dom/vitest";
Vitest 官方文档同时说明了 v8 和 istanbul。在 Node 与 Chromium 环境中,v8 通常是更直接的选择。
示例1:用单元测试保护价格逻辑
// src/lib/pricing.ts
export type PriceInput = {
unitPrice: number;
quantity: number;
discountRate?: number;
taxRate?: number;
};
export function calculateTotal({
unitPrice,
quantity,
discountRate = 0,
taxRate = 0.1,
}: PriceInput): number {
if (!Number.isInteger(quantity) || quantity < 0) {
throw new Error("quantity must be a non-negative integer");
}
if (unitPrice < 0) throw new Error("unitPrice must be non-negative");
if (discountRate < 0 || discountRate > 1) {
throw new Error("discountRate must be between 0 and 1");
}
const discounted = unitPrice * quantity * (1 - discountRate);
return Math.round(discounted * (1 + taxRate));
}
// src/lib/pricing.test.ts
import { describe, expect, it } from "vitest";
import { calculateTotal } from "./pricing";
describe("calculateTotal", () => {
it("calculates a tax-included total", () => {
expect(calculateTotal({ unitPrice: 1000, quantity: 2 })).toBe(2200);
});
it("applies discount before tax", () => {
expect(
calculateTotal({ unitPrice: 1000, quantity: 2, discountRate: 0.2 })
).toBe(1760);
});
it("allows zero quantity", () => {
expect(calculateTotal({ unitPrice: 1000, quantity: 0 })).toBe(0);
});
it("rejects invalid inputs", () => {
expect(() => calculateTotal({ unitPrice: 1000, quantity: -1 })).toThrow(
"quantity must be a non-negative integer"
);
});
});
常见坑是只为了 80% 覆盖率而补正常路径。价格逻辑真正容易出问题的是数量为 0、折扣为 100%、非法折扣率和四舍五入。
示例2:用 Testing Library 测 CTA 组件
Testing Library 推荐优先使用接近用户行为的查询方式,例如 getByRole 和 getByLabelText。这不仅对无障碍友好,也能减少样式改版导致的测试损坏。
// src/components/CheckoutButton.tsx
import { useState } from "react";
type Props = {
stock: number;
onCheckout: () => Promise<void>;
};
export function CheckoutButton({ stock, onCheckout }: Props) {
const [submitting, setSubmitting] = useState(false);
const soldOut = stock <= 0;
async function handleClick() {
setSubmitting(true);
try {
await onCheckout();
} finally {
setSubmitting(false);
}
}
return (
<button
type="button"
disabled={soldOut || submitting}
aria-busy={submitting}
onClick={handleClick}
>
{soldOut ? "Sold out" : submitting ? "Processing..." : "Buy now"}
</button>
);
}
// src/components/CheckoutButton.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { CheckoutButton } from "./CheckoutButton";
describe("CheckoutButton", () => {
it("calls checkout when in stock", async () => {
const user = userEvent.setup();
const onCheckout = vi.fn().mockResolvedValue(undefined);
render(<CheckoutButton stock={3} onCheckout={onCheckout} />);
await user.click(screen.getByRole("button", { name: "Buy now" }));
expect(onCheckout).toHaveBeenCalledTimes(1);
});
it("prevents checkout when sold out", async () => {
const user = userEvent.setup();
const onCheckout = vi.fn().mockResolvedValue(undefined);
render(<CheckoutButton stock={0} onCheckout={onCheckout} />);
const button = screen.getByRole("button", { name: "Sold out" });
expect(button).toBeDisabled();
await user.click(button);
expect(onCheckout).not.toHaveBeenCalled();
});
});
不要把 CTA 测试写成 .primary-button 选择器或只看 snapshot。收益入口需要验证的是按钮是否可用、文案是否可被用户找到、点击是否触发正确行为。
示例3:Playwright 只保护关键路径
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.PLAYWRIGHT_TEST_BASE_URL ?? "http://127.0.0.1:5173";
export default defineConfig({
testDir: "./e2e",
retries: process.env.CI ? 2 : 0,
use: { baseURL, trace: "on-first-retry" },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: process.env.PLAYWRIGHT_TEST_BASE_URL
? undefined
: {
command: "npm run dev -- --host 127.0.0.1",
url: baseURL,
reuseExistingServer: !process.env.CI,
},
});
// e2e/article-cta.spec.ts
import { expect, test } from "@playwright/test";
test("reader can move from article CTA to products", async ({ page }) => {
await page.goto("/zh/blog/claude-code-testing-strategies");
await page.getByRole("link", { name: /products|templates|商品/i }).click();
await expect(page).toHaveURL(/\/products\/?$/);
await expect(page.getByRole("heading", { name: /products|商品/i })).toBeVisible();
});
Playwright 的网页优先断言会自动重试。比起 waitForTimeout,更应该等待用户能看到的状态。选择器优先用 role、label、可控的 test id,少用深层 CSS 和 nth()。
示例4:把测试放进 CI
# .github/workflows/test.yml
name: Test
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:coverage
- run: npx playwright install --with-deps
- run: npm run test:e2e
env:
CI: "true"
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
如果使用 Claude Code GitHub Actions,请以官方文档中的 anthropics/claude-code-action@v1 为准,不要直接复制旧的 @beta 示例。
给 Claude Code 的请求模板
为 src/lib/pricing.ts 添加 Vitest 单元测试。
覆盖正常路径、边界值和非法输入。
不要先改实现;如果发现疑似 bug,请先列出来。
运行 npm run test -- pricing 并汇总结果。
为 src/components/CheckoutButton.tsx 添加 Testing Library 测试。
使用 getByRole 和 userEvent.setup()。
不要使用 CSS 选择器、只靠 snapshot、或验证实现细节。
覆盖有库存、无库存、提交中的状态。
只为文章到商品页的 CTA 路径添加一个 Playwright E2E 测试。
避免 waitForTimeout、深层 CSS 选择器和 nth()。
使用网页优先断言,并保持测试聚焦收益路径。
读取最新 CI 失败日志。
把失败归类为 lint、typecheck、unit、e2e 或环境差异。
用最小差异修复根因。
不要 skip 测试,也不要只增加 timeout。
常见落坑
不要过度 mock。支付网关和邮件服务可以 mock,但价格、权限、保存逻辑需要在合适层级真实验证。
不要让 E2E 固定所有视觉细节。E2E 应保护注册、购买、咨询、商品点击等用户行动。
不要把覆盖率当作质量证明。80% 覆盖率仍可能漏掉最危险的删除、扣款或发布分支。
不要只给 Claude Code 正常路径。明确禁止脆弱选择器、跳过测试、snapshot-only 和实现细节耦合,输出会稳定很多。
合并前还要让 Claude Code 留下一段验证记录:新增了哪些测试、哪些范围有意没有测、实际跑了哪条命令。这个记录看起来普通,但能避免下一位维护者猜测 E2E 失败是否被忽略、支付 mock 是否合理、coverage 是否真的来自 CI。
转化引导(CTA)
测试策略也会影响收益路径。先用免费的 Claude Code cheatsheet 固定基本命令,再把常用审查规则整理进 products and templates。团队要统一 CI、测试责任和 review 标准时,可以通过 Claude Code training 一起设计。相关主题可继续阅读 TDD with Claude Code、CI/CD setup 和 debugging techniques。
实际尝试后的结果
Masa 在 ClaudeCodeLab 的文章 CTA 和商品页路径中测试了这个做法。最有效的不是增加大量 E2E,而是把价格逻辑留给单元测试,用 Testing Library 保护 CTA 的角色和文案,再用一条 Playwright 测试确认文章能导向商品页。让 Claude Code 先列出失败模式后,跳过测试和脆弱 CSS 选择器明显减少。剩余风险是外部支付和广告脚本,这些仍需要 CI 报告加发布前人工确认。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code Permission Receipt Pattern:记录权限、证据和回滚方式
Claude Code 权限 receipt:记录允许动作、需要批准的边界、验证命令、回滚说明,以及 Gumroad 和咨询 CTA 检查。
Claude Code/Codex 安全 Agent Harness 实战:权限、验证与回滚
用权限策略、执行计划、验证脚本和回滚日志,为 Claude Code 与 Codex 搭建更安全的 AI Agent 工作流。
Claude Code 子代理实战指南:安全委派并行文章与代码工作
用 Claude Code 子代理安全拆分文章和代码工作:委派规则、提示词模板、失败模式与检查清单。