一键导入
frontend-tdd-workflow
Test-driven development workflow with Vitest, React Testing Library, MSW v2, and Playwright
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Test-driven development workflow with Vitest, React Testing Library, MSW v2, and Playwright
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Backend testing patterns — API request construction, response verification, database state checks, error handling testing, and adaptive tool detection.
Frontend testing patterns using Playwright — navigation, interaction, assertions, screenshots on failure, and common UI testing scenarios.
Test report format with QA-XXX issue IDs compatible with code-review plugin. Defines report structure, severity levels, issue format with canonical fields, and detailed results.
Test plan structure, naming conventions, edge case generation rules, and file saving conventions for QA test plans.
Enforces AppVerk Swift coding standards across all code.
Structured concurrency and thread safety patterns in modern Swift.
| name | frontend-tdd-workflow |
| description | Test-driven development workflow with Vitest, React Testing Library, MSW v2, and Playwright |
| activation | MUST load when writing tests, fixing bugs, or refactoring TypeScript/React code |
Enforces red-green-refactor TDD cycle with comprehensive testing strategy:
/\
/E2E\ 5-10% — Playwright critical paths
/------\
/ Integr. \ Largest layer — RTL + MSW
/ ation \ "Write tests. Not too many. Mostly integration."
/--------------\
/ Unit tests \ Hooks, utils, store logic, pure functions
/------------------\
/ Static (TypeScript) \ Foundation — errors caught at compile time
/________________________\
Static Analysis (Foundation)
Unit Tests (10-15%)
formatDate(), calculateTotal(), parseQueryString()useAuth(), useFormData() without renderingIntegration Tests (Largest Layer — 60-75%)
E2E Tests (5-10%)
fireEvent — ALWAYS use userEventgetByText as first choice — prefer screen.getByRolegetByTestId without strong justificationact() wrapping around render() or userEvent — React Testing Library handles itbeforeEach — inline setup in each testvi.mock() — ALWAYS use MSW for API mockingxit, skip, pendingButton.tsx + Button.test.tsx)pnpm test) not just individual files during developmentsrc/
features/auth/
components/
LoginForm.tsx
LoginForm.test.tsx # co-located
hooks/
useAuth.ts
useAuth.test.ts # co-located
api/
authApi.ts
authApi.test.ts # co-located
mocks/
handlers.ts # MSW handlers (centralized)
server.ts # MSW server setup
test/
setup.ts # Vitest setup file
test-utils.tsx # Custom render with providers
e2e/
pages/
LoginPage.ts # Page Object Model
DashboardPage.ts
tests/
auth.spec.ts # E2E tests
checkout.spec.ts
| Test Type | Pattern | Location |
|---|---|---|
| Unit / Integration | *.test.{ts,tsx} | Co-located in src/ |
| E2E | *.spec.ts | e2e/tests/ |
| Page Objects | *Page.ts | e2e/pages/ |
Every project must have src/test/test-utils.tsx with custom render wrapping components in all required providers.
Example:
// src/test/test-utils.tsx
import type { ReactElement } from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RenderOptions, render } from '@testing-library/react';
import type { Router } from '@tanstack/react-router';
import { RouterProvider } from '@tanstack/react-router';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
queryClient?: QueryClient;
router?: Router;
}
const Wrapper = ({
children,
queryClient,
}: {
children: ReactElement;
queryClient: QueryClient;
}): ReactElement => (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
export function renderWithProviders(
ui: ReactElement,
{
queryClient = createTestQueryClient(),
...renderOptions
}: ExtendedRenderOptions = {},
) {
return render(ui, {
wrapper: (props) => <Wrapper {...props} queryClient={queryClient} />,
...renderOptions,
});
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
export { renderWithProviders as render };
Always import from test-utils:
// ❌ BAD: Direct RTL import
import { render } from '@testing-library/react';
// ✅ GOOD: From test-utils
import { render } from '@/test/test-utils';
src/mocks/handlers.ts — centralized API mocks:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
const { id } = params as { id: string };
return HttpResponse.json({ id, name: 'John Doe' });
}),
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'test@example.com') {
return HttpResponse.json(
{ token: 'mock-token', user: { id: '1', email: body.email } },
{ status: 200 }
);
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
];
src/mocks/server.ts — Vitest integration:
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
vitest.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
src/test/setup.ts:
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from '@/mocks/server';
// Enable API mocking before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => {
server.resetHandlers();
cleanup();
});
// Disable API mocking after tests
afterAll(() => server.close());
Override handlers for specific tests:
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('shows error on failed login', async () => {
// Override handler for this test
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
})
);
render(<LoginForm />);
// ... test error state
});
});
// formatDate.ts
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
// formatDate.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from './formatDate';
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-12-25');
expect(formatDate(date)).toBe('December 25, 2024');
});
it('handles different dates', () => {
expect(formatDate(new Date('2024-01-01'))).toBe('January 1, 2024');
});
});
// useAuth.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
describe('useAuth', () => {
it('returns null user on init', () => {
const { result } = renderHook(() => useAuth());
expect(result.current.user).toBeNull();
});
it('logs in user', async () => {
const { result } = renderHook(() => useAuth());
// Simulate login
await waitFor(() => {
result.current.login('test@example.com', 'password');
});
expect(result.current.user?.email).toBe('test@example.com');
});
});
// LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('logs in user on valid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
// Find inputs by role
const emailInput = screen.getByRole('textbox', { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /sign in/i });
// Simulate user interaction
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password');
await user.click(submitButton);
// Wait for success state
await waitFor(() => {
expect(screen.getByText(/logged in/i)).toBeInTheDocument();
});
});
it('shows error on invalid credentials', async () => {
// Handler override from setup
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ error: 'Invalid' }, { status: 401 })
)
);
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
await user.type(screen.getByRole('textbox', { name: /email/i }), 'wrong@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrong');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
});
});
Use this priority for selecting elements:
screen.getByRole() — most semantic, encourages accessible codescreen.getByLabelText() — form inputs with labelsscreen.getByPlaceholderText() — inputs with placeholdersscreen.getByText() — last resort for non-interactive textscreen.getByTestId() — only when others impossible (rare)Example:
// ✅ GOOD: Using getByRole
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// ⚠️ ACCEPTABLE: Using getByLabelText
screen.getByLabelText(/password/i);
// ❌ AVOID: Using getByText without strong reason
screen.getByText('Click me'); // Brittle, tests text not behavior
// ❌ AVOID: Using getByTestId
screen.getByTestId('submit-button'); // Tests implementation, not behavior
e2e/pages/LoginPage.ts:
import type { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.page.fill('input[type="email"]', email);
await this.page.fill('input[type="password"]', password);
await this.page.click('button[type="submit"]');
}
async hasError(): Promise<boolean> {
return this.page.isVisible('[role="alert"]');
}
async isLoggedIn(): Promise<boolean> {
return this.page.isVisible('[data-testid="user-menu"]');
}
}
e2e/tests/auth.spec.ts:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('Auth Flow', () => {
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'password');
expect(await loginPage.isLoggedIn()).toBe(true);
});
test('shows error on invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrong');
expect(await loginPage.hasError()).toBe(true);
});
});
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});
pnpm typecheck
No errors, no warnings. Zero any, !, as (except as const).
pnpm test
All tests passing. Zero failures. Coverage >= 80%.
pnpm lint
Zero warnings. Zero errors. Automatic fixes with --fix acceptable.
If any gate fails after 3 attempts: Record remaining failures and proceed.
Check coverage:
pnpm test --coverage
Never exclude code from coverage. If coverage is hard to reach:
// WRONG: Tests internal state
it('stores user in state', () => {
render(<LoginForm />);
expect(component.state.user).toBeDefined();
});
// CORRECT: Tests user-visible behavior
it('shows user name after login', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(...);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// WRONG: fireEvent doesn't simulate real user interactions
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'text' } });
// CORRECT: userEvent simulates realistic interactions
await userEvent.click(button);
await userEvent.type(input, 'text');
// WRONG: beforeEach mutates state shared across tests
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient();
// Tests might affect each other
});
// CORRECT: Create fresh state in each test
it('test 1', () => {
const queryClient = new QueryClient();
// ...
});
// WRONG: Mocking fetch directly
vi.mock('node-fetch');
// CORRECT: MSW handles API mocking
server.use(http.get('/api/users', () => HttpResponse.json([])));
TDD ensures: