From ccfg-typescript
This skill should be used when writing TypeScript tests, configuring Vitest or Jest, testing React components, mocking APIs, or improving test coverage.
npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-typescriptThis skill uses the workspace's default tool permissions.
This skill defines comprehensive testing patterns for TypeScript projects, covering unit tests,
Provides TypeScript testing patterns using Vitest for unit tests, MSW for API mocking, typed mocks for dependency injection, and snapshot testing.
Provides testing pyramid, unit patterns (AAA, isolation, parameterized, edge cases), and React Testing Library for component tests. Use when writing tests or setting up testing infrastructure.
Designs, implements, and maintains TypeScript test suites using Vitest, Jest, Playwright, Cypress, and Testing Library. For adding tests, debugging failures, or refactoring.
Share bugs, ideas, or general feedback.
This skill defines comprehensive testing patterns for TypeScript projects, covering unit tests, integration tests, component tests, and E2E tests with Vitest, React Testing Library, MSW, and Playwright.
ALWAYS use Vitest for new TypeScript projects.
Advantages:
CORRECT vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; // If testing React
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom', // or 'node' for backend
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: ['node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*', '**/mockData', 'dist/'],
all: true,
lines: 90,
functions: 90,
branches: 85,
statements: 90,
},
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Setup file (src/test/setup.ts):
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
// Extend Vitest matchers with jest-dom
expect.extend(matchers);
// Cleanup after each test
afterEach(() => {
cleanup();
});
CORRECT patterns:
Button.test.tsx - colocated with componentutils.test.ts - colocated with module__tests__/Button.tsx - in tests directoryButton.spec.tsx - alternative conventionWRONG:
ButtonTest.tsx - not standardtest-button.tsx - not standardbutton-test.tsx - not standardCORRECT:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('UserService', () => {
describe('createUser', () => {
it('creates a user with valid data', () => {
const user = createUser({ name: 'Alice', email: 'alice@example.com' });
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
});
it('throws error when email is invalid', () => {
expect(() => {
createUser({ name: 'Alice', email: 'invalid' });
}).toThrow('Invalid email');
});
it('generates unique ID for each user', () => {
const user1 = createUser({ name: 'Alice', email: 'alice@example.com' });
const user2 = createUser({ name: 'Bob', email: 'bob@example.com' });
expect(user1.id).not.toBe(user2.id);
});
});
describe('updateUser', () => {
let existingUser: User;
beforeEach(() => {
existingUser = createUser({ name: 'Alice', email: 'alice@example.com' });
});
it('updates user properties', () => {
const updated = updateUser(existingUser.id, { name: 'Alicia' });
expect(updated.name).toBe('Alicia');
expect(updated.email).toBe('alice@example.com');
});
});
});
WRONG:
// Don't use test() instead of it()
test('should create user', () => {
/* ... */
});
// Don't skip describe blocks
it('creates user', () => {
/* ... */
});
it('updates user', () => {
/* ... */
});
it('deletes user', () => {
/* ... */
});
// Don't use unclear test names
it('works', () => {
/* ... */
});
it('test 1', () => {
/* ... */
});
Write test names in plain English that describe behavior, not implementation.
CORRECT:
it('displays user name after loading');
it('shows error message when API fails');
it('disables submit button while form is invalid');
it('filters items by search query');
it('calculates total price including tax');
WRONG:
it('should work'); // Too vague
it('test handleClick'); // Implementation detail
it('renders correctly'); // What does "correctly" mean?
it('test #1234'); // No context
userEvent over fireEventwaitForgetByTestId when possibleCORRECT:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('calls onClick handler when clicked', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button onClick={onClick}>Click me</Button>);
await user.click(screen.getByRole('button', { name: 'Click me' }));
expect(onClick).toHaveBeenCalledOnce();
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies variant class', () => {
render(<Button variant="secondary">Click me</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('button--secondary');
});
});
Follow this priority order for queries:
getByRole - Preferred (accessible)getByLabelText - Form inputsgetByPlaceholderText - If no labelgetByText - Non-interactive elementsgetByDisplayValue - Current valuegetByAltText - ImagesgetByTitle - SVG, iframesgetByTestId - Last resort onlyCORRECT:
// Prefer accessible queries
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('heading', { level: 1, name: 'Welcome' });
// For form inputs
screen.getByLabelText('Email address');
// For text content
screen.getByText('Welcome back!');
screen.getByText(/welcome/i); // Case insensitive regex
WRONG:
// Don't use getByTestId as first choice
screen.getByTestId('submit-button');
screen.getByTestId('email-input');
// Don't query by class or implementation details
container.querySelector('.button');
container.querySelector('#submit');
ALWAYS use userEvent for simulating user interactions.
CORRECT:
import { userEvent } from '@testing-library/user-event';
it('handles user typing', async () => {
const user = userEvent.setup();
render(<Input />);
const input = screen.getByRole('textbox');
await user.type(input, 'Hello World');
expect(input).toHaveValue('Hello World');
});
it('handles user clicking', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalled();
});
it('handles user selecting option', async () => {
const user = userEvent.setup();
render(<Select options={['A', 'B', 'C']} />);
await user.selectOptions(screen.getByRole('combobox'), 'B');
expect(screen.getByRole('combobox')).toHaveValue('B');
});
WRONG:
import { fireEvent } from '@testing-library/react';
// Don't use fireEvent - it doesn't simulate real user behavior
fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.click(button);
Use waitFor for async operations.
CORRECT:
import { waitFor } from '@testing-library/react';
it('displays user data after loading', async () => {
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
it('shows error message when API fails', async () => {
server.use(
http.get('/api/user', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
// Alternative: use findBy queries (combines getBy + waitFor)
it('displays user data after loading', async () => {
render(<UserProfile userId="123" />);
const userName = await screen.findByText('John Doe');
expect(userName).toBeInTheDocument();
});
WRONG:
// Don't use act() manually - waitFor handles it
act(() => {
render(<UserProfile />);
});
// Don't use arbitrary timeouts
await new Promise(resolve => setTimeout(resolve, 1000));
expect(screen.getByText('John Doe')).toBeInTheDocument();
// Don't test implementation details
expect(component.state.loading).toBe(false);
ALWAYS use MSW for mocking HTTP requests in tests.
Install:
pnpm add -D msw
Create handlers (src/test/mocks/handlers.ts):
import { http, HttpResponse } from 'msw';
export const handlers = [
// GET request
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' },
]);
}),
// POST request
http.post('/api/users', async ({ request }) => {
const user = await request.json();
return HttpResponse.json({ id: '3', ...user }, { status: 201 });
}),
// Error response
http.get('/api/error', () => {
return new HttpResponse(null, { status: 500 });
}),
// Dynamic path parameters
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({
id,
name: `User ${id}`,
email: `user${id}@example.com`,
});
}),
// Query parameters
http.get('/api/search', ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('q');
return HttpResponse.json({
results: [`Result for ${query}`],
});
}),
];
Create server (src/test/mocks/server.ts):
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
Setup in test config (src/test/setup.ts):
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Close server after all tests
afterAll(() => server.close());
CORRECT:
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
describe('UserList', () => {
it('displays users from API', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
it('displays error when API fails', async () => {
// Override handler for this test
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it('displays empty state when no users', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([]);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('No users found')).toBeInTheDocument();
});
});
it('handles network error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.error();
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
});
CORRECT:
import { http, HttpResponse, type HttpHandler } from 'msw';
type User = {
id: string;
name: string;
email: string;
};
type CreateUserRequest = Omit<User, 'id'>;
type CreateUserResponse = User;
export const handlers: HttpHandler[] = [
http.get<never, never, User[]>('/api/users', () => {
return HttpResponse.json([{ id: '1', name: 'John', email: 'john@example.com' }]);
}),
http.post<never, CreateUserRequest, CreateUserResponse>('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: '123',
...body,
});
}),
http.get<{ id: string }, never, User>('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'John',
email: 'john@example.com',
});
}),
];
CORRECT:
import { vi } from 'vitest';
it('calls callback with result', () => {
const callback = vi.fn();
processData('input', callback);
expect(callback).toHaveBeenCalledWith('RESULT');
expect(callback).toHaveBeenCalledOnce();
});
it('uses mock implementation', () => {
const fetchUser = vi.fn().mockResolvedValue({
id: '1',
name: 'John',
});
const result = await fetchUser('1');
expect(result.name).toBe('John');
});
it('tracks multiple calls', () => {
const logger = vi.fn();
logger('first');
logger('second');
logger('third');
expect(logger).toHaveBeenCalledTimes(3);
expect(logger).toHaveBeenNthCalledWith(1, 'first');
expect(logger).toHaveBeenNthCalledWith(2, 'second');
expect(logger).toHaveBeenNthCalledWith(3, 'third');
});
CORRECT:
import { vi, beforeEach } from 'vitest';
import { sendEmail } from './email';
import { notifyUser } from './notifications';
// Mock entire module
vi.mock('./email', () => ({
sendEmail: vi.fn(),
}));
describe('notifyUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('sends email to user', () => {
notifyUser('user@example.com', 'Hello');
expect(sendEmail).toHaveBeenCalledWith({
to: 'user@example.com',
subject: 'Notification',
body: 'Hello',
});
});
});
CORRECT:
import { vi } from 'vitest';
it('tracks console.log calls', () => {
const logSpy = vi.spyOn(console, 'log');
logger.info('test message');
expect(logSpy).toHaveBeenCalledWith('[INFO]', 'test message');
logSpy.mockRestore();
});
it('mocks Date.now', () => {
const now = new Date('2024-01-01').getTime();
vi.spyOn(Date, 'now').mockReturnValue(now);
const timestamp = getCurrentTimestamp();
expect(timestamp).toBe(now);
vi.restoreAllMocks();
});
CORRECT:
import { vi, beforeEach, afterEach } from 'vitest';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('delays function execution', () => {
const fn = vi.fn();
const debounced = debounce(fn, 1000);
debounced('arg1');
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledWith('arg1');
});
it('cancels previous timeout', () => {
const fn = vi.fn();
const debounced = debounce(fn, 1000);
debounced('first');
vi.advanceTimersByTime(500);
debounced('second');
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('second');
});
});
CORRECT:
import { renderHook, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('updates when props change', () => {
const { result, rerender } = renderHook(({ initialValue }) => useCounter(initialValue), {
initialProps: { initialValue: 0 },
});
expect(result.current.count).toBe(0);
rerender({ initialValue: 10 });
expect(result.current.count).toBe(10);
});
});
CORRECT:
describe('useAsyncData', () => {
it('loads data', async () => {
const { result } = renderHook(() => useAsyncData('/api/data'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ value: 'test' });
});
it('handles errors', async () => {
server.use(
http.get('/api/data', () => {
return new HttpResponse(null, { status: 500 });
})
);
const { result } = renderHook(() => useAsyncData('/api/data'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeTruthy();
});
});
Only use snapshots for stable data structures, not UI.
CORRECT:
it('serializes config correctly', () => {
const config = generateConfig({ env: 'production' });
expect(config).toMatchSnapshot();
});
it('generates correct AST', () => {
const ast = parseCode('function add(a, b) { return a + b; }');
expect(ast).toMatchInlineSnapshot(`
{
"type": "FunctionDeclaration",
"name": "add",
"params": ["a", "b"],
"body": { "type": "ReturnStatement" }
}
`);
});
WRONG:
// Don't snapshot entire component trees
it('renders correctly', () => {
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
CORRECT:
describe('LoginForm', () => {
it('submits form with email and password', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
it('displays validation errors', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
it('disables submit button during submission', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 1000))
);
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
const submitButton = screen.getByRole('button', { name: 'Sign in' });
await user.click(submitButton);
expect(submitButton).toBeDisabled();
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
});
CORRECT:
describe('TodoList', () => {
const todos = [
{ id: '1', text: 'Buy milk', completed: false },
{ id: '2', text: 'Walk dog', completed: true },
{ id: '3', text: 'Write tests', completed: false },
];
it('renders all todo items', () => {
render(<TodoList todos={todos} />);
expect(screen.getByText('Buy milk')).toBeInTheDocument();
expect(screen.getByText('Walk dog')).toBeInTheDocument();
expect(screen.getByText('Write tests')).toBeInTheDocument();
});
it('shows completed items as checked', () => {
render(<TodoList todos={todos} />);
const walkDog = screen.getByRole('checkbox', { name: /walk dog/i });
expect(walkDog).toBeChecked();
const buyMilk = screen.getByRole('checkbox', { name: /buy milk/i });
expect(buyMilk).not.toBeChecked();
});
it('toggles todo completion', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<TodoList todos={todos} onToggle={onToggle} />);
await user.click(screen.getByRole('checkbox', { name: /buy milk/i }));
expect(onToggle).toHaveBeenCalledWith('1');
});
});
CORRECT:
describe('UserProfile', () => {
it('shows loading state initially', () => {
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows user data after loading', async () => {
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
it('shows error state on failure', async () => {
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it('shows empty state when user not found', async () => {
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 404 });
})
);
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('User not found')).toBeInTheDocument();
});
});
});
CORRECT vitest.config.ts:
export default defineConfig({
test: {
coverage: {
provider: 'v8', // or 'c8'
reporter: ['text', 'json', 'html', 'lcov'],
all: true,
include: ['src/**/*.{ts,tsx}'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/*.test.{ts,tsx}',
'**/mockData/**',
'dist/',
],
lines: 90,
functions: 90,
branches: 85,
statements: 90,
},
},
});
Run coverage:
pnpm exec vitest run --coverage
CORRECT playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'pnpm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
CORRECT:
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('successful login redirects to dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toHaveText('Dashboard');
});
test('shows error on invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'invalid@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toHaveText('Invalid credentials');
});
});
When writing tests, ensure:
These patterns ensure reliable, maintainable tests that catch bugs and document behavior.