Initial commit
This commit is contained in:
90
skills/react-tanstack-testing/examples/INDEX.md
Normal file
90
skills/react-tanstack-testing/examples/INDEX.md
Normal 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)
|
||||
488
skills/react-tanstack-testing/examples/tanstack-form-testing.md
Normal file
488
skills/react-tanstack-testing/examples/tanstack-form-testing.md
Normal 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)
|
||||
475
skills/react-tanstack-testing/examples/tanstack-query-testing.md
Normal file
475
skills/react-tanstack-testing/examples/tanstack-query-testing.md
Normal 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)
|
||||
@@ -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)
|
||||
496
skills/react-tanstack-testing/examples/tanstack-table-testing.md
Normal file
496
skills/react-tanstack-testing/examples/tanstack-table-testing.md
Normal 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)
|
||||
Reference in New Issue
Block a user