Load PROACTIVELY when task involves writing tests, improving coverage, or test infrastructure. Use when user says "add tests", "write unit tests", "set up E2E testing", "improve coverage", or "add mocks". Covers Vitest/Jest unit tests, React Testing Library component tests, Playwright E2E tests, MSW API mocking, test organization and naming, fixture management, coverage targets and thresholds, snapshot testing, and CI integration.
Generates comprehensive test strategies and code for unit, component, and E2E testing using frameworks like Vitest and Playwright.
/plugin marketplace add mgd34msu/goodvibes-plugin/plugin install goodvibes@goodvibes-marketThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/testing-patterns.mdscripts/validate-tests.shscripts/
validate-tests.sh
references/
testing-patterns.md
This skill guides you through implementing comprehensive testing strategies using modern testing frameworks and GoodVibes precision tools. Use this workflow when adding tests to existing code, setting up test infrastructure, or achieving coverage goals.
Follow consistent naming patterns:
// Co-located pattern (recommended)
src/
components/
Button.tsx
Button.test.tsx // Component tests
utils/
formatDate.ts
formatDate.test.ts // Unit tests
api/
users.ts
users.integration.test.ts // Integration tests
// Centralized pattern (alternative)
__tests__/
components/
Button.test.tsx
utils/
formatDate.test.ts
Organize tests with clear describe blocks:
import { describe, it, expect, beforeEach } from 'vitest';
import { formatDate } from './formatDate';
describe('formatDate', () => {
describe('with valid dates', () => {
it('formats ISO dates to MM/DD/YYYY', () => {
expect(formatDate('2024-01-15')).toBe('01/15/2024');
});
it('handles Date objects', () => {
const date = new Date('2024-01-15');
expect(formatDate(date)).toBe('01/15/2024');
});
});
describe('with invalid dates', () => {
it('throws for invalid strings', () => {
expect(() => formatDate('invalid')).toThrow('Invalid date');
});
it('throws for null', () => {
expect(() => formatDate(null)).toThrow('Invalid date');
});
});
});
Use precision tools to discover existing test patterns:
# Find all test files and analyze patterns
discover:
queries:
- id: test-files
type: glob
patterns:
- "**/*.test.{ts,tsx,js,jsx}"
- "**/*.spec.{ts,tsx,js,jsx}"
- "e2e/**/*.spec.ts"
exclude:
- "**/node_modules/**"
- "**/dist/**"
- id: test-config
type: glob
patterns:
- "vitest.config.ts"
- "jest.config.js"
- "playwright.config.ts"
- id: skipped-tests
type: grep
pattern: "\\.skip|it\\.only|describe\\.only"
glob: "**/*.test.{ts,tsx}"
output_mode: files_only
// vitest.config.ts
import { defineConfig } from 'vitest/config';
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'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
import { describe, it, expect } from 'vitest';
import { calculateTotal, discountPrice } from './pricing';
describe('pricing utilities', () => {
describe('calculateTotal', () => {
it('sums item prices', () => {
const items = [
{ price: 10.00, quantity: 2 },
{ price: 5.50, quantity: 1 },
];
expect(calculateTotal(items)).toBe(25.50);
});
it('returns 0 for empty array', () => {
expect(calculateTotal([])).toBe(0);
});
it('handles quantity multipliers', () => {
const items = [{ price: 10, quantity: 3 }];
expect(calculateTotal(items)).toBe(30);
});
});
describe('discountPrice', () => {
it('applies percentage discount', () => {
expect(discountPrice(100, 0.2)).toBe(80);
});
it('rounds to 2 decimal places', () => {
expect(discountPrice(10.99, 0.15)).toBe(9.34);
});
it('throws for invalid discounts', () => {
expect(() => discountPrice(100, -0.1)).toThrow('Invalid discount');
expect(() => discountPrice(100, 1.5)).toThrow('Invalid discount');
});
});
});
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from './useAuth';
// Mock the auth context
vi.mock('@/contexts/AuthContext', () => ({
useAuthContext: vi.fn(),
}));
describe('useAuth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns user when authenticated', () => {
vi.mocked(useAuthContext).mockReturnValue({
user: { id: '1', email: 'test@example.com' },
isLoading: false,
});
const { result } = renderHook(() => useAuth());
expect(result.current.user).toEqual({
id: '1',
email: 'test@example.com',
});
expect(result.current.isAuthenticated).toBe(true);
});
it('handles loading state', () => {
vi.mocked(useAuthContext).mockReturnValue({
user: null,
isLoading: true,
});
const { result } = renderHook(() => useAuth());
expect(result.current.isLoading).toBe(true);
expect(result.current.isAuthenticated).toBe(false);
});
it('refetches user on login', async () => {
const refetch = vi.fn().mockResolvedValue({ id: '1' });
vi.mocked(useAuthContext).mockReturnValue({
user: null,
refetch,
});
const { result } = renderHook(() => useAuth());
await result.current.login('test@example.com', 'password');
expect(refetch).toHaveBeenCalledOnce();
});
});
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import { SearchInput } from './SearchInput';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('disables button when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('shows spinner when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
it('applies variant styles', () => {
const { container } = render(<Button variant="primary">Primary</Button>);
expect(container.firstChild).toHaveClass('bg-blue-600');
});
});
describe('SearchInput', () => {
it('debounces search input', async () => {
const onSearch = vi.fn();
const user = userEvent.setup();
render(<SearchInput onSearch={onSearch} debounce={300} />);
const input = screen.getByRole('searchbox');
await user.type(input, 'test query');
// Should not call immediately
expect(onSearch).not.toHaveBeenCalled();
// Should call after debounce
await waitFor(
() => expect(onSearch).toHaveBeenCalledWith('test query'),
{ timeout: 500 }
);
});
it('clears input on clear button click', async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await user.type(input, 'test');
expect(input.value).toBe('test');
await user.click(screen.getByRole('button', { name: /clear/i }));
expect(input.value).toBe('');
});
});
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '@/api/users';
vi.mock('@/api/users');
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows loading state initially', () => {
vi.mocked(api.getUser).mockReturnValue(new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data when loaded', async () => {
vi.mocked(api.getUser).mockResolvedValue({
id: '1',
name: 'John Doe',
email: 'john@example.com',
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('shows error message on fetch failure', async () => {
vi.mocked(api.getUser).mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
});
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/posts/route';
import { prisma } from '@/lib/prisma';
import { createTestUser, cleanupDatabase } from '@/test/helpers';
describe('POST /api/posts', () => {
let testUser: { id: string; email: string };
beforeAll(async () => {
testUser = await createTestUser();
});
afterAll(async () => {
await cleanupDatabase();
});
it('creates a new post', async () => {
const { req } = createMocks({
method: 'POST',
body: {
title: 'Test Post',
content: 'Test content',
},
headers: {
authorization: `Bearer ${testUser.token}`,
},
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(201);
expect(data).toMatchObject({
title: 'Test Post',
content: 'Test content',
authorId: testUser.id,
});
// Verify in database
const post = await prisma.post.findUnique({
where: { id: data.id },
});
expect(post).toBeTruthy();
});
it('validates required fields', async () => {
const { req } = createMocks({
method: 'POST',
body: { title: '' }, // Missing content
headers: { authorization: `Bearer ${testUser.token}` },
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toMatchObject({
fieldErrors: {
title: expect.arrayContaining([expect.any(String)]),
content: expect.arrayContaining([expect.any(String)]),
},
});
});
it('requires authentication', async () => {
const { req } = createMocks({
method: 'POST',
body: { title: 'Test', content: 'Test' },
// No authorization header
});
const response = await POST(req);
expect(response.status).toBe(401);
});
});
// test/helpers.ts
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
export const prisma = new PrismaClient();
export async function createTestUser(overrides = {}) {
const hashedPassword = await hash('password123', 10);
return prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
password: hashedPassword,
...overrides,
},
});
}
export async function createTestPost(userId: string, overrides = {}) {
return prisma.post.create({
data: {
title: 'Test Post',
content: 'Test content',
authorId: userId,
published: false,
...overrides,
},
});
}
export async function cleanupDatabase() {
// Delete in correct order to respect foreign keys
await prisma.comment.deleteMany();
await prisma.post.deleteMany();
await prisma.user.deleteMany();
}
// 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: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
// Note: expect is imported from @playwright/test
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Authentication', () => {
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('invalid@example.com', 'wrong');
await loginPage.expectError('Invalid email or password');
});
test('preserves redirect after login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?redirect=%2Fdashboard/);
const loginPage = new LoginPage(page);
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
});
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: '1',
email: 'test@example.com',
name: 'Test User',
});
}),
http.post('/api/posts', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{
id: '1',
...body,
createdAt: new Date().toISOString(),
},
{ status: 201 }
);
}),
http.get('/api/posts/:id', ({ params }) => {
const { id } = params;
if (id === '404') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({
id,
title: 'Test Post',
content: 'Test content',
});
}),
];
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
import { vi } from 'vitest';
import type * as PrismaModule from '@/lib/prisma';
// Mock entire module
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
post: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
// Mock with implementation
vi.mock('@/lib/auth', () => ({
hashPassword: vi.fn((password: string) => `hashed_${password}`),
verifyPassword: vi.fn((password: string, hash: string) => {
return hash === `hashed_${password}`;
}),
}));
// Partial mock
vi.mock('@/lib/email', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/email')>();
return {
...actual,
sendEmail: vi.fn(), // Mock only sendEmail
};
});
Aim for high coverage on new code:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
'**/*.stories.tsx', // Storybook
'**/types/**',
],
all: true,
lines: 80,
functions: 80,
branches: 80,
statements: 80,
// Fail CI if coverage drops
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
Use precision tools to find untested code:
# Find files missing tests
precision_exec:
commands:
- cmd: "npm run test:coverage -- --reporter=json-summary"
expect:
exit_code: 0
# Parse coverage report
precision_read:
files:
- path: "coverage/coverage-summary.json"
extract: content
verbosity: minimal
# Find source files without corresponding tests
discover:
queries:
- id: source-files
type: glob
patterns: ["src/**/*.{ts,tsx}"]
exclude: ["**/*.test.*", "**/*.spec.*"]
- id: test-files
type: glob
patterns: ["**/*.test.{ts,tsx}"]
output_mode: files_only
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
// vitest.config.ts - parallel by default
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
maxThreads: 8,
},
},
},
});
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 1 : 4, // Limit parallelism in CI
fullyParallel: true,
});
# Run tests and validate output
precision_exec:
commands:
- cmd: "npm run test -- --run"
expect:
exit_code: 0
- cmd: "npm run typecheck"
expect:
exit_code: 0
- cmd: "npm run test:coverage -- --run"
expect:
exit_code: 0
stdout_contains: "All files"
verbosity: minimal
batch:
id: validate-tests
operations:
query:
- id: find-skipped
type: grep
pattern: "\\.skip|it\\.only|describe\\.only"
glob: "**/*.test.ts"
output:
format: count_only
exec:
- id: run-tests
type: command
commands:
- cmd: "npm run test -- --run"
expect: { exit_code: 0 }
- id: check-coverage
type: command
commands:
- cmd: "npm run test:coverage -- --run"
expect:
exit_code: 0
stdout_contains: "All files"
config:
execution:
mode: sequential
waitFor for async operationsvi.useFakeTimers()import { vi, beforeEach, afterEach } from 'vitest';
// Mock timers for time-dependent code
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('debounces function calls', async () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced('test');
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith('test');
});
// Ensure test isolation
beforeEach(async () => {
await cleanupDatabase();
vi.clearAllMocks();
});
// Use deterministic data
it('sorts users by creation date', () => {
const users = [
{ id: '1', createdAt: new Date('2024-01-01') },
{ id: '2', createdAt: new Date('2024-01-02') },
];
const sorted = sortByDate(users);
expect(sorted[0].id).toBe('2');
});
// BAD - tests internal state
it('sets loading to true', () => {
const { result } = renderHook(() => useUsers());
expect(result.current.loading).toBe(true);
});
// GOOD - tests user-visible behavior
it('shows loading spinner while fetching', () => {
render(<UserList />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
// BAD - mocks everything, tests nothing
vi.mock('./api');
vi.mock('./utils');
vi.mock('./hooks');
// GOOD - mock only external dependencies
vi.mock('axios'); // Mock HTTP client
// Let your code run for real
// BAD - breaks when styling changes
const button = container.querySelector('.btn-primary');
// GOOD - uses accessible queries
const button = screen.getByRole('button', { name: /submit/i });
Effective testing requires:
Use precision tools to discover test gaps, run tests efficiently, and validate implementation quality.
Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.