12 KiB
12 KiB
Common Testing Patterns
Frequently used patterns for testing loading states, errors, infinite queries, and prefetching.
Testing Loading States
Skeleton Loader
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
- Loading States: Always test skeleton loaders and spinners
- Error Handling: Test error display and retry functionality
- Infinite Queries: Test pagination, load more, and end of list
- Intersection Observer: Mock IntersectionObserver for auto-loading
- Prefetching: Test hover prefetch and cache population
- Optimistic Updates: Test immediate UI updates and rollback on error
Previous: Server Components | Index: Reference Index