AgentSkillsCN

zoonk-testing

遵循 TDD 原则编写测试用例。适用于功能开发、Bug 修复,或新增测试覆盖范围的场景。涵盖端到端测试、集成测试与单元测试等多种测试模式。

SKILL.md
--- frontmatter
name: zoonk-testing
description: Write tests following TDD principles. Use when implementing features, fixing bugs, or adding test coverage. Covers e2e, integration, and unit testing patterns.
license: MIT
metadata:
  author: zoonk
  version: "1.0.0"

Testing Guidelines

Follow TDD (Test-Driven Development) for all features and bug fixes. Always write failing tests first.

TDD Workflow

  1. Write a failing test that describes the expected behavior
  2. Run the test to confirm it fails (red)
  3. Write the minimum code to make the test pass
  4. Run the test to confirm it passes (green)
  5. Refactor while keeping tests green

When to Apply TDD

  • Bug fix: Write a test that reproduces the bug first
  • New feature: Write a test showing the feature doesn't exist yet
  • Refactoring: Ensure tests exist before changing code

CRITICAL: Verify the Test Fails First

You MUST run the test before implementing the fix to confirm it fails. This is non-negotiable.

If the test passes before you write the 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. Common anti-patterns:

typescript
// BAD: Using .first() to avoid "strict mode violation" with multiple matches
await expect(page.getByText(courseTitle).first()).toBeVisible();
// This passes even if the item existed before your fix!

// BAD: Using loose assertions that match existing data
const courseTitle = "Test Course"; // Generic name that might exist
await expect(page.getByText(courseTitle)).toBeVisible();

// GOOD: Use unique identifiers to ensure you're testing NEW behavior
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

When you see "strict mode violation: resolved to N elements":

  1. Don't add .first() - that masks the real issue
  2. Ask: "Why are there multiple matches?"
  3. Make your test data unique so only ONE element can match but DO NOT use locators to make it unique, still use accessible queries like getByRole or getByText, just make the content unique
  4. The test should fail before the fix and pass after

TDD verification checklist:

  1. ✅ Write the test
  2. ✅ Run the test - it MUST fail
  3. ✅ If it passes, the test is wrong - fix the test first
  4. ✅ Write the implementation
  5. ✅ Run the test - it should now pass

Test Types

WhenTest TypeFrameworkLocation
Apps/UI featuresE2EPlaywrightapps/{app}/e2e/
Data functions (Prisma)IntegrationVitestapps/{app}/src/data/ or packages/
Utils/helpersUnitVitestpackages/{pkg}/*.test.ts

E2E Testing (Playwright)

Core Principle: Test User Behavior

Test what users see and do, not implementation details. If your test breaks when you refactor CSS or rename a class, it's testing the wrong thing.

Preventing Flaky Tests

CRITICAL: Run new tests multiple times before considering them done. Flaky tests often pass 90% of the time but fail intermittently. After writing a new E2E test:

bash
# Run the test 5+ times to catch flakiness
for i in {1..5}; do pnpm e2e -- -g "test name" --reporter=line; done

High-risk scenarios that commonly cause flakiness:

ScenarioRiskPrevention
Clicking dropdown/menu itemsAnimations cause instabilityWait for item visibility, use force: true
Actions that trigger navigationPage reload detaches elementsUse waitForLoadState or waitForURL after click
Form submissionsAsync save operationsWait for success indicator before next action
Clicking items in listsList may still be loadingWait for specific item with toBeVisible() first
Keyboard navigationFocus state transitionsWait for focused element before next key press
Inputs with debounced validationState changes between actionsUse waitForLoadState("networkidle") after fill

Defensive patterns for common interactions:

typescript
// RISKY: Dropdown item click without animation handling
await page.getByRole("menuitem", { name: /settings/i }).click();
await page.getByRole("menuitem", { name: "Dark mode" }).click();

// SAFE: Wait for submenu, then force click
await page.getByRole("menuitem", { name: /settings/i }).click();
await expect(page.getByRole("menuitem", { name: "Dark mode" })).toBeVisible();
await page.getByRole("menuitem", { name: "Dark mode" }).click({ force: true });

// RISKY: Click that triggers navigation without waiting
await page.getByRole("button", { name: /save/i }).click();
await page.getByRole("link", { name: /home/i }).click(); // May fail if save triggers reload

// SAFE: Wait for navigation to complete
await page.getByRole("button", { name: /save/i }).click();
await page.waitForLoadState("domcontentloaded");
await page.getByRole("link", { name: /home/i }).click();

Before marking a test as complete, ask:

  1. Does this test interact with animated elements (dropdowns, modals, tooltips)?
  2. Does any action trigger navigation or page reload?
  3. Does the test depend on async operations completing?
  4. Have I run this test multiple times to verify it's stable?

Avoid Redundant Tests

Don't write separate tests when a higher-level test already covers the behavior. If a test proves the final outcome, intermediate steps are implicitly verified.

Examples of redundancy to avoid:

  1. Visibility + interaction: If you click a button, don't also test that it's visible—the click would fail if it weren't.
  2. Intermediate + final states: If "data persists after reload" passes, "auto-save works" is already proven—don't test both.
  3. Multiple steps in a flow: If "user completes checkout" passes, you don't need separate tests for each form field.
typescript
// BAD: Two redundant tests - persistence proves auto-save worked
test("auto-saves title changes", async ({ page }) => {
  await page.getByRole("textbox", { name: /title/i }).fill("New Title");
  await expect(page.getByText(/saved/i)).toBeVisible();
});

test("persists title after reload", async ({ page }) => {
  await page.getByRole("textbox", { name: /title/i }).fill("New Title");
  await expect(page.getByText(/saved/i)).toBeVisible();
  await page.reload();
  await expect(page.getByRole("textbox", { name: /title/i })).toHaveValue("New Title");
});

// GOOD: Single test that implicitly verifies auto-save through persistence
test("auto-saves and persists title", async ({ page }) => {
  const titleInput = page.getByRole("textbox", { name: /title/i });
  await titleInput.fill("New Title");
  await expect(page.getByText(/saved/i)).toBeVisible();

  await page.reload();
  await expect(titleInput).toHaveValue("New Title");
});

When separate tests ARE useful:

  • Testing conditional rendering (element appears/disappears based on state)
  • Testing error states that require different setup than success states
  • Testing independent behaviors that don't share a logical flow

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 (including data-slot, data-testid, CSS classes)
page.locator(".btn-primary");
page.locator("#submit-button");
page.locator("[data-testid='submit']");
page.locator("[data-slot='media-card-icon']");
page.locator("button.bg-blue-500");

If you can't use getByRole, the component likely has accessibility issues. Refactor to make it more accessible instead of using implementation-detail selectors.

Fix Accessibility First, Then Test

When a component lacks semantic markup, fix the component before writing tests:

typescript
// BAD: Using implementation details because component lacks accessibility
test("shows fallback icon", async ({ page }) => {
  await expect(page.locator("[data-slot='media-card-icon']")).toBeVisible();
});

// GOOD: First fix the component to be accessible
// In the component: <MediaCardIcon role="img" aria-label={title}>
// Then test with semantic queries:
test("shows fallback icon", async ({ page }) => {
  const fallbackIcon = page.getByRole("img", { name: /post title/i }).first();
  await expect(fallbackIcon).toBeVisible();
  await expect(fallbackIcon).not.toHaveAttribute("src"); // Distinguishes from <img>
});

Common accessibility fixes:

  • Decorative icons acting as image placeholders → add role="img" and aria-label
  • Interactive elements without labels → add aria-label or visible text
  • Custom controls → add appropriate ARIA roles

Wait Patterns

typescript
// GOOD: Wait for visible state with timeout
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);

Animated Elements (Dropdowns, Modals, Submenus)

Animated elements with CSS transitions (slide-in-from-*, zoom-in-*, fade-in-*) can cause flaky tests because Playwright considers moving elements "not stable" and won't click them.

Pattern: Wait for content to be visible, then use force: true to bypass stability checks:

typescript
// BAD: Click immediately - element may still be animating
async function openLanguageSubmenu(page: Page) {
  await page.getByRole("button", { name: /menu/i }).click();
  await page.getByRole("menuitem", { name: /language/i }).click();
}

test("switches locale", async ({ page }) => {
  await openLanguageSubmenu(page);
  // This fails intermittently with "element is not stable"
  await page.getByRole("menuitem", { name: "Español" }).click();
});

// GOOD: Wait for animation to complete, then force click
async function openLanguageSubmenu(page: Page) {
  await page.getByRole("button", { name: /menu/i }).click();
  await page.getByRole("menuitem", { name: /language/i }).click();
  // Wait for submenu content to be visible (animation complete)
  await expect(page.getByRole("menuitem", { name: "English" })).toBeVisible();
}

test("switches locale", async ({ page }) => {
  await openLanguageSubmenu(page);
  // 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

Verify Destination Content, Not Just URLs

Never rely solely on toHaveURL for navigation tests. If a route is moved or broken, URL-only tests will pass even when the destination is wrong. Always verify the destination page renders expected content.

typescript
// BAD: Only checks URL - will pass even if page is broken or moved
test("creates item and redirects", async ({ page }) => {
  await page.getByRole("button", { name: /create/i }).click();
  await expect(page).toHaveURL(/\/items\/new-item/);
});

// GOOD: Verifies destination content exists
test("creates item and redirects to item page", async ({ page }) => {
  const itemTitle = "My New Item";
  const itemDescription = "Item description";

  // ... fill form with title and description ...

  await page.getByRole("button", { name: /create/i }).click();

  // Verify destination page shows the created content
  // For editable fields, use toHaveValue:
  await expect(page.getByRole("textbox", { name: /edit title/i })).toHaveValue(itemTitle);

  // For static text, use toBeVisible:
  await expect(page.getByText(itemDescription)).toBeVisible();
});

Testing Server Actions

Server Actions run server-side, so page.route() cannot intercept them. Don't try to mock server action failures with route interception—it only works for client-side HTTP requests.

To test error states, trigger real validation errors through user input:

typescript
// BAD: Trying to intercept server action (won't work)
test("shows error on failure", async ({ page }) => {
  await page.route("**/api/endpoint", (route) => route.fulfill({ status: 500 }));
  // This won't affect server actions - they don't go through the browser
});

// GOOD: Trigger real validation error
test("shows error for whitespace-only name", async ({ authenticatedPage }) => {
  const nameInput = authenticatedPage.getByLabel(/name/i);
  const originalName = await nameInput.inputValue();

  // Whitespace passes HTML5 "required" but fails server-side when trimmed
  await nameInput.clear();
  await nameInput.fill("   ");

  await authenticatedPage.getByRole("button", { name: /submit/i }).click();

  await expect(authenticatedPage.getByRole("alert")).toBeVisible();

  // Verify data wasn't corrupted
  await authenticatedPage.reload();
  await expect(nameInput).toHaveValue(originalName);
});

Common ways to trigger real server-side errors:

Error TypeHow to Trigger
Empty/invalid inputWhitespace-only " " (passes HTML5, fails server)
Duplicate valuesUse existing slug/email from seeded data
Invalid fileUpload wrong type or oversized file
Missing required dataRemove required field via JavaScript before submit

Always verify persistence after mutations:

typescript
// Success test: verify new data persisted
await nameInput.fill("New Name");
await submitButton.click();
await expect(page.getByText(/success/i)).toBeVisible();
await page.reload();
await expect(nameInput).toHaveValue("New Name");

// Error test: verify original data wasn't corrupted
const originalName = await nameInput.inputValue();
await nameInput.fill("   "); // Invalid
await submitButton.click();
await expect(page.getByRole("alert")).toBeVisible();
await page.reload();
await expect(nameInput).toHaveValue(originalName);

Authentication Fixtures

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();
});

test("new user sees onboarding", async ({ userWithoutProgress }) => {
  await userWithoutProgress.goto("/");
  await expect(userWithoutProgress.getByText("Get started")).toBeVisible();
});

E2E Test Data Setup

Choose the right approach based on what you're testing:

ScenarioApproachWhy
Testing a creation wizard/formUse UIYou're testing the creation flow itself
Testing edit/mutation behaviorUse Prisma fixturesFaster, isolated, focused on edit behavior
Testing read-only pagesUse seeded dataNo isolation needed, seeded data is stable
Testing validation against duplicatesUse seeded dataNeed known duplicates to validate against

Use Prisma fixtures when tests mutate data:

typescript
import { prisma } from "@/lib/db";
import { postFixture } from "@/tests/fixtures/posts";

async function createTestPost() {
  const user = await prisma.user.findFirstOrThrow();

  return postFixture({
    isPublished: true,
    userId: user.id,
    slug: `e2e-${randomUUID().slice(0, 8)}`,
  });
}

test("edits post title", async ({ authenticatedPage }) => {
  const post = await createTestPost();
  await authenticatedPage.goto(`/posts/${post.slug}`);
  // ... test editing behavior
});

Benefits over UI-based setup:

  • ~100x faster (50ms vs 5s per record)
  • Tests only what you intend to test
  • Failures are isolated to the behavior under test
  • Clear arrange/act/assert structure

Use seeded data for read-only tests:

typescript
// GOOD: Read-only test uses seeded "welcome-post" post
test("shows post details", async ({ page }) => {
  await page.goto("/posts/welcome-post");

  await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});

// GOOD: Validation test uses seeded post as duplicate target
test("shows error for duplicate slug", async ({ authenticatedPage }) => {
  const post = await createTestPost();
  await authenticatedPage.goto(`/posts/${post.slug}/edit`);
  await authenticatedPage.getByLabel(/url/i).fill("welcome-post"); // seeded post
  await expect(authenticatedPage.getByText(/already in use/i)).toBeVisible();
});

Test Organization

typescript
test.describe("Post Page", () => {
  test.describe("unauthenticated users", () => {
    test("shows sign-in prompt", async ({ page }) => {
      // ...
    });
  });

  test.describe("authenticated users", () => {
    test("can bookmark post", async ({ authenticatedPage }) => {
      // ...
    });
  });
});

However, avoid nesting too deeply. You shouldn't have more than 2 test.describe blocks.

Integration Testing (Vitest + Prisma)

Using Fixtures

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 organization: Organization;
    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");

      // Verify in database
      const comment = await prisma.comment.findFirst({
        where: { postId: post.id },
      });

      expect(comment?.content).toBe("New Comment");
    });
  });
});

Fixture Patterns

typescript
// Parallel fixture creation (faster)
const [org, user] = await Promise.all([organizationFixture(), userFixture()]);

// Dependent fixtures (when order matters)
const { organization, user } = await memberFixture({ role: "admin" });
const post = await postFixture({ organizationId: organization.id });

Test All Permission Levels

typescript
describe("unauthenticated users", () => {
  /* ... */
});

describe("members", () => {
  /* ... */
});

describe("admins", () => {
  /* ... */
});

// if owners have the same permissions as admins, you can skip this test
describe("owners", () => {
  /* ... */
});

Unit Testing (Vitest)

Pure Functions

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");
  });
});

When to Add Unit Tests

  • Edge cases not covered by e2e tests
  • Complex utility functions
  • Error boundary conditions

Commands

bash
# Unit/Integration tests using Vitest
pnpm test                    # Run all tests once

# Run specific test file
pnpm test -- --run src/data/posts/create-post.test.ts

# E2E tests using Playwright
pnpm e2e                     # Run all e2e tests