Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:29:30 +08:00
commit 40d73f6839
33 changed files with 8109 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
# React TanStack Testing Skill
Specialized testing for React applications using TanStack ecosystem (Query, Router, Table, Form) with Vite and Vitest.
## Description
Comprehensive testing patterns for modern React with TanStack libraries including server state management, routing, and data handling.
## What's Included
- **Examples**: TanStack Query tests, Router tests, Form validation
- **Reference**: Testing patterns, mocking strategies
- **Templates**: Test suites for TanStack components
- **Checklists**: React testing best practices
## Use When
- Testing React + TanStack Query applications
- Testing complex routing with TanStack Router
- Validating server state management
## Related Agents
- `react-tanstack-tester`
- `test-generator`
**Skill Version**: 1.0

View File

@@ -0,0 +1,90 @@
# React TanStack Testing Examples
Complete testing examples for React applications using TanStack ecosystem libraries.
## Examples Overview
### TanStack Query Testing
**File**: [tanstack-query-testing.md](tanstack-query-testing.md)
Testing server state management with TanStack Query:
- Query hooks (loading, success, error states)
- Mutation hooks with optimistic updates
- Query invalidation and cache management
- Infinite queries and pagination
- Prefetching and cache warming
**Use when**: Testing components that fetch data, manage server state, or use caching.
---
### TanStack Router Testing
**File**: [tanstack-router-testing.md](tanstack-router-testing.md)
Testing routing and navigation with TanStack Router:
- Route navigation and programmatic routing
- Route parameters and search params
- Protected routes and authentication
- Route loaders and actions
- Nested routes and layouts
**Use when**: Testing navigation, route-based data fetching, or protected pages.
---
### TanStack Table Testing
**File**: [tanstack-table-testing.md](tanstack-table-testing.md)
Testing data tables with TanStack Table:
- Column rendering and data display
- Sorting (ascending, descending, none)
- Filtering (global and column-specific)
- Pagination and page size changes
- Row selection (single and multiple)
**Use when**: Testing data grids, tables with sorting/filtering, or complex data displays.
---
### TanStack Form Testing
**File**: [tanstack-form-testing.md](tanstack-form-testing.md)
Testing forms with TanStack Form:
- Field validation (required, email, min/max)
- Form submission with valid/invalid data
- Validation error display
- Field states (touched, dirty, pristine)
- Schema validation with Zod
**Use when**: Testing forms with validation, complex field interactions, or submission handling.
---
## Quick Reference
| Library | Primary Use Case | Key Test Patterns |
|---------|-----------------|-------------------|
| **Query** | Data fetching | Loading states, mutations, cache |
| **Router** | Navigation | Routes, params, loaders, protected |
| **Table** | Data display | Sorting, filtering, pagination |
| **Form** | User input | Validation, submission, errors |
## Testing Tools
All examples use:
- **Vitest** - Fast unit test runner
- **React Testing Library** - User-centric testing
- **MSW** - API mocking
- **@testing-library/user-event** - User interactions
## Best Practices
1. **Test user behavior** - Focus on what users see and do
2. **Mock API calls** - Use MSW for realistic network mocking
3. **Test all states** - Loading, success, error, empty
4. **Use proper queries** - Prefer `getByRole` over `getByTestId`
5. **Async utilities** - Use `waitFor`, `findBy` for async operations
---
Return to [main agent](../react-tanstack-tester.md)

View File

@@ -0,0 +1,488 @@
# TanStack Form Testing Examples
Complete examples for testing TanStack Form validation, submission, and field states.
## Test Setup
### Form Dependencies
```typescript
// src/test/form-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
export function renderForm(ui: ReactElement, options?: RenderOptions) {
return render(ui, options);
}
```
## Example 1: Basic Form with Validation
### Form Component
```typescript
// src/components/UserForm.tsx
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18 years old'),
});
export function UserForm({ onSubmit }: { onSubmit: (data: any) => void }) {
const form = useForm({
defaultValues: {
name: '',
email: '',
age: 0,
},
onSubmit: async ({ value }) => {
onSubmit(value);
},
validatorAdapter: zodValidator,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<div>
<form.Field
name="name"
validators={{
onChange: userSchema.shape.name,
}}
children={(field) => (
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.length > 0 && (
<span role="alert">{field.state.meta.errors[0]}</span>
)}
</div>
)}
/>
</div>
<div>
<form.Field
name="email"
validators={{
onChange: userSchema.shape.email,
}}
children={(field) => (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.length > 0 && (
<span role="alert">{field.state.meta.errors[0]}</span>
)}
</div>
)}
/>
</div>
<button type="submit">Submit</button>
</form>
);
}
```
### Test Suite
```typescript
// src/components/UserForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserForm } from './UserForm';
describe('UserForm', () => {
it('renders all form fields', () => {
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
});
it('shows validation error for short name', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Name'), 'A');
await user.tab(); // Trigger blur/validation
expect(await screen.findByRole('alert')).toHaveTextContent('Name must be at least 2 characters');
});
it('shows validation error for invalid email', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'invalid-email');
await user.tab();
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email address');
});
it('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Name'), 'Alice Johnson');
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(onSubmit).toHaveBeenCalledWith({
name: 'Alice Johnson',
email: 'alice@example.com',
age: 0,
});
});
it('does not submit form with invalid data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Name'), 'A');
await user.type(screen.getByLabelText('Email'), 'invalid');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(onSubmit).not.toHaveBeenCalled();
});
});
```
## Example 2: Testing Field States
### Form with Field State Display
```typescript
// src/components/FieldStateForm.tsx
import { useForm } from '@tanstack/react-form';
export function FieldStateForm() {
const form = useForm({
defaultValues: {
username: '',
},
});
return (
<form>
<form.Field
name="username"
children={(field) => (
<div>
<label htmlFor="username">Username</label>
<input
id="username"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
<div data-testid="field-states">
<span data-testid="is-touched">{field.state.meta.isTouched ? 'Touched' : 'Untouched'}</span>
<span data-testid="is-dirty">{field.state.meta.isDirty ? 'Dirty' : 'Pristine'}</span>
<span data-testid="is-valid">{field.state.meta.errors.length === 0 ? 'Valid' : 'Invalid'}</span>
</div>
</div>
)}
/>
</form>
);
}
```
### Test Suite
```typescript
// src/components/FieldStateForm.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FieldStateForm } from './FieldStateForm';
describe('FieldStateForm', () => {
it('field starts untouched and pristine', () => {
render(<FieldStateForm />);
expect(screen.getByTestId('is-touched')).toHaveTextContent('Untouched');
expect(screen.getByTestId('is-dirty')).toHaveTextContent('Pristine');
});
it('field becomes touched after blur', async () => {
const user = userEvent.setup();
render(<FieldStateForm />);
const input = screen.getByLabelText('Username');
await user.click(input);
await user.tab(); // Blur the field
expect(screen.getByTestId('is-touched')).toHaveTextContent('Touched');
});
it('field becomes dirty after value change', async () => {
const user = userEvent.setup();
render(<FieldStateForm />);
await user.type(screen.getByLabelText('Username'), 'alice');
expect(screen.getByTestId('is-dirty')).toHaveTextContent('Dirty');
});
});
```
## Example 3: Testing Async Validation
### Form with Async Validation
```typescript
// src/components/UsernameForm.tsx
import { useForm } from '@tanstack/react-form';
async function checkUsernameAvailable(username: string): Promise<boolean> {
const response = await fetch(`/api/check-username?username=${username}`);
return response.json();
}
export function UsernameForm({ onSubmit }: { onSubmit: (data: any) => void }) {
const form = useForm({
defaultValues: {
username: '',
},
onSubmit: async ({ value }) => {
onSubmit(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="username"
validators={{
onChangeAsync: async ({ value }) => {
if (value.length < 3) {
return 'Username must be at least 3 characters';
}
const isAvailable = await checkUsernameAvailable(value);
if (!isAvailable) {
return 'Username already taken';
}
return undefined;
},
}}
children={(field) => (
<div>
<label htmlFor="username">Username</label>
<input
id="username"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && (
<span data-testid="validating">Checking availability...</span>
)}
{field.state.meta.errors.length > 0 && (
<span role="alert">{field.state.meta.errors[0]}</span>
)}
</div>
)}
/>
<button type="submit">Submit</button>
</form>
);
}
```
### Test Suite
```typescript
// src/components/UsernameForm.test.tsx
import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from 'vitest';
import { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { UsernameForm } from './UsernameForm';
const server = setupServer(
http.get('/api/check-username', ({ request }) => {
const url = new URL(request.url);
const username = url.searchParams.get('username');
return HttpResponse.json(username !== 'taken');
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UsernameForm', () => {
it('shows validating indicator during async validation', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UsernameForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Username'), 'alice');
expect(screen.getByTestId('validating')).toHaveTextContent('Checking availability...');
});
it('accepts available username', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UsernameForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Username'), 'available');
await waitFor(() => {
expect(screen.queryByTestId('validating')).not.toBeInTheDocument();
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('rejects taken username', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UsernameForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Username'), 'taken');
expect(await screen.findByRole('alert')).toHaveTextContent('Username already taken');
});
});
```
## Example 4: Testing Form Reset
### Form with Reset Button
```typescript
// src/components/ResettableForm.tsx
import { useForm } from '@tanstack/react-form';
export function ResettableForm() {
const form = useForm({
defaultValues: {
name: '',
email: '',
},
});
return (
<form>
<div>
<label htmlFor="name">Name</label>
<form.Field
name="name"
children={(field) => (
<input
id="name"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
</div>
<div>
<label htmlFor="email">Email</label>
<form.Field
name="email"
children={(field) => (
<input
id="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
</div>
<button type="button" onClick={() => form.reset()}>
Reset
</button>
</form>
);
}
```
### Test Suite
```typescript
// src/components/ResettableForm.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ResettableForm } from './ResettableForm';
describe('ResettableForm', () => {
it('resets form fields to default values', async () => {
const user = userEvent.setup();
render(<ResettableForm />);
const nameInput = screen.getByLabelText('Name') as HTMLInputElement;
const emailInput = screen.getByLabelText('Email') as HTMLInputElement;
// Fill in form
await user.type(nameInput, 'Alice');
await user.type(emailInput, 'alice@example.com');
expect(nameInput.value).toBe('Alice');
expect(emailInput.value).toBe('alice@example.com');
// Reset form
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(nameInput.value).toBe('');
expect(emailInput.value).toBe('');
});
});
```
## Key Takeaways
1. **Zod Validation**: Use `zodValidator` for type-safe schema validation
2. **Field States**: Test touched, dirty, pristine, and valid states
3. **Async Validation**: Use MSW to mock async validation endpoints
4. **Error Display**: Test that validation errors appear correctly
5. **Submission**: Test form submission with both valid and invalid data
6. **Reset**: Test form reset functionality clears all fields
---
**Previous**: [Table Testing](tanstack-table-testing.md) | **Index**: [Examples Index](INDEX.md)

View File

@@ -0,0 +1,475 @@
# TanStack Query Testing Examples
Complete examples for testing TanStack Query (React Query) hooks and components.
## Test Setup
### QueryClient Configuration
```typescript
// src/test/query-client.ts
import { QueryClient } from '@tanstack/react-query';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: 0, // No garbage collection
staleTime: 0, // Always stale
},
mutations: {
retry: false,
},
},
});
}
```
### Custom Render with QueryClientProvider
```typescript
// src/test/test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { createTestQueryClient } from './query-client';
export function renderWithQuery(
ui: ReactElement,
options?: RenderOptions
) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
options
);
}
```
## Example 1: Testing Query Hooks
### Hook Under Test
```typescript
// src/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch users');
return response.json() as Promise<User[]>;
},
});
}
```
### Test Suite
```typescript
// src/hooks/useUsers.test.ts
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createTestQueryClient } from '../test/query-client';
import { useUsers } from './useUsers';
// Mock API server
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('useUsers', () => {
it('fetches users successfully', async () => {
const queryClient = createTestQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const { result } = renderHook(() => useUsers(), { wrapper });
// Initially loading
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
// Wait for success
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]);
});
});
```
## Example 2: Testing Mutation Hooks
### Hook Under Test
```typescript
// src/hooks/useCreateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserInput {
name: string;
email: string;
}
interface User extends CreateUserInput {
id: string;
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateUserInput) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Failed to create user');
return response.json() as Promise<User>;
},
onSuccess: () => {
// Invalidate users query to refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
```
### Test Suite
```typescript
// src/hooks/useCreateUser.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createTestQueryClient } from '../test/query-client';
import { useCreateUser } from './useCreateUser';
const server = setupServer(
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: '3',
...body,
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('useCreateUser', () => {
it('creates user successfully', async () => {
const queryClient = createTestQueryClient();
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const { result } = renderHook(() => useCreateUser(), { wrapper });
// Initially idle
expect(result.current.isPending).toBe(false);
// Trigger mutation
result.current.mutate({
name: 'Charlie',
email: 'charlie@example.com',
});
// Wait for success
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
id: '3',
name: 'Charlie',
email: 'charlie@example.com',
});
// Verify cache invalidation
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['users'] });
});
});
```
## Example 3: Testing Components with Queries
### Component Under Test
```typescript
// src/components/UserList.tsx
import { useUsers } from '../hooks/useUsers';
export function UserList() {
const { data: users, isLoading, error } = useUsers();
if (isLoading) {
return <div data-testid="loading">Loading users...</div>;
}
if (error) {
return <div role="alert">Error: {error.message}</div>;
}
if (!users || users.length === 0) {
return <div data-testid="empty">No users found</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}
```
### Test Suite
```typescript
// src/components/UserList.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { renderWithQuery } from '../test/test-utils';
import { UserList } from './UserList';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList', () => {
it('displays users after loading', async () => {
renderWithQuery(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice - alice@example.com')).toBeInTheDocument();
});
expect(screen.getByText('Bob - bob@example.com')).toBeInTheDocument();
});
});
```
## Example 4: Testing Optimistic Updates
### Hook with Optimistic Update
```typescript
// src/hooks/useUpdateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (user: User) => {
const response = await fetch(`/api/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!response.ok) throw new Error('Failed to update user');
return response.json() as Promise<User>;
},
onMutate: async (updatedUser) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot previous value
const previousUsers = queryClient.getQueryData<User[]>(['users']);
// Optimistically update
if (previousUsers) {
queryClient.setQueryData<User[]>(
['users'],
previousUsers.map((user) =>
user.id === updatedUser.id ? updatedUser : user
)
);
}
// Return context for rollback
return { previousUsers };
},
onError: (_error, _variables, context) => {
// Rollback on error
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
```
### Test Suite
```typescript
// src/hooks/useUpdateUser.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createTestQueryClient } from '../test/query-client';
import { useUpdateUser } from './useUpdateUser';
const server = setupServer(
http.put('/api/users/:id', async ({ request, params }) => {
const body = await request.json();
return HttpResponse.json({ ...body, id: params.id });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('useUpdateUser', () => {
it('applies optimistic update immediately', async () => {
const queryClient = createTestQueryClient();
// Pre-populate cache
queryClient.setQueryData(['users'], [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const { result } = renderHook(() => useUpdateUser(), { wrapper });
// Trigger mutation
result.current.mutate({
id: '1',
name: 'Alice Updated',
email: 'alice.updated@example.com',
});
// Immediately check optimistic update
const cachedUsers = queryClient.getQueryData(['users']);
expect(cachedUsers).toEqual([
{ id: '1', name: 'Alice Updated', email: 'alice.updated@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]);
// Wait for mutation to complete
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it('rolls back on mutation failure', async () => {
server.use(
http.put('/api/users/:id', () => {
return new HttpResponse(null, { status: 500 });
})
);
const queryClient = createTestQueryClient();
// Pre-populate cache
const originalUsers = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
];
queryClient.setQueryData(['users'], originalUsers);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const { result } = renderHook(() => useUpdateUser(), { wrapper });
// Trigger mutation
result.current.mutate({
id: '1',
name: 'Alice Updated',
email: 'alice.updated@example.com',
});
// Wait for error
await waitFor(() => expect(result.current.isError).toBe(true));
// Verify rollback
const cachedUsers = queryClient.getQueryData(['users']);
expect(cachedUsers).toEqual(originalUsers);
});
});
```
## Key Takeaways
1. **Test QueryClient Setup**: Always create test-specific QueryClient with retries disabled
2. **MSW for Mocking**: Use MSW to mock API endpoints realistically
3. **Test All States**: Loading, success, error, and empty states
4. **Optimistic Updates**: Test immediate UI changes and rollback on failure
5. **Cache Invalidation**: Verify queries are invalidated after mutations
---
**Next**: [TanStack Router Testing](tanstack-router-testing.md) | **Index**: [Examples Index](INDEX.md)

View File

@@ -0,0 +1,420 @@
# TanStack Router Testing Examples
Complete examples for testing TanStack Router navigation, routes, and data loading.
## Test Setup
### Router Test Configuration
```typescript
// src/test/router-utils.tsx
import { ReactElement } from 'react';
import { render } from '@testing-library/react';
import { createMemoryHistory, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
export function renderWithRouter(
ui: ReactElement,
{ initialEntries = ['/'] } = {}
) {
const rootRoute = createRootRoute({
component: () => ui,
});
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries }),
});
return render(<RouterProvider router={router} />);
}
```
## Example 1: Testing Route Navigation
### Component with Navigation
```typescript
// src/components/Navigation.tsx
import { Link } from '@tanstack/react-router';
export function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
);
}
```
### Test Suite
```typescript
// src/components/Navigation.test.tsx
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRootRoute, createRoute, createRouter, RouterProvider } from '@tanstack/react-router';
import { render } from '@testing-library/react';
import { Navigation } from './Navigation';
function Home() {
return <div>Home Page</div>;
}
function About() {
return <div>About Page</div>;
}
function Users() {
return <div>Users Page</div>;
}
describe('Navigation', () => {
it('navigates between routes', async () => {
const user = userEvent.setup();
// Create routes
const rootRoute = createRootRoute({
component: () => (
<>
<Navigation />
<div id="content" />
</>
),
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Home,
});
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: About,
});
const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users',
component: Users,
});
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute, usersRoute]);
const router = createRouter({ routeTree });
render(<RouterProvider router={router} />);
// Initially on home
expect(screen.getByText('Home Page')).toBeInTheDocument();
// Navigate to About
await user.click(screen.getByRole('link', { name: /about/i }));
expect(await screen.findByText('About Page')).toBeInTheDocument();
// Navigate to Users
await user.click(screen.getByRole('link', { name: /users/i }));
expect(await screen.findByText('Users Page')).toBeInTheDocument();
});
});
```
## Example 2: Testing Route Parameters
### Component Using Route Params
```typescript
// src/pages/UserProfile.tsx
import { useParams } from '@tanstack/react-router';
export function UserProfile() {
const { userId } = useParams({ from: '/users/$userId' });
return (
<div>
<h1>User Profile</h1>
<p data-testid="user-id">User ID: {userId}</p>
</div>
);
}
```
### Test Suite
```typescript
// src/pages/UserProfile.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import { createRootRoute, createRoute, createRouter, RouterProvider } from '@tanstack/react-router';
import { createMemoryHistory } from '@tanstack/react-router';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
it('displays correct user ID from route params', () => {
const rootRoute = createRootRoute();
const userRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users/$userId',
component: UserProfile,
});
const routeTree = rootRoute.addChildren([userRoute]);
const history = createMemoryHistory({ initialEntries: ['/users/123'] });
const router = createRouter({ routeTree, history });
render(<RouterProvider router={router} />);
expect(screen.getByTestId('user-id')).toHaveTextContent('User ID: 123');
});
it('updates when route params change', () => {
const rootRoute = createRootRoute();
const userRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/users/$userId',
component: UserProfile,
});
const routeTree = rootRoute.addChildren([userRoute]);
const history = createMemoryHistory({ initialEntries: ['/users/123'] });
const router = createRouter({ routeTree, history });
const { rerender } = render(<RouterProvider router={router} />);
expect(screen.getByTestId('user-id')).toHaveTextContent('User ID: 123');
// Navigate to different user
history.push('/users/456');
rerender(<RouterProvider router={router} />);
expect(screen.getByTestId('user-id')).toHaveTextContent('User ID: 456');
});
});
```
## Example 3: Testing Protected Routes
### Protected Route Component
```typescript
// src/components/ProtectedRoute.tsx
import { useAuth } from '../hooks/useAuth';
import { Navigate } from '@tanstack/react-router';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <>{children}</>;
}
```
### Test Suite
```typescript
// src/components/ProtectedRoute.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { screen, render } from '@testing-library/react';
import { createRootRoute, createRoute, createRouter, RouterProvider } from '@tanstack/react-router';
import { ProtectedRoute } from './ProtectedRoute';
import * as useAuthModule from '../hooks/useAuth';
vi.mock('../hooks/useAuth');
function Dashboard() {
return <div>Dashboard</div>;
}
function Login() {
return <div>Login Page</div>;
}
describe('ProtectedRoute', () => {
it('renders children when authenticated', () => {
vi.mocked(useAuthModule.useAuth).mockReturnValue({
isAuthenticated: true,
user: { id: '1', name: 'Alice' },
login: vi.fn(),
logout: vi.fn(),
});
const rootRoute = createRootRoute();
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: () => (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
),
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: Login,
});
const routeTree = rootRoute.addChildren([dashboardRoute, loginRoute]);
const router = createRouter({ routeTree });
render(<RouterProvider router={router} />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('redirects to login when not authenticated', async () => {
vi.mocked(useAuthModule.useAuth).mockReturnValue({
isAuthenticated: false,
user: null,
login: vi.fn(),
logout: vi.fn(),
});
const rootRoute = createRootRoute();
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: () => (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
),
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: Login,
});
const routeTree = rootRoute.addChildren([dashboardRoute, loginRoute]);
const router = createRouter({ routeTree });
render(<RouterProvider router={router} />);
// Should redirect and show login
expect(await screen.findByText('Login Page')).toBeInTheDocument();
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
});
});
```
## Example 4: Testing Route Loaders
### Route with Loader
```typescript
// src/routes/user.tsx
import { createRoute } from '@tanstack/react-router';
interface User {
id: string;
name: string;
email: string;
}
export const userRoute = createRoute({
path: '/users/$userId',
loader: async ({ params }) => {
const response = await fetch(`/api/users/${params.userId}`);
if (!response.ok) throw new Error('User not found');
return response.json() as Promise<User>;
},
component: function UserPage({ useLoaderData }) {
const user = useLoaderData();
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
},
});
```
### Test Suite
```typescript
// src/routes/user.test.tsx
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { screen, render } from '@testing-library/react';
import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router';
import { createMemoryHistory } from '@tanstack/react-router';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { userRoute } from './user';
const server = setupServer(
http.get('/api/users/:userId', ({ params }) => {
return HttpResponse.json({
id: params.userId,
name: 'Alice Johnson',
email: 'alice@example.com',
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('userRoute', () => {
it('loads and displays user data', async () => {
const rootRoute = createRootRoute();
const routeTree = rootRoute.addChildren([userRoute]);
const history = createMemoryHistory({ initialEntries: ['/users/123'] });
const router = createRouter({ routeTree, history });
render(<RouterProvider router={router} />);
// Wait for loader to complete
expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
it('handles loader error', async () => {
server.use(
http.get('/api/users/:userId', () => {
return new HttpResponse(null, { status: 404 });
})
);
const rootRoute = createRootRoute();
const routeTree = rootRoute.addChildren([userRoute]);
const history = createMemoryHistory({ initialEntries: ['/users/999'] });
const router = createRouter({ routeTree, history });
render(<RouterProvider router={router} />);
// Should show error (TanStack Router handles this automatically)
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
```
## Key Takeaways
1. **Memory History**: Use `createMemoryHistory` for controlled navigation in tests
2. **Route Setup**: Build complete route trees with `createRouter` for realistic tests
3. **Params Testing**: Pass `initialEntries` to test routes with params
4. **Protected Routes**: Mock authentication context to test access control
5. **Loaders**: Use MSW to mock loader data fetching
---
**Next**: [TanStack Table Testing](tanstack-table-testing.md) | **Previous**: [Query Testing](tanstack-query-testing.md)

View File

@@ -0,0 +1,496 @@
# TanStack Table Testing Examples
Complete examples for testing TanStack Table sorting, filtering, pagination, and selection.
## Test Setup
### Sample Data
```typescript
// src/test/table-data.ts
export interface User {
id: string;
name: string;
email: string;
role: string;
age: number;
}
export const mockUsers: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'Admin', age: 30 },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'User', age: 25 },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'User', age: 35 },
{ id: '4', name: 'Diana', email: 'diana@example.com', role: 'Admin', age: 28 },
];
```
## Example 1: Testing Table Rendering
### Basic Table Component
```typescript
// src/components/UserTable.tsx
import { useReactTable, getCoreRowModel, flexRender, ColumnDef } from '@tanstack/react-table';
import { User } from '../test/table-data';
interface UserTableProps {
data: User[];
}
export function UserTable({ data }: UserTableProps) {
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'role',
header: 'Role',
},
{
accessorKey: 'age',
header: 'Age',
},
];
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
```
### Test Suite
```typescript
// src/components/UserTable.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import { UserTable } from './UserTable';
import { mockUsers } from '../test/table-data';
describe('UserTable', () => {
it('renders table headers', () => {
render(<UserTable data={mockUsers} />);
expect(screen.getByRole('columnheader', { name: 'Name' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Email' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Role' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Age' })).toBeInTheDocument();
});
it('renders all user data', () => {
render(<UserTable data={mockUsers} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
});
it('renders correct number of rows', () => {
render(<UserTable data={mockUsers} />);
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(5); // 1 header + 4 data rows
});
});
```
## Example 2: Testing Sorting
### Table with Sorting
```typescript
// src/components/SortableTable.tsx
import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender, SortingState } from '@tanstack/react-table';
import { useState } from 'react';
export function SortableTable({ data, columns }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder ? null : (
<button
onClick={header.column.getToggleSortingHandler()}
aria-label={`Sort by ${header.column.id}`}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</button>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
```
### Test Suite
```typescript
// src/components/SortableTable.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SortableTable } from './SortableTable';
import { mockUsers } from '../test/table-data';
const columns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'age', header: 'Age' },
];
describe('SortableTable', () => {
it('sorts by name ascending', async () => {
const user = userEvent.setup();
render(<SortableTable data={mockUsers} columns={columns} />);
// Click name header to sort
await user.click(screen.getByRole('button', { name: 'Sort by name' }));
const rows = screen.getAllByRole('row');
const firstDataRow = rows[1]; // Skip header row
expect(within(firstDataRow).getByText('Alice')).toBeInTheDocument();
});
it('sorts by name descending on second click', async () => {
const user = userEvent.setup();
render(<SortableTable data={mockUsers} columns={columns} />);
const sortButton = screen.getByRole('button', { name: 'Sort by name' });
// First click: ascending
await user.click(sortButton);
// Second click: descending
await user.click(sortButton);
const rows = screen.getAllByRole('row');
const firstDataRow = rows[1];
expect(within(firstDataRow).getByText('Diana')).toBeInTheDocument();
});
it('sorts by age correctly', async () => {
const user = userEvent.setup();
render(<SortableTable data={mockUsers} columns={columns} />);
await user.click(screen.getByRole('button', { name: 'Sort by age' }));
const rows = screen.getAllByRole('row');
const ages = rows.slice(1).map((row) => parseInt(within(row).getAllByRole('cell')[1].textContent || '0'));
expect(ages).toEqual([25, 28, 30, 35]); // Sorted ascending
});
});
```
## Example 3: Testing Pagination
### Table with Pagination
```typescript
// src/components/PaginatedTable.tsx
import { useReactTable, getCoreRowModel, getPaginationRowModel, flexRender } from '@tanstack/react-table';
export function PaginatedTable({ data, columns }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 2,
},
},
});
return (
<div>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</button>
<span>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
</div>
</div>
);
}
```
### Test Suite
```typescript
// src/components/PaginatedTable.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PaginatedTable } from './PaginatedTable';
import { mockUsers } from '../test/table-data';
const columns = [
{ accessorKey: 'name', header: 'Name' },
];
describe('PaginatedTable', () => {
it('displays first page of results', () => {
render(<PaginatedTable data={mockUsers} columns={columns} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
});
it('navigates to next page', async () => {
const user = userEvent.setup();
render(<PaginatedTable data={mockUsers} columns={columns} />);
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
expect(screen.getByText('Diana')).toBeInTheDocument();
});
it('displays correct page number', async () => {
const user = userEvent.setup();
render(<PaginatedTable data={mockUsers} columns={columns} />);
expect(screen.getByText('Page 1 of 2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Page 2 of 2')).toBeInTheDocument();
});
it('disables previous button on first page', () => {
render(<PaginatedTable data={mockUsers} columns={columns} />);
expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled();
});
it('disables next button on last page', async () => {
const user = userEvent.setup();
render(<PaginatedTable data={mockUsers} columns={columns} />);
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
});
});
```
## Example 4: Testing Row Selection
### Table with Row Selection
```typescript
// src/components/SelectableTable.tsx
import { useReactTable, getCoreRowModel, flexRender, RowSelectionState } from '@tanstack/react-table';
import { useState } from 'react';
export function SelectableTable({ data, columns }) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable({
data,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
});
return (
<div>
<p data-testid="selected-count">
{Object.keys(rowSelection).length} selected
</p>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<th>
<input type="checkbox" checked={table.getIsAllRowsSelected()} onChange={table.getToggleAllRowsSelectedHandler()} aria-label="Select all rows" />
</th>
{headerGroup.headers.map((header) => (
<th key={header.id}>{flexRender(header.column.columnDef.header, header.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
aria-label={`Select row ${row.id}`}
/>
</td>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
```
### Test Suite
```typescript
// src/components/SelectableTable.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SelectableTable } from './SelectableTable';
import { mockUsers } from '../test/table-data';
const columns = [
{ accessorKey: 'name', header: 'Name' },
];
describe('SelectableTable', () => {
it('selects individual row', async () => {
const user = userEvent.setup();
render(<SelectableTable data={mockUsers} columns={columns} />);
await user.click(screen.getByRole('checkbox', { name: 'Select row 0' }));
expect(screen.getByTestId('selected-count')).toHaveTextContent('1 selected');
});
it('selects all rows', async () => {
const user = userEvent.setup();
render(<SelectableTable data={mockUsers} columns={columns} />);
await user.click(screen.getByRole('checkbox', { name: 'Select all rows' }));
expect(screen.getByTestId('selected-count')).toHaveTextContent('4 selected');
});
});
```
## Key Takeaways
1. **Core Setup**: Use `useReactTable` with appropriate row models (core, sorted, pagination)
2. **Sorting**: Test ascending, descending, and unsorted states
3. **Pagination**: Test navigation, disabled states, and page indicators
4. **Selection**: Test individual and bulk selection
5. **Accessibility**: Use proper ARIA labels for buttons and checkboxes
---
**Next**: [TanStack Form Testing](tanstack-form-testing.md) | **Previous**: [Router Testing](tanstack-router-testing.md)

View File

@@ -0,0 +1,91 @@
# React TanStack Testing Reference
Comprehensive reference materials for testing React applications with TanStack ecosystem.
## Reference Overview
### Testing Setup
**File**: [testing-setup.md](testing-setup.md)
Complete configuration for Vitest and test environment:
- Vitest configuration for React testing
- Test environment setup (jsdom, globals)
- React Testing Library configuration
- MSW (Mock Service Worker) setup
- Test file patterns and structure
**Use when**: Setting up a new project or configuring testing infrastructure.
---
### Testing Best Practices
**File**: [testing-best-practices.md](testing-best-practices.md)
Patterns and best practices for effective testing:
- Custom test utilities and render functions
- Provider wrappers (Query, Router, Auth)
- Mock data factories
- Test organization strategies
- Coverage requirements
**Use when**: Creating reusable test utilities or establishing testing standards.
---
### Server Components Testing
**File**: [server-components-testing.md](server-components-testing.md)
Testing React Server Components and async patterns:
- Async component testing
- Suspense boundaries
- Server actions
- Streaming rendering
- Error boundaries
**Use when**: Testing React 19 Server Components or async data fetching.
---
### Common Patterns
**File**: [common-patterns.md](common-patterns.md)
Frequently used testing patterns:
- Loading states and skeletons
- Error boundaries and fallbacks
- Infinite queries and scrolling
- Prefetching strategies
- Optimistic updates
**Use when**: Testing common UI patterns like loading states or error handling.
---
## Quick Reference
| Topic | Key Concepts |
|-------|-------------|
| **Setup** | Vitest config, jsdom, MSW, cleanup |
| **Best Practices** | Custom utils, providers, factories |
| **Server Components** | Async testing, Suspense, streaming |
| **Common Patterns** | Loading, errors, infinite, prefetch |
## Testing Tools Reference
### Vitest
- **Fast** - Native ESM, parallel tests
- **Compatible** - Jest-compatible API
- **Built-in** - Coverage, mocking, snapshots
### React Testing Library
- **User-centric** - Test what users see/do
- **Queries** - getBy, findBy, queryBy
- **Events** - userEvent for realistic interactions
### MSW (Mock Service Worker)
- **Realistic** - Intercepts actual network requests
- **Flexible** - Override handlers per test
- **Portable** - Works in Node and browser
---
Return to [main agent](../react-tanstack-tester.md)

View File

@@ -0,0 +1,494 @@
# Common Testing Patterns
Frequently used patterns for testing loading states, errors, infinite queries, and prefetching.
## Testing Loading States
### Skeleton Loader
```typescript
// src/components/UserListSkeleton.tsx
export function UserListSkeleton() {
return (
<div data-testid="skeleton">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar" />
<div className="skeleton-text" />
</div>
))}
</div>
);
}
// src/components/UserList.tsx
export function UserList() {
const { data: users, isLoading } = useUsers();
if (isLoading) {
return <UserListSkeleton />;
}
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
```
### Test Suite
```typescript
// src/components/UserList.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, render } from '@testing-library/react';
import { renderWithQuery } from '../test/test-utils';
import { UserList } from './UserList';
describe('UserList', () => {
it('shows skeleton loader while loading', () => {
renderWithQuery(<UserList />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(screen.getAllByClassName('skeleton-item')).toHaveLength(3);
});
it('hides skeleton after data loads', async () => {
renderWithQuery(<UserList />);
// Wait for data to load
await screen.findByText('Alice');
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
});
});
```
## Testing Error States
### Error Component
```typescript
// src/components/ErrorMessage.tsx
interface ErrorMessageProps {
error: Error;
onRetry?: () => void;
}
export function ErrorMessage({ error, onRetry }: ErrorMessageProps) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
{onRetry && <button onClick={onRetry}>Try Again</button>}
</div>
);
}
// src/components/UserList.tsx
export function UserList() {
const { data: users, isLoading, error, refetch } = useUsers();
if (error) {
return <ErrorMessage error={error} onRetry={() => refetch()} />;
}
// ...
}
```
### Test Suite
```typescript
// src/components/UserList.test.tsx
import { server } from '../test/msw/server';
import { http, HttpResponse } from 'msw';
describe('UserList', () => {
it('shows error message on fetch failure', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
renderWithQuery(<UserList />);
expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong');
});
it('retries on error retry button click', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
renderWithQuery(<UserList />);
await screen.findByRole('alert');
// Fix the error
server.use(
http.get('/api/users', () => {
return HttpResponse.json([{ id: '1', name: 'Alice' }]);
})
);
await user.click(screen.getByRole('button', { name: 'Try Again' }));
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
```
## Testing Infinite Queries
### Infinite Query Component
```typescript
// src/hooks/useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useInfiniteUsers() {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/users?page=${pageParam}&limit=10`);
return response.json();
},
getNextPageParam: (lastPage, pages) => {
return lastPage.hasMore ? pages.length : undefined;
},
initialPageParam: 0,
});
}
// src/components/InfiniteUserList.tsx
export function InfiniteUserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteUsers();
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
```
### Test Suite
```typescript
// src/components/InfiniteUserList.test.tsx
import { server } from '../test/msw/server';
import { http, HttpResponse } from 'msw';
beforeAll(() => {
const mockPages = [
{ users: [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }], hasMore: true },
{ users: [{ id: '3', name: 'Charlie' }, { id: '4', name: 'Diana' }], hasMore: true },
{ users: [{ id: '5', name: 'Eve' }], hasMore: false },
];
server.use(
http.get('/api/users', ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '0');
return HttpResponse.json(mockPages[page] || { users: [], hasMore: false });
})
);
});
describe('InfiniteUserList', () => {
it('loads first page initially', async () => {
renderWithQuery(<InfiniteUserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('loads next page on load more click', async () => {
const user = userEvent.setup();
renderWithQuery(<InfiniteUserList />);
await screen.findByText('Alice');
await user.click(screen.getByRole('button', { name: 'Load More' }));
expect(await screen.findByText('Charlie')).toBeInTheDocument();
expect(screen.getByText('Diana')).toBeInTheDocument();
});
it('hides load more button when no more pages', async () => {
const user = userEvent.setup();
renderWithQuery(<InfiniteUserList />);
await screen.findByText('Alice');
// Load page 2
await user.click(screen.getByRole('button', { name: 'Load More' }));
await screen.findByText('Charlie');
// Load page 3 (last page)
await user.click(screen.getByRole('button', { name: 'Load More' }));
await screen.findByText('Eve');
expect(screen.queryByRole('button', { name: 'Load More' })).not.toBeInTheDocument();
});
});
```
## Testing Intersection Observer (Infinite Scroll)
### Auto-loading Infinite List
```typescript
// src/components/AutoLoadingList.tsx
import { useRef, useEffect } from 'react';
export function AutoLoadingList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteUsers();
const observerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage]);
return (
<div>
{data?.pages.map((page) =>
page.users.map((user) => <div key={user.id}>{user.name}</div>)
)}
<div ref={observerRef} data-testid="load-more-trigger" />
</div>
);
}
```
### Test Suite
```typescript
// src/components/AutoLoadingList.test.tsx
describe('AutoLoadingList', () => {
it('loads next page when scrolling to bottom', async () => {
renderWithQuery(<AutoLoadingList />);
await screen.findByText('Alice');
// Simulate intersection observer trigger
const trigger = screen.getByTestId('load-more-trigger');
const observer = (window as any).IntersectionObserver.mock.calls[0][0];
// Trigger intersection
observer([{ isIntersecting: true, target: trigger }]);
// Wait for next page
expect(await screen.findByText('Charlie')).toBeInTheDocument();
});
});
```
## Testing Prefetching
### Hover Prefetch
```typescript
// src/components/UserCard.tsx
import { useQueryClient } from '@tanstack/react-query';
export function UserCard({ userId, name }: { userId: string; name: string }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
// Prefetch user details on hover
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
};
return (
<Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>
{name}
</Link>
);
}
```
### Test Suite
```typescript
// src/components/UserCard.test.tsx
describe('UserCard', () => {
it('prefetches user data on hover', async () => {
const user = userEvent.setup();
const queryClient = createTestQueryClient();
const prefetchSpy = vi.spyOn(queryClient, 'prefetchQuery');
renderWithQuery(<UserCard userId="123" name="Alice" />, { queryClient });
const link = screen.getByRole('link', { name: 'Alice' });
await user.hover(link);
expect(prefetchSpy).toHaveBeenCalledWith({
queryKey: ['user', '123'],
queryFn: expect.any(Function),
});
});
it('caches prefetched data', async () => {
const user = userEvent.setup();
const queryClient = createTestQueryClient();
renderWithQuery(<UserCard userId="123" name="Alice" />, { queryClient });
const link = screen.getByRole('link', { name: 'Alice' });
await user.hover(link);
// Wait for prefetch to complete
await waitFor(() => {
const cachedData = queryClient.getQueryData(['user', '123']);
expect(cachedData).toBeDefined();
});
});
});
```
## Testing Optimistic Updates
### Optimistic Delete
```typescript
// src/hooks/useDeleteUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userId: string) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Delete failed');
},
onMutate: async (userId) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot previous value
const previousUsers = queryClient.getQueryData(['users']);
// Optimistically remove user
queryClient.setQueryData(['users'], (old: any[]) =>
old.filter((user) => user.id !== userId)
);
return { previousUsers };
},
onError: (_error, _userId, context) => {
// Rollback on error
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
```
### Test Suite
```typescript
// src/hooks/useDeleteUser.test.tsx
describe('useDeleteUser', () => {
it('removes user optimistically', async () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(['users'], [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
const { result } = renderHookWithQuery(() => useDeleteUser(), { queryClient });
result.current.mutate('1');
// Immediately check optimistic update
const cachedUsers = queryClient.getQueryData(['users']);
expect(cachedUsers).toEqual([{ id: '2', name: 'Bob' }]);
});
it('rolls back on error', async () => {
server.use(
http.delete('/api/users/:id', () => {
return new HttpResponse(null, { status: 500 });
})
);
const queryClient = createTestQueryClient();
const originalUsers = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
];
queryClient.setQueryData(['users'], originalUsers);
const { result } = renderHookWithQuery(() => useDeleteUser(), { queryClient });
result.current.mutate('1');
await waitFor(() => expect(result.current.isError).toBe(true));
// Verify rollback
const cachedUsers = queryClient.getQueryData(['users']);
expect(cachedUsers).toEqual(originalUsers);
});
});
```
## Key Takeaways
1. **Loading States**: Always test skeleton loaders and spinners
2. **Error Handling**: Test error display and retry functionality
3. **Infinite Queries**: Test pagination, load more, and end of list
4. **Intersection Observer**: Mock IntersectionObserver for auto-loading
5. **Prefetching**: Test hover prefetch and cache population
6. **Optimistic Updates**: Test immediate UI updates and rollback on error
---
**Previous**: [Server Components](server-components-testing.md) | **Index**: [Reference Index](INDEX.md)

View File

@@ -0,0 +1,443 @@
# Server Components Testing
Testing patterns for React 19 Server Components, Suspense, and async rendering.
## Testing Async Server Components
### Basic Async Component
```typescript
// src/components/AsyncUserProfile.tsx
export async function AsyncUserProfile({ userId }: { userId: string }) {
// Fetch data in Server Component
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
```
### Test Suite
```typescript
// src/components/AsyncUserProfile.test.tsx
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { AsyncUserProfile } from './AsyncUserProfile';
const server = setupServer(
http.get('/api/users/:userId', ({ params }) => {
return HttpResponse.json({
id: params.userId,
name: 'Alice Johnson',
email: 'alice@example.com',
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('AsyncUserProfile', () => {
it('renders user data after loading', async () => {
// Render async component
const component = await AsyncUserProfile({ userId: '123' });
render(component);
expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
});
```
## Testing with Suspense Boundaries
### Component with Suspense
```typescript
// src/app/users/[userId]/page.tsx
import { Suspense } from 'react';
import { AsyncUserProfile } from '@/components/AsyncUserProfile';
export default function UserProfilePage({ params }: { params: { userId: string } }) {
return (
<Suspense fallback={<div>Loading user profile...</div>}>
<AsyncUserProfile userId={params.userId} />
</Suspense>
);
}
```
### Test Suite
```typescript
// src/app/users/[userId]/page.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import UserProfilePage from './page';
describe('UserProfilePage', () => {
it('shows loading state initially', () => {
render(<UserProfilePage params={{ userId: '123' }} />);
expect(screen.getByText('Loading user profile...')).toBeInTheDocument();
});
it('renders user profile after loading', async () => {
render(<UserProfilePage params={{ userId: '123' }} />);
// Wait for async component to resolve
expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
});
});
```
## Testing Streaming Rendering
### Streaming Component
```typescript
// src/components/StreamingUserList.tsx
export async function StreamingUserList() {
const response = await fetch('/api/users', {
// Enable streaming
next: { revalidate: 60 },
});
const users = await response.json();
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<Suspense fallback={<div>Loading {user.name}...</div>}>
<UserCard userId={user.id} />
</Suspense>
</li>
))}
</ul>
);
}
```
### Test Suite
```typescript
// src/components/StreamingUserList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StreamingUserList } from './StreamingUserList';
describe('StreamingUserList', () => {
it('progressively renders user cards', async () => {
const component = await StreamingUserList();
render(component);
// All users should eventually appear
expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
expect(await screen.findByText('Bob Smith')).toBeInTheDocument();
});
it('shows loading placeholders while streaming', () => {
render(<Suspense fallback={<div>Loading list...</div>}><StreamingUserList /></Suspense>);
expect(screen.getByText('Loading list...')).toBeInTheDocument();
});
});
```
## Testing Server Actions
### Server Action
```typescript
// src/actions/createUser.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
const user = await response.json();
// Revalidate users list
revalidatePath('/users');
return user;
}
```
### Test Suite
```typescript
// src/actions/createUser.test.ts
import { describe, it, expect, vi } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createUser } from './createUser';
// Mock revalidatePath
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));
const server = setupServer(
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: '123', ...body },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('createUser', () => {
it('creates user via server action', async () => {
const formData = new FormData();
formData.append('name', 'Charlie');
formData.append('email', 'charlie@example.com');
const user = await createUser(formData);
expect(user).toEqual({
id: '123',
name: 'Charlie',
email: 'charlie@example.com',
});
});
it('revalidates path after creation', async () => {
const { revalidatePath } = await import('next/cache');
const formData = new FormData();
formData.append('name', 'Diana');
formData.append('email', 'diana@example.com');
await createUser(formData);
expect(revalidatePath).toHaveBeenCalledWith('/users');
});
});
```
## Testing Form Actions
### Form with Server Action
```typescript
// src/components/UserForm.tsx
import { createUser } from '@/actions/createUser';
export function UserForm() {
return (
<form action={createUser}>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Create User</button>
</form>
);
}
```
### Test Suite
```typescript
// src/components/UserForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserForm } from './UserForm';
// Mock the server action
vi.mock('@/actions/createUser', () => ({
createUser: vi.fn(),
}));
describe('UserForm', () => {
it('submits form with server action', async () => {
const { createUser } = await import('@/actions/createUser');
const user = userEvent.setup();
render(<UserForm />);
await user.type(screen.getByLabelText('Name'), 'Alice');
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.click(screen.getByRole('button', { name: 'Create User' }));
expect(createUser).toHaveBeenCalledWith(expect.any(FormData));
const formData = (createUser as any).mock.calls[0][0] as FormData;
expect(formData.get('name')).toBe('Alice');
expect(formData.get('email')).toBe('alice@example.com');
});
});
```
## Testing Error Boundaries
### Error Boundary Component
```typescript
// src/components/AsyncUserError.tsx
export async function AsyncUserError({ userId }: { userId: string }) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`User ${userId} not found`);
}
const user = await response.json();
return <div>{user.name}</div>;
}
// src/app/users/[userId]/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div role="alert">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
```
### Test Suite
```typescript
// src/app/users/[userId]/error.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Error from './error';
describe('Error Boundary', () => {
it('displays error message', () => {
const error = new Error('User not found');
const reset = vi.fn();
render(<Error error={error} reset={reset} />);
expect(screen.getByRole('alert')).toHaveTextContent('User not found');
});
it('calls reset on try again', async () => {
const user = userEvent.setup();
const error = new Error('User not found');
const reset = vi.fn();
render(<Error error={error} reset={reset} />);
await user.click(screen.getByRole('button', { name: 'Try again' }));
expect(reset).toHaveBeenCalled();
});
});
```
## Testing Parallel Data Fetching
### Parallel Async Components
```typescript
// src/app/dashboard/page.tsx
import { Suspense } from 'react';
async function UserStats() {
const response = await fetch('/api/stats/users');
const stats = await response.json();
return <div>{stats.count} users</div>;
}
async function PostStats() {
const response = await fetch('/api/stats/posts');
const stats = await response.json();
return <div>{stats.count} posts</div>;
}
export default function Dashboard() {
return (
<div>
<Suspense fallback={<div>Loading user stats...</div>}>
<UserStats />
</Suspense>
<Suspense fallback={<div>Loading post stats...</div>}>
<PostStats />
</Suspense>
</div>
);
}
```
### Test Suite
```typescript
// src/app/dashboard/page.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import Dashboard from './page';
describe('Dashboard', () => {
it('loads components in parallel', async () => {
render(<Dashboard />);
// Both should load independently
expect(await screen.findByText('150 users')).toBeInTheDocument();
expect(await screen.findByText('300 posts')).toBeInTheDocument();
});
it('shows individual loading states', () => {
render(<Dashboard />);
expect(screen.getByText('Loading user stats...')).toBeInTheDocument();
expect(screen.getByText('Loading post stats...')).toBeInTheDocument();
});
});
```
## Key Takeaways
1. **Async Components**: Await component render before passing to `render()`
2. **Suspense**: Test both fallback and resolved states
3. **Server Actions**: Mock server actions and verify FormData
4. **Error Boundaries**: Test error display and reset functionality
5. **Parallel Fetching**: Each Suspense boundary loads independently
---
**Next**: [Common Patterns](common-patterns.md) | **Previous**: [Best Practices](testing-best-practices.md)

View File

@@ -0,0 +1,499 @@
# Testing Best Practices
Patterns and utilities for effective React and TanStack testing.
## Custom Render Functions
### Basic Custom Render
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
it('matches snapshot', () => {
const { container } = render(<UserCard user={mockUser} />);
expect(container).toMatchSnapshot();
});
```
### Inline Snapshot
```typescript
it('renders correct HTML', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toMatchInlineSnapshot(`
<button>
Click me
</button>
`);
});
```
## Coverage Exclusions
### Exclude from Coverage
```typescript
/* v8 ignore start */
if (process.env.NODE_ENV === 'development') {
// Dev-only code excluded from coverage
}
/* v8 ignore stop */
```
### Exclude Test Files
```typescript
// 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
```bash
# Run tests in parallel
vitest --threads --maxThreads=4
# Run specific tests
vitest src/components/UserList
```
### Coverage Enforcement
```typescript
// 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
1. **Custom Utilities**: Create reusable render functions with all providers
2. **Mock Factories**: Use faker for consistent test data
3. **MSW Organization**: Group handlers by domain (users, auth, posts)
4. **Async Testing**: Prefer `findBy` and `waitFor` for async operations
5. **Accessibility**: Always query by role first
6. **Coverage**: Aim for 80%+ with meaningful tests
---
**Next**: [Server Components Testing](server-components-testing.md) | **Previous**: [Testing Setup](testing-setup.md)

View File

@@ -0,0 +1,421 @@
# Testing Setup Reference
Complete configuration guide for Vitest, React Testing Library, and MSW in TanStack projects.
## Vitest Configuration
### vitest.config.ts
```typescript
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.*',
'**/mockData',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
```
### Key Options
| Option | Purpose | Value |
|--------|---------|-------|
| `globals` | Enable global test APIs (describe, it, expect) | `true` |
| `environment` | Test environment (jsdom for DOM testing) | `'jsdom'` |
| `setupFiles` | Files to run before each test file | `['./src/test/setup.ts']` |
| `coverage.provider` | Coverage provider | `'v8'` (faster) |
| `coverage.thresholds` | Minimum coverage percentages | 80% recommended |
## Test Setup File
### src/test/setup.ts
```typescript
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll, vi } from 'vitest';
import { server } from './msw/server';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// MSW setup
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Mock window.matchMedia (for responsive components)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver (for infinite scroll)
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock ResizeObserver (for table columns)
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Suppress console errors in tests (optional)
const originalError = console.error;
beforeAll(() => {
console.error = (...args: any[]) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render')
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
```
## MSW Setup
### src/test/msw/server.ts
```typescript
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
```
### src/test/msw/handlers.ts
```typescript
import { http, HttpResponse } from 'msw';
export const handlers = [
// Users API
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
]);
}),
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
});
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: '3', ...body },
{ status: 201 }
);
}),
// Auth API
http.post('/api/auth/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password') {
return HttpResponse.json({
token: 'mock-jwt-token',
user: { id: '1', email, name: 'Test User' },
});
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
];
```
### Overriding Handlers in Tests
```typescript
import { server } from '../test/msw/server';
import { http, HttpResponse } from 'msw';
it('handles API error', async () => {
// Override handler for this test
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
// Test error handling...
});
```
## TanStack Query Setup
### src/test/query-client.ts
```typescript
import { QueryClient } from '@tanstack/react-query';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry failed queries in tests
gcTime: 0, // No garbage collection
staleTime: 0, // Always consider data stale
},
mutations: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence error logs in tests
},
});
}
```
## TanStack Router Setup
### src/test/router-utils.tsx
```typescript
import { createMemoryHistory, createRouter } from '@tanstack/react-router';
import { routeTree } from '../routeTree.gen';
export function createTestRouter(initialEntries = ['/']) {
const history = createMemoryHistory({ initialEntries });
return createRouter({
routeTree,
history,
context: {
// Mock auth context
auth: {
isAuthenticated: true,
user: { id: '1', name: 'Test User' },
},
},
});
}
```
## Custom Test Utilities
### src/test/test-utils.tsx
```typescript
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { createTestQueryClient } from './query-client';
import { createTestRouter } from './router-utils';
interface WrapperProps {
children: React.ReactNode;
}
export function AllTheProviders({ children }: WrapperProps) {
const queryClient = createTestQueryClient();
const router = createTestRouter();
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router}>
{children}
</RouterProvider>
</QueryClientProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllTheProviders, ...options });
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
export { renderWithProviders as render };
```
## Package.json Scripts
```json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@vitest/coverage-v8": "^1.0.4",
"@vitest/ui": "^1.0.4",
"jsdom": "^23.0.1",
"msw": "^2.0.11",
"vitest": "^1.0.4"
}
}
```
## TypeScript Configuration
### tsconfig.json
```json
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}
```
## Coverage Configuration
### .gitignore
```
coverage/
.vitest/
```
### Coverage Thresholds
```typescript
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
// Per-file thresholds
perFile: true,
},
},
},
});
```
## Environment Variables
### .env.test
```bash
VITE_API_URL=http://localhost:3000/api
VITE_ENV=test
```
### Loading in Tests
```typescript
// src/test/setup.ts
import { loadEnv } from 'vite';
const env = loadEnv('test', process.cwd(), '');
process.env = { ...process.env, ...env };
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
```
## Troubleshooting
### Common Issues
**Issue**: `ReferenceError: describe is not defined`
**Solution**: Add `globals: true` to vitest config
**Issue**: `Cannot find module '@testing-library/jest-dom/vitest'`
**Solution**: Install `@testing-library/jest-dom` package
**Issue**: MSW not intercepting requests
**Solution**: Ensure `server.listen()` is called in `beforeAll`
**Issue**: Tests fail with "Act" warnings
**Solution**: Wrap async operations with `waitFor` or `findBy`
---
**Next**: [Testing Best Practices](testing-best-practices.md) | **Index**: [Reference Index](INDEX.md)