Initial commit
This commit is contained in:
91
skills/react-tanstack-testing/reference/INDEX.md
Normal file
91
skills/react-tanstack-testing/reference/INDEX.md
Normal 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)
|
||||
494
skills/react-tanstack-testing/reference/common-patterns.md
Normal file
494
skills/react-tanstack-testing/reference/common-patterns.md
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
421
skills/react-tanstack-testing/reference/testing-setup.md
Normal file
421
skills/react-tanstack-testing/reference/testing-setup.md
Normal 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)
|
||||
Reference in New Issue
Block a user