zoonk-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting Guidelines
测试指南
Follow TDD (Test-Driven Development) for all features and bug fixes. Always write failing tests first.
所有功能开发和bug修复都需遵循TDD(测试驱动开发)原则,永远先编写失败的测试用例。
How to Think About Tests
测试的思考方式
Before writing any test, answer these questions:
编写任何测试之前,先回答以下问题:
1. What behavior am I verifying?
1. 我要验证的是什么行为?
State it in one sentence. If you can't, the test is too complex.
- ✅ "Verify locale persists when navigating between pages"
- ✅ "Verify users can create a course with a title"
- ❌ "Verify the course description popover opens in the correct language when clicking from a Portuguese page"
- ❌ "Verify the sidebar collapses, remembers state, and shows tooltips on hover when collapsed"
用一句话说明。如果你做不到,说明这个测试太复杂了。
- ✅ "验证页面跳转时区域设置保持不变"
- ✅ "验证用户可以创建带标题的课程"
- ❌ "验证从葡萄牙语页面点击时,课程描述弹出框以正确语言打开"
- ❌ "验证侧边栏折叠、记住状态,折叠后悬停时显示tooltip"
2. What's the simplest proof?
2. 最简单的验证方式是什么?
Find the minimum actions to verify the behavior:
| Behavior | Simplest Proof | NOT This |
|---|---|---|
| Locale persistence | Navigate → click link → check URL contains | Navigate → click course → open popover → verify translation |
| Course creation | Fill title → submit → verify title appears | Fill all fields → verify each field → check database → verify list |
| Login works | Enter credentials → submit → see dashboard heading | Enter credentials → verify button enabled → submit → check cookie |
| Item appears in list | Create item → verify it's visible | Create item → scroll list → filter → sort → find item |
找到能验证目标行为的最少操作步骤:
| 行为 | 最简验证方式 | 不推荐的方式 |
|---|---|---|
| 区域设置持久化 | 跳转 → 点击链接 → 检查URL包含 | 跳转 → 点击课程 → 打开弹出框 → 验证翻译内容 |
| 课程创建 | 填写标题 → 提交 → 验证标题显示 | 填写所有字段 → 验证每个字段 → 检查数据库 → 验证列表展示 |
| 登录功能正常 | 输入凭据 → 提交 → 看到控制台标题 | 输入凭据 → 验证按钮启用 → 提交 → 检查cookie |
| 条目出现在列表中 | 创建条目 → 验证条目可见 | 创建条目 → 滚动列表 → 筛选 → 排序 → 查找条目 |
3. Am I testing the right thing?
3. 我测试的内容是否正确?
Ask: "If this test passes, am I confident the feature works?"
- If "yes" requires trusting other unrelated UI mechanics, you're testing the wrong thing
- If the test could pass with broken code, it's too loose
- If the test could fail with working code, it's testing implementation details
Example: To test locale preservation, you don't need to verify translated content renders correctly. That's a translation test, not a locale persistence test. Just verify the URL maintains the locale segment.
自问:"如果这个测试通过了,我能确定功能正常运行吗?"
- 如果答案为"是"需要依赖其他无关的UI机制,说明你测试的内容不对
- 如果代码存在问题时测试仍可能通过,说明测试太松散
- 如果代码正常运行时测试仍可能失败,说明你在测试实现细节
示例:测试区域设置保存时,你不需要验证翻译内容是否正确渲染。那是翻译测试,不是区域设置持久化测试。只需要验证URL保留了区域设置字段即可。
TDD Workflow
TDD工作流
- Write a failing test that describes the expected behavior
- Run the test to confirm it fails - this is non-negotiable
- Write the minimum code to make the test pass
- Run the test to confirm it passes
- Refactor while keeping tests green
- 编写描述预期行为的失败测试用例
- 运行测试确认它会失败 - 这一步必不可少
- 编写最少的代码让测试通过
- 运行测试确认它已通过
- 重构代码同时保持测试全部通过
If the Test Passes Before Your Fix
如果在你修复代码前测试就通过了
The test is wrong. A passing test means one of:
- The bug doesn't exist (investigate further)
- The test is matching existing/seeded data instead of new behavior
- The test assertion is too loose
Never use workarounds to make a "failing" test pass:
typescript
// BAD: Using .first() to avoid multiple matches
await expect(page.getByText(courseTitle).first()).toBeVisible();
// This passes even if the item existed before your fix!
// GOOD: Use unique identifiers so only ONE element can match
const uniqueId = randomUUID().slice(0, 8);
const courseTitle = `Test Course ${uniqueId}`;
await expect(page.getByText(courseTitle)).toBeVisible();
// This ONLY passes if your code actually created this specific item说明测试有问题。测试提前通过意味着以下情况之一:
- bug不存在(需要进一步排查)
- 测试匹配的是现有/预设数据,而不是新的行为
- 测试断言太松散
永远不要使用变通方法让"应该失败"的测试通过:
typescript
// 错误:使用.first()避免多重匹配
await expect(page.getByText(courseTitle).first()).toBeVisible();
// 哪怕测试的条目在你修复前就已经存在,这个测试也会通过!
// 正确:使用唯一标识符,保证只有一个元素能匹配
const uniqueId = randomUUID().slice(0, 8);
const courseTitle = `Test Course ${uniqueId}`;
await expect(page.getByText(courseTitle)).toBeVisible();
// 只有当你的代码真的创建了这个特定条目时,测试才会通过Test Isolation Principle
测试隔离原则
Core Rule: Tests must be completely self-contained.
This means:
- Create your own data - Don't rely on seed data existing or having specific values
- No cleanup needed - If you need cleanup, your test isn't isolated
afterEach - Parallel safe - Tests should run in any order, even simultaneously
核心规则:测试必须完全自包含。
这意味着:
- 创建你自己的测试数据 - 不要依赖预设数据的存在或特定值
- 不需要额外清理 - 如果你需要做清理,说明你的测试没有隔离
afterEach - 支持并行运行 - 测试应该可以按任意顺序运行,甚至同时运行
Why Not Seed Data?
为什么不推荐用预设数据?
Seed data creates hidden dependencies:
- Tests break when seed data changes
- Tests pass locally but fail in CI (different seeds)
- Tests can't run in parallel (shared state)
- Debugging requires knowing what's seeded
预设数据会产生隐藏依赖:
- 预设数据变更时测试会失败
- 测试在本地通过但在CI中失败(预设数据不同)
- 测试无法并行运行(状态共享)
- 调试需要知道预设了什么内容
The Pattern
推荐模式
Every test that needs data should create it:
typescript
// Create unique data for THIS test
const uniqueId = randomUUID().slice(0, 8);
const course = await courseFixture({
slug: `e2e-${uniqueId}`,
title: `E2E Course ${uniqueId}`,
});
// Test uses only data it created
await page.goto(`/courses/${course.slug}`);每个需要数据的测试都应该自行创建数据:
typescript
// 为当前测试创建唯一数据
const uniqueId = randomUUID().slice(0, 8);
const course = await courseFixture({
slug: `e2e-${uniqueId}`,
title: `E2E Course ${uniqueId}`,
});
// 测试只使用自己创建的数据
await page.goto(`/courses/${course.slug}`);Exception: Structural Dependencies
例外:结构依赖
Using a seeded organization as a container is acceptable because:
- It's a structural dependency, not a content assertion
- Tests create their own content within it
- The org is guaranteed to exist in all environments
typescript
// ACCEPTABLE: Using seeded org as container
const org = await prisma.organization.findUniqueOrThrow({
where: { slug: "ai" },
});
const course = await courseFixture({ organizationId: org.id });使用预设的组织作为容器是可以接受的,因为:
- 它是结构依赖,不是内容断言
- 测试会在它内部创建自己的内容
- 这个组织在所有环境中都保证存在
typescript
// 可接受:使用预设组织作为容器
const org = await prisma.organization.findUniqueOrThrow({
where: { slug: "ai" },
});
const course = await courseFixture({ organizationId: org.id });Exception: Read-Only Route Verification
例外:只读路由验证
For verifying that a page renders at all (not specific content), you may use known paths:
typescript
// OK: Just verifying the route works
test("course detail page renders", async ({ page }) => {
await page.goto("/b/ai/c/machine-learning"); // Seeded course
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
// NOT OK: Relying on specific seeded content
test("shows machine learning description", async ({ page }) => {
await page.goto("/b/ai/c/machine-learning");
await expect(page.getByText("patterns|predictions")).toBeVisible(); // Fragile!
});如果只是验证页面可以正常渲染(不验证具体内容),你可以使用已知路径:
typescript
// 没问题:只是验证路由可用
test("课程详情页可正常渲染", async ({ page }) => {
await page.goto("/b/ai/c/machine-learning"); // 预设的课程
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
// 不推荐:依赖特定的预设内容
test("展示机器学习描述", async ({ page }) => {
await page.goto("/b/ai/c/machine-learning");
await expect(page.getByText("patterns|predictions")).toBeVisible(); // 非常脆弱!
});Test Types
测试类型
| When | Test Type | Framework | Location |
|---|---|---|---|
| Apps/UI features | E2E | Playwright | |
| Data functions (Prisma) | Integration | Vitest | |
| Utils/helpers | Unit | Vitest | |
| 适用场景 | 测试类型 | 框架 | 存放位置 |
|---|---|---|---|
| 应用/UI功能 | E2E | Playwright | |
| 数据函数(Prisma) | 集成测试 | Vitest | |
| 工具函数/辅助方法 | 单元测试 | Vitest | |
E2E Testing (Playwright)
E2E测试(Playwright)
Query Priority
查询优先级
Use semantic queries that reflect how users interact with the page:
typescript
// GOOD: Semantic queries (in order of preference)
page.getByRole("button", { name: "Submit" });
page.getByRole("heading", { name: "Welcome" });
page.getByLabel("Email address");
page.getByText("Sign up for free");
page.getByPlaceholder("Search...");
// BAD: Implementation details
page.locator(".btn-primary");
page.locator("#submit-button");
page.locator("[data-testid='submit']");
page.locator("[data-slot='media-card-icon']");If you can't use , the component likely has accessibility issues. Fix the component first.
getByRole使用能反映用户页面交互方式的语义化查询:
typescript
// 推荐:语义化查询(按优先级排序)
page.getByRole("button", { name: "Submit" });
page.getByRole("heading", { name: "Welcome" });
page.getByLabel("Email address");
page.getByText("Sign up for free");
page.getByPlaceholder("Search...");
// 不推荐:依赖实现细节
page.locator(".btn-primary");
page.locator("#submit-button");
page.locator("[data-testid='submit']");
page.locator("[data-slot='media-card-icon']");如果你无法使用,说明组件很可能存在可访问性问题。先修复组件。
getByRoleWait Patterns
等待模式
typescript
// GOOD: Wait for visible state
await expect(page.getByRole("heading")).toBeVisible();
await expect(page.getByText("Success")).toBeVisible();
// GOOD: Wait for URL change
await page.waitForURL(/\/dashboard/);
// BAD: Arbitrary delays
await page.waitForTimeout(2000);typescript
// 推荐:等待可见状态
await expect(page.getByRole("heading")).toBeVisible();
await expect(page.getByText("Success")).toBeVisible();
// 推荐:等待URL变更
await page.waitForURL(/\/dashboard/);
// 不推荐:任意延迟
await page.waitForTimeout(2000);Animated Elements
动画元素
Elements with CSS transitions can cause "element is not stable" errors. Pattern: wait for visibility, then use :
force: truetypescript
// Wait for submenu content to be visible (animation complete)
await expect(page.getByRole("menuitem", { name: "English" })).toBeVisible();
// Force click bypasses stability check - safe because we confirmed visibility
await page.getByRole("menuitem", { name: "Español" }).click({ force: true });When to use :
force: true- After confirming the element is visible via
toBeVisible() - When CSS animations cause repeated "element is not stable" errors
- Never as a first resort—always investigate why the element is unstable first
带CSS过渡的元素可能会导致"元素不稳定"错误。推荐模式:先等待可见,然后使用:
force: truetypescript
// 等待子菜单内容可见(动画完成)
await expect(page.getByRole("menuitem", { name: "English" })).toBeVisible();
// 强制点击跳过稳定性检查 - 因为我们已经确认元素可见,所以是安全的
await page.getByRole("menuitem", { name: "Español" }).click({ force: true });什么时候使用:
force: true- 已经通过确认元素可见之后
toBeVisible() - 当CSS动画反复导致"元素不稳定"错误时
- 永远不要作为首选方案 - 先排查元素不稳定的原因
Authentication Fixtures
身份认证fixture
Use pre-configured fixtures from your test setup:
typescript
import { expect, test } from "./fixtures";
test("authenticated user sees dashboard", async ({ authenticatedPage }) => {
await authenticatedPage.goto("/");
await expect(authenticatedPage.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});使用测试配置中预先定义的fixture:
typescript
import { expect, test } from "./fixtures";
test("已认证用户可以看到控制台", async ({ authenticatedPage }) => {
await authenticatedPage.goto("/");
await expect(authenticatedPage.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});Creating Test Data
创建测试数据
Use Prisma fixtures for tests that need specific data states:
typescript
import { postFixture } from "@/tests/fixtures/posts";
async function createTestPost() {
const uniqueId = randomUUID().slice(0, 8);
return postFixture({
slug: `e2e-${uniqueId}`,
title: `E2E Post ${uniqueId}`,
});
}
test("edits post title", async ({ authenticatedPage }) => {
const post = await createTestPost();
await authenticatedPage.goto(`/posts/${post.slug}`);
// ... test editing behavior
});Create unique users for user-specific state:
When testing features that depend on user state (subscriptions, permissions), create a unique user per test:
typescript
test("works with subscription", async () => {
const uniqueId = randomUUID().slice(0, 8);
const email = `e2e-test-${uniqueId}@zoonk.test`;
// Create unique user via sign-up API
const signupContext = await request.newContext({ baseURL });
await signupContext.post("/v1/auth/sign-up/email", {
data: { email, name: `E2E User ${uniqueId}`, password: "password123" },
});
// Create user-specific state
const user = await prisma.user.findUniqueOrThrow({ where: { email } });
await prisma.subscription.create({
data: { referenceId: String(user.id), status: "active", plan: "hobby" },
});
// No cleanup needed - user is unique to this test
});需要特定数据状态的测试请使用Prisma fixture:
typescript
import { postFixture } from "@/tests/fixtures/posts";
async function createTestPost() {
const uniqueId = randomUUID().slice(0, 8);
return postFixture({
slug: `e2e-${uniqueId}`,
title: `E2E Post ${uniqueId}`,
});
}
test("可以编辑文章标题", async ({ authenticatedPage }) => {
const post = await createTestPost();
await authenticatedPage.goto(`/posts/${post.slug}`);
// ... 测试编辑逻辑
});为用户特定状态创建唯一用户:
测试依赖用户状态(订阅、权限)的功能时,为每个测试创建唯一用户:
typescript
test("订阅用户功能正常", async () => {
const uniqueId = randomUUID().slice(0, 8);
const email = `e2e-test-${uniqueId}@zoonk.test`;
// 通过注册API创建唯一用户
const signupContext = await request.newContext({ baseURL });
await signupContext.post("/v1/auth/sign-up/email", {
data: { email, name: `E2E User ${uniqueId}`, password: "password123" },
});
// 创建用户特定状态
const user = await prisma.user.findUniqueOrThrow({ where: { email } });
await prisma.subscription.create({
data: { referenceId: String(user.id), status: "active", plan: "hobby" },
});
// 无需清理 - 这个用户是当前测试独有的
});Preventing Flaky Tests
避免测试不稳定
Run new tests multiple times before considering them done:
bash
for i in {1..5}; do pnpm e2e -- -g "test name" --reporter=line; doneHigh-risk scenarios:
| Scenario | Prevention |
|---|---|
| Clicking dropdown items | Wait for visibility, use |
| Actions triggering navigation | Use |
| Form submissions | Wait for success indicator before next action |
| Inputs with debounced validation | Use |
| Server action persistence | Assert UI immediately, then DB query with |
新测试完成前多次运行验证稳定性:
bash
for i in {1..5}; do pnpm e2e -- -g "test name" --reporter=line; done高风险场景:
| 场景 | 预防方案 |
|---|---|
| 点击下拉选项 | 等待可见,使用 |
| 触发页面跳转的操作 | 点击后使用 |
| 表单提交 | 下一步操作前等待成功提示出现 |
| 带防抖验证的输入框 | 填写后使用 |
| 服务端操作持久化 | 先立即断言UI,再用 |
Server Actions
服务端Actions
Server Actions run server-side, so cannot intercept them. To test error states, trigger real validation errors:
page.route()typescript
// Whitespace passes HTML5 "required" but fails server-side when trimmed
await nameInput.fill(" ");
await page.getByRole("button", { name: /submit/i }).click();
await expect(page.getByRole("alert")).toBeVisible();服务端Actions在服务端运行,所以无法拦截它们。 要测试错误状态,触发真实的验证错误:
page.route()typescript
// 空白字符可以通过HTML5的"required"校验,但服务端修剪后会失败
await nameInput.fill(" ");
await page.getByRole("button", { name: /submit/i }).click();
await expect(page.getByRole("alert")).toBeVisible();Verifying Persistence in E2E Tests
E2E测试中的持久化验证
When a server action mutates data (toggle, add, remove, reorder), verify two things separately:
- UI updates immediately (no reload) — tests the user experience
- Data persisted to DB (via Prisma query) — tests the server action, fast and deterministic
typescript
// GOOD: UI assertion + DB assertion
await openCategoryPopover(page);
await getCategoryOption(page, /technology/i).click();
await page.keyboard.press("Escape");
// 1. Badge appears immediately — user doesn't need to refresh
await expect(page.getByText("Technology")).toBeVisible();
// 2. Server action persisted — fast DB check with retry
await expect(async () => {
const record = await prisma.courseCategory.findUnique({
where: { courseCategory: { category: "tech", courseId: course.id } },
});
expect(record).not.toBeNull();
}).toPass({ timeout: 10_000 });
// BAD: Reloading to verify persistence
await page.reload();
await expect(page.getByText("Technology")).toBeVisible();
// This is slow, flaky (caching), and doesn't catch "user must refresh" bugsWhen reload IS appropriate: Auto-save flows (type → debounce → "saved" indicator → persist) where the reload verifies the complete UX cycle end-to-end. The user types, sees "saved", and expects data to survive a refresh — that IS the behavior being tested.
typescript
// Auto-save flow: reload is the right tool
await titleInput.fill(uniqueTitle);
await expect(page.getByText(/^saved$/i)).toBeVisible();
await page.reload();
await expect(titleInput).toHaveValue(uniqueTitle);Decision guide:
| Action type | Immediate check | Persistence check |
|---|---|---|
| Server action (click → mutation) | UI assertion (no reload) | DB query with |
| Auto-save (type → debounce → save) | "saved" indicator visible | Reload + verify value |
| URL/cookie state (locale, filters) | URL assertion | Reload + verify URL |
当服务端操作修改数据(切换、新增、删除、重排序)时,分别验证两点:
- UI立即更新(无需刷新) - 测试用户体验
- 数据持久化到数据库(通过Prisma查询) - 测试服务端操作,快速且确定
typescript
// 推荐:UI断言 + 数据库断言
await openCategoryPopover(page);
await getCategoryOption(page, /technology/i).click();
await page.keyboard.press("Escape");
// 1. 徽章立即出现 - 用户不需要刷新
await expect(page.getByText("Technology")).toBeVisible();
// 2. 服务端操作持久化成功 - 带重试的快速数据库检查
await expect(async () => {
const record = await prisma.courseCategory.findUnique({
where: { courseCategory: { category: "tech", courseId: course.id } },
});
expect(record).not.toBeNull();
}).toPass({ timeout: 10_000 });
// 不推荐:刷新页面验证持久化
await page.reload();
await expect(page.getByText("Technology")).toBeVisible();
// 这种方式慢、不稳定(缓存问题),而且无法发现"必须刷新才能看到更新"的bug什么时候适合刷新:自动保存流程(输入 → 防抖 → "已保存"提示 → 持久化),刷新可以验证完整的用户体验闭环。用户输入后看到"已保存",期望数据在刷新后仍然存在 - 这正是要测试的行为。
typescript
// 自动保存流程:刷新是合适的验证方式
await titleInput.fill(uniqueTitle);
await expect(page.getByText(/^saved$/i)).toBeVisible();
await page.reload();
await expect(titleInput).toHaveValue(uniqueTitle);决策指南:
| 操作类型 | 即时检查 | 持久化检查 |
|---|---|---|
| 服务端操作(点击 → 数据修改) | UI断言(无需刷新) | 带 |
| 自动保存(输入 → 防抖 → 保存) | "已保存"提示可见 | 刷新 + 验证值 |
| URL/cookie状态(区域设置、筛选器) | URL断言 | 刷新 + 验证URL |
Drag and Drop (dnd-kit)
拖拽功能(dnd-kit)
Use with the parameter. The option emits intermediate events, which dnd-kit's requires to activate a drag:
locator.dragTo()stepsstepsmousemovePointerSensortypescript
const firstHandle = page.getByRole("button", { name: "Drag to reorder" }).first();
const secondHandle = page.getByRole("button", { name: "Drag to reorder" }).nth(1);
await firstHandle.dragTo(secondHandle, { steps: 20 });Why matters: Without , Playwright emits a single at the destination, which isn't enough for dnd-kit's PointerSensor to recognize a drag gesture. Use for smooth, reliable drags.
stepsstepsmousemovesteps: 20Non-deterministic landing position: between adjacent items can produce different results across runs (swap vs move-to-end). This happens because dnd-kit's collision detection is sensitive to layout shifts — when an item is "lifted" into the DragOverlay, remaining items shift to fill the gap, moving the drop target. Assert that the order changed, not a specific final order:
dragToclosestCentertypescript
// BAD: Asserts a specific order — flaky because drag can land in different positions
const reorderedItems = [
{ position: 1, title: "Item 2" },
{ position: 2, title: "Item 1" },
{ position: 3, title: "Item 3" },
];
await expectItemsVisible(page, reorderedItems);
// GOOD: Asserts that reordering happened — stable regardless of exact landing position
const firstItem = page.getByRole("listitem").filter({ hasText: /01/ });
await expect(firstItem.getByRole("link", { name: /item 1/i })).not.toBeVisible();使用带参数的。选项会触发中间的事件,这是dnd-kit的识别拖拽手势的必要条件:
stepslocator.dragTo()stepsmousemovePointerSensortypescript
const firstHandle = page.getByRole("button", { name: "Drag to reorder" }).first();
const secondHandle = page.getByRole("button", { name: "Drag to reorder" }).nth(1);
await firstHandle.dragTo(secondHandle, { steps: 20 });为什么很重要:没有的话,Playwright只会在目标位置触发一次,不足以让dnd-kit的PointerSensor识别到拖拽手势。使用可以获得流畅可靠的拖拽效果。
stepsstepsmousemovesteps: 20落地位置不确定:相邻元素之间的在不同运行中可能产生不同结果(交换 vs 移动到末尾)。这是因为dnd-kit的碰撞检测对布局变化很敏感 - 当一个元素被"提起"到DragOverlay中时,剩下的元素会移动填充空缺,导致放置目标位置变化。断言顺序发生了变化即可,不要断言特定的最终顺序:
dragToclosestCentertypescript
// 错误:断言特定顺序 - 不稳定,因为拖拽可能落在不同位置
const reorderedItems = [
{ position: 1, title: "Item 2" },
{ position: 2, title: "Item 1" },
{ position: 3, title: "Item 3" },
];
await expectItemsVisible(page, reorderedItems);
// 正确:断言重排序确实发生了 - 不管具体落地位置都稳定
const firstItem = page.getByRole("listitem").filter({ hasText: /01/ });
await expect(firstItem.getByRole("link", { name: /item 1/i })).not.toBeVisible();Common Thinking Mistakes
常见的思路误区
Over-Testing Through UI Mechanics
通过UI机制过度测试
Mistake: Testing locale preservation by opening popovers and verifying their content.
Why it's wrong: You're testing popover behavior, not locale persistence.
Fix: Test the simplest proof - URL changes preserve locale.
typescript
// BAD: Tests translation rendering, not locale persistence
test("preserves locale", async ({ page }) => {
await page.goto("/pt/courses/intro");
await page.getByRole("button", { name: /detalhes/i }).click();
await expect(page.getByText(/descrição em português/i)).toBeVisible();
});
// GOOD: Tests actual locale persistence
test("preserves locale when navigating", async ({ page }) => {
await page.goto("/pt/courses");
await page.getByRole("link", { name: /machine learning/i }).click();
await expect(page).toHaveURL(/^\/pt\//);
});误区:通过打开弹出框验证内容来测试区域设置保存。
错误原因:你在测试弹出框的行为,而不是区域设置持久化。
修复方案:测试最简单的验证点 - URL变化时保留区域设置。
typescript
// 错误:测试翻译渲染,不是区域设置持久化
test("保留区域设置", async ({ page }) => {
await page.goto("/pt/courses/intro");
await page.getByRole("button", { name: /detalhes/i }).click();
await expect(page.getByText(/descrição em português/i)).toBeVisible();
});
// 正确:测试实际的区域设置持久化
test("跳转时保留区域设置", async ({ page }) => {
await page.goto("/pt/courses");
await page.getByRole("link", { name: /machine learning/i }).click();
await expect(page).toHaveURL(/^\/pt\//);
});Testing Implementation Instead of Behavior
测试实现而非行为
Mistake:
await expect(page.locator('[data-slot="badge"]')).toBeVisible()Why it's wrong: You're testing that an attribute exists, not user-visible behavior.
Fix: What does the user see? Test that.
typescript
// BAD: Testing CSS implementation
await expect(page.locator('[data-slot="badge"]')).toBeVisible();
// GOOD: Testing what user sees
await expect(page.getByRole("img", { name: /course thumbnail/i })).toBeVisible();误区:
await expect(page.locator('[data-slot="badge"]')).toBeVisible()错误原因:你在测试属性是否存在,而不是用户可见的行为。
修复方案:用户能看到什么?就测试什么。
typescript
// 错误:测试CSS实现
await expect(page.locator('[data-slot="badge"]')).toBeVisible();
// 正确:测试用户看到的内容
await expect(page.getByRole("img", { name: /course thumbnail/i })).toBeVisible();Relying on Seed Data Content
依赖预设数据内容
Mistake: Asserting specific seeded values appear in results.
Why it's wrong: Test breaks if seed data changes; can't run in parallel.
Fix: Create test data with unique identifiers.
typescript
// BAD: Depends on seed data
test("finds course by title", async ({ page }) => {
await page.getByRole("textbox").fill("Machine Learning");
await expect(page.getByText("Introduction to ML")).toBeVisible();
});
// GOOD: Creates its own data
test("finds course by title", async ({ page }) => {
const uniqueId = randomUUID().slice(0, 8);
await courseFixture({ title: `Search Test ${uniqueId}` });
await page.getByRole("textbox").fill(`Search Test ${uniqueId}`);
await expect(page.getByText(`Search Test ${uniqueId}`)).toBeVisible();
});误区:断言结果中出现特定的预设值。
错误原因:预设数据变化时测试会失败;无法并行运行。
修复方案:创建带唯一标识符的测试数据。
typescript
// 错误:依赖预设数据
test("可以通过标题搜索课程", async ({ page }) => {
await page.getByRole("textbox").fill("Machine Learning");
await expect(page.getByText("Introduction to ML")).toBeVisible();
});
// 正确:自行创建测试数据
test("可以通过标题搜索课程", async ({ page }) => {
const uniqueId = randomUUID().slice(0, 8);
await courseFixture({ title: `Search Test ${uniqueId}` });
await page.getByRole("textbox").fill(`Search Test ${uniqueId}`);
await expect(page.getByText(`Search Test ${uniqueId}`)).toBeVisible();
});Reloading to Verify Server Action Persistence
刷新页面验证服务端操作持久化
Mistake: Reloading the page to verify a server action persisted data.
Why it's wrong: Slow, flaky (caching/timing), and doesn't catch bugs where the UI doesn't update without a refresh.
Fix: Assert the UI updates immediately, then query the DB for persistence.
typescript
// BAD: Reload for persistence — slow, flaky, misses "must refresh" bugs
await page.getByRole("button", { name: /publish/i }).click();
await expect(toggle).toBeChecked();
await page.reload();
await expect(toggle).toBeChecked();
// GOOD: UI check + DB check — fast, reliable, catches UI bugs
await page.getByRole("button", { name: /publish/i }).click();
await expect(toggle).toBeChecked();
await expect(async () => {
const course = await prisma.course.findUniqueOrThrow({ where: { id: course.id } });
expect(course.isPublished).toBe(true);
}).toPass({ timeout: 10_000 });误区:刷新页面验证服务端操作是否持久化了数据。
错误原因:慢、不稳定(缓存/时序问题),而且无法发现UI不刷新就不更新的bug。
修复方案:先断言UI立即更新,再查询数据库验证持久化。
typescript
// 错误:刷新验证持久化 - 慢、不稳定,遗漏"必须刷新"的bug
await page.getByRole("button", { name: /publish/i }).click();
await expect(toggle).toBeChecked();
await page.reload();
await expect(toggle).toBeChecked();
// 正确:UI检查 + 数据库检查 - 快速、可靠,能发现UI bug
await page.getByRole("button", { name: /publish/i }).click();
await expect(toggle).toBeChecked();
await expect(async () => {
const course = await prisma.course.findUniqueOrThrow({ where: { id: course.id } });
expect(course.isPublished).toBe(true);
}).toPass({ timeout: 10_000 });Redundant Tests
冗余测试
Mistake: Writing separate tests when a higher-level test already covers the behavior.
Why it's wrong: More tests to maintain without more confidence.
Fix: If a test proves the final outcome, intermediate steps are implicitly verified.
typescript
// BAD: Two redundant tests
test("auto-saves title changes", async ({ page }) => {
await page.getByRole("textbox").fill("New Title");
await expect(page.getByText(/saved/i)).toBeVisible();
});
test("persists title after reload", async ({ page }) => {
await page.getByRole("textbox").fill("New Title");
await page.reload();
await expect(page.getByRole("textbox")).toHaveValue("New Title");
});
// GOOD: Single test proves both
test("auto-saves and persists title", async ({ page }) => {
await page.getByRole("textbox").fill("New Title");
await expect(page.getByText(/saved/i)).toBeVisible();
await page.reload();
await expect(page.getByRole("textbox")).toHaveValue("New Title");
});误区:当更上层的测试已经覆盖了行为时,还编写单独的测试。
错误原因:增加了需要维护的测试数量,但没有提升信心。
修复方案:如果一个测试已经验证了最终结果,中间步骤已经被隐式覆盖了。
typescript
// 错误:两个冗余的测试
test("自动保存标题修改", async ({ page }) => {
await page.getByRole("textbox").fill("New Title");
await expect(page.getByText(/saved/i)).toBeVisible();
});
test("刷新后标题仍然保存", async ({ page }) => {
await page.getByRole("textbox").fill("New Title");
await page.reload();
await expect(page.getByRole("textbox")).toHaveValue("New Title");
});
// 正确:单个测试验证两个点
test("自动保存并持久化标题", async ({ page }) => {
await page.getByRole("textbox").fill("New Title");
await expect(page.getByText(/saved/i)).toBeVisible();
await page.reload();
await expect(page.getByRole("textbox")).toHaveValue("New Title");
});Integration Testing (Vitest + Prisma)
集成测试(Vitest + Prisma)
Structure
结构
typescript
import { prisma } from "@/lib/db";
import { postFixture, memberFixture, signInAs } from "@/tests/fixtures";
describe("createComment", () => {
describe("unauthenticated users", () => {
test("returns unauthorized error", async () => {
const result = await createComment({ headers: new Headers(), postId: 1, content: "Test" });
expect(result.error?.message).toBe(ErrorCode.unauthorized);
});
});
describe("admin users", () => {
let post: Post;
let headers: Headers;
beforeAll(async () => {
const { organization, user } = await memberFixture({ role: "admin" });
post = await postFixture({ organizationId: organization.id });
headers = await signInAs(user.email, user.password);
});
test("creates comment successfully", async () => {
const result = await createComment({ headers, postId: post.id, content: "New Comment" });
expect(result.data?.content).toBe("New Comment");
});
});
});typescript
import { prisma } from "@/lib/db";
import { postFixture, memberFixture, signInAs } from "@/tests/fixtures";
describe("createComment", () => {
describe("未认证用户", () => {
test("返回未授权错误", async () => {
const result = await createComment({ headers: new Headers(), postId: 1, content: "Test" });
expect(result.error?.message).toBe(ErrorCode.unauthorized);
});
});
describe("管理员用户", () => {
let post: Post;
let headers: Headers;
beforeAll(async () => {
const { organization, user } = await memberFixture({ role: "admin" });
post = await postFixture({ organizationId: organization.id });
headers = await signInAs(user.email, user.password);
});
test("成功创建评论", async () => {
const result = await createComment({ headers, postId: post.id, content: "New Comment" });
expect(result.data?.content).toBe("New Comment");
});
});
});Test All Permission Levels
测试所有权限等级
typescript
describe("unauthenticated users", () => {
/* ... */
});
describe("members", () => {
/* ... */
});
describe("admins", () => {
/* ... */
});typescript
describe("未认证用户", () => {
/* ... */
});
describe("普通成员", () => {
/* ... */
});
describe("管理员", () => {
/* ... */
});Unit Testing (Vitest)
单元测试(Vitest)
When to Add Unit Tests
什么时候添加单元测试
- Edge cases not covered by e2e tests
- Complex utility functions
- Error boundary conditions
typescript
import { removeAccents } from "./string";
describe("removeAccents", () => {
test("removes diacritics from string", () => {
expect(removeAccents("café")).toBe("cafe");
expect(removeAccents("São Paulo")).toBe("Sao Paulo");
});
});- e2e测试没有覆盖的边界场景
- 复杂的工具函数
- 错误边界条件
typescript
import { removeAccents } from "./string";
describe("removeAccents", () => {
test("移除字符串中的重音符号", () => {
expect(removeAccents("café")).toBe("cafe");
expect(removeAccents("São Paulo")).toBe("Sao Paulo");
});
});Commands
命令
bash
undefinedbash
undefinedUnit/Integration tests
单元/集成测试
pnpm test # Run all tests once
pnpm test -- --run src/data/posts/create-post.test.ts # Run specific file
pnpm test # 运行所有测试一次
pnpm test -- --run src/data/posts/create-post.test.ts # 运行指定文件
E2E tests
E2E测试
pnpm --filter {app} build:e2e # Always run before e2e tests
pnpm --filter {app} e2e # Run all e2e tests
undefinedpnpm --filter {app} build:e2e # 运行e2e测试前必须先执行
pnpm --filter {app} e2e # 运行所有e2e测试
undefinedBest Practices
最佳实践
- 编写e2e测试前先阅读Playwright最佳实践