11 KiB
11 KiB
Testing Best Practices
Patterns and utilities for effective React and TanStack testing.
Custom Render Functions
Basic Custom Render
// src/test/test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
}
export function renderWithQuery(
ui: ReactElement,
options?: CustomRenderOptions
) {
const queryClient = options?.queryClient ?? new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
options
);
}
Complete Provider Wrapper
// src/test/test-utils.tsx
import { RouterProvider, createRouter, createMemoryHistory } from '@tanstack/react-router';
import { AuthProvider } from '../contexts/AuthContext';
interface AllProvidersOptions {
queryClient?: QueryClient;
initialRoute?: string;
authContext?: {
isAuthenticated: boolean;
user?: any;
};
}
export function createWrapper(options: AllProvidersOptions = {}) {
const queryClient = options.queryClient ?? createTestQueryClient();
const history = createMemoryHistory({
initialEntries: [options.initialRoute || '/'],
});
const router = createRouter({ routeTree, history });
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider value={options.authContext}>
<RouterProvider router={router}>
{children}
</RouterProvider>
</AuthProvider>
</QueryClientProvider>
);
};
}
export function renderWithAllProviders(
ui: ReactElement,
options?: AllProvidersOptions & RenderOptions
) {
const Wrapper = createWrapper(options);
return render(ui, { wrapper: Wrapper, ...options });
}
Mock Data Factories
User Factory
// src/test/factories/userFactory.ts
import { faker } from '@faker-js/faker';
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: string;
}
export function createMockUser(overrides?: Partial<User>): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
role: 'user',
createdAt: faker.date.past().toISOString(),
...overrides,
};
}
export function createMockUsers(count: number, overrides?: Partial<User>): User[] {
return Array.from({ length: count }, () => createMockUser(overrides));
}
// Usage in tests
it('displays user list', () => {
const users = createMockUsers(5);
render(<UserList users={users} />);
// ...
});
Query Data Factory
// src/test/factories/queryDataFactory.ts
import { QueryClient } from '@tanstack/react-query';
export function createQueryClientWithData(queryKey: any[], data: any) {
const queryClient = createTestQueryClient();
queryClient.setQueryData(queryKey, data);
return queryClient;
}
// Usage in tests
it('shows cached users', () => {
const users = createMockUsers(3);
const queryClient = createQueryClientWithData(['users'], users);
renderWithQuery(<UserList />, { queryClient });
// Users are already in cache, no loading state
});
Test Organization
File Structure
src/
├── components/
│ ├── UserList/
│ │ ├── UserList.tsx
│ │ ├── UserList.test.tsx
│ │ └── index.ts
├── hooks/
│ ├── useUsers.ts
│ ├── useUsers.test.ts
└── test/
├── setup.ts
├── test-utils.tsx
├── factories/
│ ├── userFactory.ts
│ └── postFactory.ts
└── msw/
├── server.ts
└── handlers.ts
Test File Patterns
// Component test pattern
describe('UserList', () => {
// Group by functionality
describe('rendering', () => {
it('displays all users', () => {});
it('shows empty state when no users', () => {});
});
describe('interactions', () => {
it('navigates to user detail on click', async () => {});
it('deletes user on delete button click', async () => {});
});
describe('loading states', () => {
it('shows skeleton while loading', () => {});
it('shows error message on failure', async () => {});
});
});
MSW Best Practices
Handler Organization
// src/test/msw/handlers/users.ts
import { http, HttpResponse } from 'msw';
export const userHandlers = [
http.get('/api/users', () => {
return HttpResponse.json([/* default users */]);
}),
];
// src/test/msw/handlers/auth.ts
export const authHandlers = [
http.post('/api/auth/login', async ({ request }) => {
// Auth logic
}),
];
// src/test/msw/handlers/index.ts
import { userHandlers } from './users';
import { authHandlers } from './auth';
export const handlers = [...userHandlers, ...authHandlers];
Dynamic Handlers
// src/test/msw/handlers/users.ts
let mockUsers = [/* default users */];
export const userHandlers = [
http.get('/api/users', () => {
return HttpResponse.json(mockUsers);
}),
http.post('/api/users', async ({ request }) => {
const newUser = await request.json();
mockUsers = [...mockUsers, newUser];
return HttpResponse.json(newUser, { status: 201 });
}),
];
// Reset between tests
export function resetMockUsers() {
mockUsers = [/* default users */];
}
// In setup.ts
afterEach(() => {
resetMockUsers();
});
Testing Hooks
renderHook with Providers
// src/test/test-utils.tsx
import { renderHook, RenderHookOptions } from '@testing-library/react';
export function renderHookWithQuery<TProps, TResult>(
hook: (props: TProps) => TResult,
options?: RenderHookOptions<TProps> & { queryClient?: QueryClient }
) {
const queryClient = options?.queryClient ?? createTestQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return renderHook(hook, { wrapper, ...options });
}
// Usage
it('fetches users', async () => {
const { result } = renderHookWithQuery(() => useUsers());
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(3);
});
Async Testing Patterns
Using waitFor
import { waitFor } from '@testing-library/react';
it('loads data asynchronously', async () => {
render(<AsyncComponent />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
});
Using findBy Queries
it('displays user after loading', async () => {
render(<UserProfile userId="123" />);
// findBy automatically waits (up to 1000ms by default)
const userName = await screen.findByText('Alice Johnson');
expect(userName).toBeInTheDocument();
});
Testing User Interactions
userEvent Setup
import userEvent from '@testing-library/user-event';
it('handles user input', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByText('Welcome')).toBeInTheDocument();
});
Keyboard Navigation
it('navigates form with keyboard', async () => {
const user = userEvent.setup();
render(<Form />);
await user.tab(); // Focus first field
await user.keyboard('Alice');
await user.tab(); // Move to next field
await user.keyboard('alice@example.com');
await user.keyboard('{Enter}'); // Submit form
});
Testing Accessibility
Query by Role
it('has accessible structure', () => {
render(<UserList users={mockUsers} />);
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(screen.getByRole('button', { name: 'Add User' })).toBeInTheDocument();
});
Aria Labels
it('has proper aria labels', () => {
render(<DeleteButton onDelete={mockDelete} />);
const button = screen.getByRole('button', { name: 'Delete user' });
expect(button).toHaveAttribute('aria-label', 'Delete user');
});
Performance Testing
Test Rendering Performance
it('renders large list efficiently', () => {
const users = createMockUsers(1000);
const start = performance.now();
render(<VirtualizedUserList users={users} />);
const duration = performance.now() - start;
expect(duration).toBeLessThan(100); // Should render in <100ms
});
Test Query Performance
it('avoids N+1 queries', () => {
const spy = vi.spyOn(window, 'fetch');
render(<UsersWithPosts />);
waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1); // Single query with join
});
});
Snapshot Testing
Component Snapshot
it('matches snapshot', () => {
const { container } = render(<UserCard user={mockUser} />);
expect(container).toMatchSnapshot();
});
Inline Snapshot
it('renders correct HTML', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toMatchInlineSnapshot(`
<button>
Click me
</button>
`);
});
Coverage Exclusions
Exclude from Coverage
/* v8 ignore start */
if (process.env.NODE_ENV === 'development') {
// Dev-only code excluded from coverage
}
/* v8 ignore stop */
Exclude Test Files
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
exclude: [
'src/test/**',
'**/*.test.{ts,tsx}',
'**/*.spec.{ts,tsx}',
'**/mockData.ts',
],
},
},
});
CI/CD Best Practices
Parallel Testing
# Run tests in parallel
vitest --threads --maxThreads=4
# Run specific tests
vitest src/components/UserList
Coverage Enforcement
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
// Fail CI if below thresholds
reporter: ['text', 'json-summary'],
},
},
});
Key Takeaways
- Custom Utilities: Create reusable render functions with all providers
- Mock Factories: Use faker for consistent test data
- MSW Organization: Group handlers by domain (users, auth, posts)
- Async Testing: Prefer
findByandwaitForfor async operations - Accessibility: Always query by role first
- Coverage: Aim for 80%+ with meaningful tests
Next: Server Components Testing | Previous: Testing Setup