Apply this skill when writing, reviewing, or planning tests for a Next.js + TypeScript + Drizzle application using Vitest, React Testing Library, MSW, and Playwright. Triggers on requests like "write tests for", "add test coverage", "test this component", "test this hook", "test this API route", "add E2E tests", "is this testable", or any time you are building features that need verification. Use proactively — all new features should include tests.
87
Quality
87%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
| Layer | Tool | What to test | Volume |
|---|---|---|---|
| Unit | Vitest | Pure functions, utils, schemas, transforms | Many |
| Integration | Vitest + RTL + MSW | Hooks, components with data fetching, forms | Some |
| E2E | Playwright | Critical user flows, auth, multi-page workflows | Few |
// lib/__tests__/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, slugify } from '../utils'
describe('formatCurrency', () => {
it('formats USD with two decimals', () => {
expect(formatCurrency(1234.5)).toBe('$1,234.50')
})
it('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00')
})
})Test edge cases and error messages — these are your runtime safety net.
import { UserInsertSchema } from '../schema'
describe('UserInsertSchema', () => {
it('rejects invalid email', () => {
const result = UserInsertSchema.safeParse({ email: 'not-email', name: 'Test' })
expect(result.success).toBe(false)
})
it('rejects empty name', () => {
const result = UserInsertSchema.safeParse({ email: 'a@b.com', name: '' })
expect(result.success).toBe(false)
})
it('accepts valid input', () => {
const result = UserInsertSchema.safeParse({ email: 'a@b.com', name: 'Test' })
expect(result.success).toBe(true)
})
})Test transform functions and query builders — not the database itself.
import { buildUserFilters } from '../queries'
describe('buildUserFilters', () => {
it('filters by active status', () => {
const filters = buildUserFilters({ active: true })
expect(filters).toHaveLength(1)
})
})// tests/test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, type RenderOptions } from '@testing-library/react'
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
options,
)
}
export * from '@testing-library/react'
export { renderWithProviders as render }import { render, screen } from '@/tests/test-utils'
import userEvent from '@testing-library/user-event'
import { UserForm } from '../UserForm'
describe('UserForm', () => {
it('calls onSubmit with form values', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<UserForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.type(screen.getByLabelText('Name'), 'Test User')
await user.click(screen.getByRole('button', { name: /save/i }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
name: 'Test User',
})
})
it('shows validation error for invalid email', async () => {
const user = userEvent.setup()
render(<UserForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText('Email'), 'not-email')
await user.click(screen.getByRole('button', { name: /save/i }))
expect(screen.getByText(/invalid email/i)).toBeInTheDocument()
})
})getByRole) — tests what screen readers seegetByLabelText) — tests that labels are wiredgetByText) — for static contentgetByTestId unless there's no semantic alternativefindBy* (async) for elements that appear after data loadsimport { renderHook, waitFor } from '@testing-library/react'
import { useUsers } from '../useUsers'
import { createWrapper } from '@/tests/test-utils'
import { server } from '@/tests/mocks/server'
import { http, HttpResponse } from 'msw'
describe('useUsers', () => {
it('fetches users', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@test.com' },
])
}),
)
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toHaveLength(1)
expect(result.current.data[0].name).toBe('Alice')
})
it('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 })
}),
)
const { result } = renderHook(() => useUsers(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isError).toBe(true))
})
})// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([])
}),
// Add default handlers for all endpoints used in tests
]
// tests/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// tests/setup.ts
import { server } from './mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())E2E tests are slow — reserve them for flows that cross multiple pages or involve real auth.
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test('user can sign up and reach dashboard', async ({ page }) => {
await page.goto('/signup')
await page.getByLabel('Email').fill('new@example.com')
await page.getByLabel('Password').fill('SecurePass123!')
await page.getByRole('button', { name: /sign up/i }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible()
})data-testid SparinglyOnly for elements with no semantic role or text (e.g., a specific card in a Kanban column).
class PipelinePage {
constructor(private page: Page) {}
async goto() { await this.page.goto('/pipeline') }
async getColumn(name: string) { return this.page.getByRole('region', { name }) }
async getCandidateCard(name: string) { return this.page.getByText(name) }
}import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
it('has no accessibility violations', async () => {
const { container } = render(<UserForm onSubmit={vi.fn()} />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})| Priority | What | Why |
|---|---|---|
| P0 | Zod schemas | Runtime safety net — wrong validation = wrong data in DB |
| P0 | Auth flows (E2E) | Broken auth = security incident |
| P1 | Forms with validation | Users interact with these directly |
| P1 | Data fetching hooks | Incorrect cache/query behaviour causes stale UI |
| P1 | Complex business logic | Pure functions with branching logic |
| P2 | Components with conditional rendering | Edge cases in UI state |
| P2 | Error states | Users should see meaningful errors, not blank screens |
| P3 | Static presentational components | Low risk, low reward |
src/features/users/__tests__/useUsers.test.tstests/hooks/useUsers.test.ts*.test.ts or *.test.tsxe2e/*.spec.tstests/helpers.ts or tests/factories.ts