Testing Strategy
Core Philosophy
Our testing strategy follows Kent C. Dodds' principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
What We Test
Focus on testing behavior, not implementation. Test what users experience, not how the code achieves it.
// ❌ Testing implementation details
test('setState updates component state', () => {
const component = render(<UserProfile />);
expect(component.state.loading).toBe(true);
});
// ✅ Testing behavior
test('shows loading spinner while fetching user data', () => {
render(<UserProfile userId="123" />);
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
The Testing Trophy
Prioritize tests based on confidence and maintenance cost:
🏆 E2E Tests (10%)
/ \
/ \ Integration Tests (70%)
/ \
/________\ Unit Tests (20%)
- Unit Tests: Pure functions, utilities, complex algorithms
- Integration Tests: Component interactions, API calls, database operations
- E2E Tests: Critical user journeys, payment flows, authentication
What NOT to Test
- Third-party libraries - Trust that Supabase, Next.js, etc. work
- Implementation details - Private methods, state structure, component internals
- Style/CSS - Unless it affects functionality
- Generated code - TypeScript types from Supabase
- Framework features - Next.js routing, React hooks
Testing Principles
1. Test User Behavior
// ❌ Testing internals
test('calls setNotes with API response', async () => {
const setNotes = jest.fn();
// ... testing that setNotes was called
});
// ✅ Testing behavior
test('displays notes after loading', async () => {
render(<NotesPage />);
await waitFor(() => {
expect(screen.getByText('My First Note')).toBeInTheDocument();
});
});
2. Avoid Excessive Mocking
// ❌ Over-mocking
jest.mock('@kit/supabase/server-client');
jest.mock('@kit/shared/logger');
jest.mock('next/navigation');
// ✅ Minimal mocking - only external services
const mockSupabase = {
from: () => ({
select: () => ({
eq: () => Promise.resolve({ data: mockNotes, error: null })
})
})
};
3. Use Real Data Structures
// ❌ Incomplete mocks
const mockUser = { id: '123' };
// ✅ Realistic test data
const mockUser: Tables<'users'> = {
id: '123',
email: 'test@example.com',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
// ... all required fields
};
4. Test Error Scenarios
test('shows error message when note creation fails', async () => {
// Simulate API failure
server.use(
http.post('/api/notes', () => {
return HttpResponse.json(
{ error: 'Insufficient permissions' },
{ status: 403 }
);
})
);
render(<CreateNoteForm />);
await userEvent.type(screen.getByLabelText('Title'), 'Test Note');
await userEvent.click(screen.getByText('Create'));
expect(await screen.findByText('Insufficient permissions')).toBeInTheDocument();
});
Testing by Feature Type
Server Components
// Test data fetching and error handling
test('NotesPage displays user notes', async () => {
const { container } = await render(
await NotesPage({ params: { userId: '123' } })
);
expect(container).toHaveTextContent('My Notes');
});
Server Actions
test('createNoteAction validates input and creates note', async () => {
const result = await createNoteAction({
title: 'Test',
content: 'Content'
});
expect(result.success).toBe(true);
expect(result.data.id).toBeDefined();
});
Client Components with Hooks
test('InteractiveNotes allows filtering', async () => {
render(<InteractiveNotes />);
const searchInput = screen.getByPlaceholderText('Search notes...');
await userEvent.type(searchInput, 'important');
expect(screen.getByText('Important Note')).toBeInTheDocument();
expect(screen.queryByText('Regular Note')).not.toBeInTheDocument();
});
Test Organization
Test Utilities
Create reusable test utilities:
// test-utils/render.tsx
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions
) {
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
options
);
}
// test-utils/factories.ts
export const createMockAccount = (
overrides?: Partial<Tables<'accounts'>>
): Tables<'accounts'> => ({
id: faker.string.uuid(),
name: faker.company.name(),
created_at: faker.date.past().toISOString(),
// ... defaults with overrides
});
Continuous Integration
Test Execution Strategy
# Run tests in parallel by type
test:
parallel:
matrix:
type: [unit, integration, e2e]
script:
- pnpm test:${{ matrix.type }}
Performance Optimization
- Parallel execution within test suites
- Shared test database for integration tests
- Selective test runs based on changed files
- Test result caching for unchanged code
Anti-Patterns to Avoid
1. Testing Framework Behavior
// ❌ Don't test React itself
test('useState updates state', () => {
// React's job, not yours
});
2. Snapshot Overuse
// ❌ Brittle snapshots
expect(component).toMatchSnapshot();
// ✅ Specific assertions
expect(screen.getByRole('heading')).toHaveTextContent('Dashboard');
3. Testing Private Functions
// ❌ Don't export just for testing
export const _privateHelper = () => {}; // exported for testing
// ✅ Test through public API
test('public method uses helper correctly', () => {
// Test the behavior, not the helper
});
4. Waiting for Arbitrary Timeouts
// ❌ Flaky time-based waits
await new Promise(resolve => setTimeout(resolve, 1000));
// ✅ Wait for specific conditions
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
Decision Framework
When writing a test, ask:
- What would break if this fails? - User can't create notes? App crashes?
- Is this testing behavior or implementation? - Can I refactor without changing tests?
- Does this increase confidence? - Will this catch real bugs?
- Is the cost worth it? - Maintenance vs. value
If the answer to any of these suggests low value, reconsider the test.
Metrics That Matter
Track these instead of just coverage percentage:
- Bug escape rate - Bugs found in production vs. testing
- Test flakiness - Tests that fail intermittently
- Test runtime - Fast feedback loops
- Refactoring friction - How often tests break during refactoring
Remember: Quality over quantity. One good integration test is worth ten unit tests of implementation details.