zoonk-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Testing 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:
BehaviorSimplest ProofNOT This
Locale persistenceNavigate → click link → check URL contains
/pt
Navigate → click course → open popover → verify translation
Course creationFill title → submit → verify title appearsFill all fields → verify each field → check database → verify list
Login worksEnter credentials → submit → see dashboard headingEnter credentials → verify button enabled → submit → check cookie
Item appears in listCreate item → verify it's visibleCreate item → scroll list → filter → sort → find item
找到能验证目标行为的最少操作步骤:
行为最简验证方式不推荐的方式
区域设置持久化跳转 → 点击链接 → 检查URL包含
/pt
跳转 → 点击课程 → 打开弹出框 → 验证翻译内容
课程创建填写标题 → 提交 → 验证标题显示填写所有字段 → 验证每个字段 → 检查数据库 → 验证列表展示
登录功能正常输入凭据 → 提交 → 看到控制台标题输入凭据 → 验证按钮启用 → 提交 → 检查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工作流

  1. Write a failing test that describes the expected behavior
  2. Run the test to confirm it fails - this is non-negotiable
  3. Write the minimum code to make the test pass
  4. Run the test to confirm it passes
  5. Refactor while keeping tests green
  1. 编写描述预期行为的失败测试用例
  2. 运行测试确认它会失败 - 这一步必不可少
  3. 编写最少的代码让测试通过
  4. 运行测试确认它已通过
  5. 重构代码同时保持测试全部通过

If the Test Passes Before Your Fix

如果在你修复代码前测试就通过了

The test is wrong. A passing test means one of:
  1. The bug doesn't exist (investigate further)
  2. The test is matching existing/seeded data instead of new behavior
  3. 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
说明测试有问题。测试提前通过意味着以下情况之一:
  1. bug不存在(需要进一步排查)
  2. 测试匹配的是现有/预设数据,而不是新的行为
  3. 测试断言太松散
永远不要使用变通方法让"应该失败"的测试通过:
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:
  1. Create your own data - Don't rely on seed data existing or having specific values
  2. No cleanup needed - If you need
    afterEach
    cleanup, your test isn't isolated
  3. Parallel safe - Tests should run in any order, even simultaneously
核心规则:测试必须完全自包含。
这意味着:
  1. 创建你自己的测试数据 - 不要依赖预设数据的存在或特定值
  2. 不需要额外清理 - 如果你需要
    afterEach
    做清理,说明你的测试没有隔离
  3. 支持并行运行 - 测试应该可以按任意顺序运行,甚至同时运行

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:
  1. It's a structural dependency, not a content assertion
  2. Tests create their own content within it
  3. 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 });
使用预设的组织作为容器是可以接受的,因为:
  1. 它是结构依赖,不是内容断言
  2. 测试会在它内部创建自己的内容
  3. 这个组织在所有环境中都保证存在
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

测试类型

WhenTest TypeFrameworkLocation
Apps/UI featuresE2EPlaywright
apps/{app}/e2e/
Data functions (Prisma)IntegrationVitest
apps/{app}/src/data/
or
packages/
Utils/helpersUnitVitest
packages/{pkg}/*.test.ts
适用场景测试类型框架存放位置
应用/UI功能E2EPlaywright
apps/{app}/e2e/
数据函数(Prisma)集成测试Vitest
apps/{app}/src/data/
packages/
工具函数/辅助方法单元测试Vitest
packages/{pkg}/*.test.ts

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
getByRole
, the component likely has accessibility issues.
Fix the component first.
使用能反映用户页面交互方式的语义化查询:
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']");
如果你无法使用
getByRole
,说明组件很可能存在可访问性问题。先修复组件。

Wait 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: true
:
typescript
// 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: true
typescript
// 等待子菜单内容可见(动画完成)
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; done
High-risk scenarios:
ScenarioPrevention
Clicking dropdown itemsWait for visibility, use
force: true
Actions triggering navigationUse
waitForLoadState
or
waitForURL
after click
Form submissionsWait for success indicator before next action
Inputs with debounced validationUse
waitForLoadState("networkidle")
after fill
Server action persistenceAssert UI immediately, then DB query with
toPass
新测试完成前多次运行验证稳定性:
bash
for i in {1..5}; do pnpm e2e -- -g "test name" --reporter=line; done
高风险场景:
场景预防方案
点击下拉选项等待可见,使用
force: true
触发页面跳转的操作点击后使用
waitForLoadState
waitForURL
表单提交下一步操作前等待成功提示出现
带防抖验证的输入框填写后使用
waitForLoadState("networkidle")
服务端操作持久化先立即断言UI,再用
toPass
查询数据库

Server Actions

服务端Actions

Server Actions run server-side, so
page.route()
cannot intercept them.
To test error states, trigger real validation errors:
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:
  1. UI updates immediately (no reload) — tests the user experience
  2. 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" bugs
When 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 typeImmediate checkPersistence check
Server action (click → mutation)UI assertion (no reload)DB query with
toPass
retry
Auto-save (type → debounce → save)"saved" indicator visibleReload + verify value
URL/cookie state (locale, filters)URL assertionReload + verify URL
当服务端操作修改数据(切换、新增、删除、重排序)时,分别验证两点:
  1. UI立即更新(无需刷新) - 测试用户体验
  2. 数据持久化到数据库(通过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断言(无需刷新)
toPass
重试的数据库查询
自动保存(输入 → 防抖 → 保存)"已保存"提示可见刷新 + 验证值
URL/cookie状态(区域设置、筛选器)URL断言刷新 + 验证URL

Drag and Drop (dnd-kit)

拖拽功能(dnd-kit)

Use
locator.dragTo()
with the
steps
parameter. The
steps
option emits intermediate
mousemove
events, which dnd-kit's
PointerSensor
requires to activate a drag:
typescript
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
steps
matters
: Without
steps
, Playwright emits a single
mousemove
at the destination, which isn't enough for dnd-kit's PointerSensor to recognize a drag gesture. Use
steps: 20
for smooth, reliable drags.
Non-deterministic landing position:
dragTo
between adjacent items can produce different results across runs (swap vs move-to-end). This happens because dnd-kit's
closestCenter
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:
typescript
// 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();
使用带
steps
参数的
locator.dragTo()
steps
选项会触发中间的
mousemove
事件,这是dnd-kit的
PointerSensor
识别拖拽手势的必要条件:
typescript
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 });
为什么
steps
很重要
:没有
steps
的话,Playwright只会在目标位置触发一次
mousemove
,不足以让dnd-kit的PointerSensor识别到拖拽手势。使用
steps: 20
可以获得流畅可靠的拖拽效果。
落地位置不确定:相邻元素之间的
dragTo
在不同运行中可能产生不同结果(交换 vs 移动到末尾)。这是因为dnd-kit的
closestCenter
碰撞检测对布局变化很敏感 - 当一个元素被"提起"到DragOverlay中时,剩下的元素会移动填充空缺,导致放置目标位置变化。断言顺序发生了变化即可,不要断言特定的最终顺序:
typescript
// 错误:断言特定顺序 - 不稳定,因为拖拽可能落在不同位置
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
undefined
bash
undefined

Unit/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
undefined
pnpm --filter {app} build:e2e # 运行e2e测试前必须先执行 pnpm --filter {app} e2e # 运行所有e2e测试
undefined

Best Practices

最佳实践