一键导入
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 职业分类
Use when designing, authoring, or reviewing a closed agent loop (test→fix→retest, audit→fix→re-audit, generate→verify→correct) in this marketplace — the minimum-bar checklist, the ground-truth oracle taxonomy, and the anti-patterns, anchored to /qa:loop as the reference implementation.
Test report format with QA-XXX issue IDs compatible with code-review plugin. Defines report structure, severity levels, issue format, and detailed results.
Bun package management, lockfile policy, workspaces, CI integration, and Bun-native tooling
Backend testing patterns — API request construction, response verification, database state checks, error handling testing, and adaptive tool detection.
Frontend testing patterns using Playwright MCP — navigation, interaction, assertions, screenshots on failure, and common UI testing scenarios.
Test plan structure, naming conventions, edge case generation rules, and file saving conventions for QA test plans.
| name | tdd-workflow |
| description | Test-driven development workflow with Vitest, React Testing Library, MSW v2, and Playwright |
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: