Integration Testing Patterns
What Makes a Test "Integration"
Here's the thing most people get wrong: they think integration tests are just "bigger unit tests." They're not. A unit test isolates a single function or component and stubs everything else. An integration test lets multiple units collaborate and verifies that the collaboration works.
The distinction matters because most bugs live in the seams between units, not inside individual units. Your formatPrice function works perfectly. Your PriceDisplay component renders whatever you pass it. But somewhere between fetching the price, formatting it, and displaying it in the cart, a currency mismatch silently corrupts the value. Unit tests miss this. Integration tests catch it.
Think of unit tests as checking individual LEGO bricks — right shape, right color, right size. Integration tests snap those bricks together and verify the assembled structure actually stands up. You can have 200 perfect bricks that produce a collapsed building because the connection points don't align.
The practical boundary: if your test renders a component with its real children, real hooks, and real context providers (but mocks the network), you're writing an integration test. If it renders a component in total isolation with every dependency mocked, that's a unit test.
The Integration Test Structure
Every integration test follows the same rhythm: render with providers, interact like a user, assert on outcomes. Here's the skeleton:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@/providers/theme'
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ThemeProvider>
{ui}
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>
)
}
Notice two things. First, retry: false on the query client. In production you want retries. In tests, a failed request should fail immediately so your test gives you a clear signal instead of hanging for 30 seconds. Second, MemoryRouter instead of BrowserRouter. You don't have a browser URL bar in tests, so MemoryRouter lets you control the initial route programmatically.
- 1Every integration test needs a fresh QueryClient — shared clients leak state between tests
- 2Use MemoryRouter with initialEntries to control the starting route
- 3Set retry: false on QueryClient in tests so failures surface immediately
- 4Wrap your render helper in a function — never share rendered state across tests
- 5Prefer userEvent over fireEvent — userEvent simulates real browser behavior (focus, typing, blur)
Testing User Flows
The real power of integration tests is simulating complete user journeys. Not "does this button exist" but "can a user actually accomplish this task."
Render, Interact, Assert
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SearchPage } from './SearchPage'
test('user can search and see results', async () => {
const user = userEvent.setup()
renderWithProviders(<SearchPage />)
const searchInput = screen.getByRole('searchbox')
await user.type(searchInput, 'react hooks')
await user.click(screen.getByRole('button', { name: /search/i }))
await waitFor(() => {
expect(screen.getByText('useEffect and Cleanup Patterns')).toBeInTheDocument()
})
expect(screen.getAllByRole('article')).toHaveLength(5)
})
A few details here that matter more than they look:
userEvent.setup()at the top. This creates a user instance that correctly simulates pointer and keyboard state across interactions. CallinguserEvent.type()directly works but doesn't track focus state between calls.getByRole('searchbox')instead ofgetByPlaceholderTextorgetByTestId. Querying by role verifies your accessibility tree is correct. If you can't find the element by role, your HTML semantics are broken — and the test just caught a real bug.waitForwrapping the assertion. The search results load asynchronously, so we need to wait for the DOM to update.waitForretries the assertion until it passes or times out.
Testing with Providers
Real applications wrap components in layers of providers: routing, data fetching, authentication, theming, feature flags. Your integration tests need these providers, but you need to control them.
The Provider Wrapper Pattern
type RenderOptions = {
route?: string
user?: { id: string; name: string } | null
theme?: 'light' | 'dark'
}
function renderWithProviders(
ui: React.ReactElement,
options: RenderOptions = {}
) {
const {
route = '/',
user = { id: '1', name: 'Test User' },
theme = 'light',
} = options
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<AuthContext.Provider value={{ user, login: vi.fn(), logout: vi.fn() }}>
<ThemeProvider initialTheme={theme}>
<MemoryRouter initialEntries={[route]}>
{ui}
</MemoryRouter>
</ThemeProvider>
</AuthContext.Provider>
</QueryClientProvider>
)
}
Now your tests can control the exact state of every provider:
test('shows login prompt when unauthenticated', () => {
renderWithProviders(<Dashboard />, { user: null })
expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument()
})
test('shows user dashboard when authenticated', async () => {
renderWithProviders(<Dashboard />, {
user: { id: '1', name: 'Alice' },
route: '/dashboard',
})
expect(screen.getByText('Welcome, Alice')).toBeInTheDocument()
})
The key insight: you're not testing the providers themselves (that's a unit test concern). You're testing that your components behave correctly when different provider states combine. What happens when the user is authenticated but on a 404 route? What happens in dark mode with a long username? Those are the integration seams where bugs hide.
Testing Forms
Forms are where integration testing really shines. A form is inherently an integration — inputs, validation logic, submission handler, error display, success feedback, and sometimes navigation all working together.
The Full Form Flow
test('submitting a valid contact form shows success message', async () => {
const user = userEvent.setup()
renderWithProviders(<ContactPage />)
await user.type(screen.getByLabelText(/name/i), 'Alice Chen')
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(
screen.getByLabelText(/message/i),
'Great course on integration testing!'
)
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText(/message sent successfully/i)).toBeInTheDocument()
})
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
Testing Validation
test('shows validation errors for invalid email', async () => {
const user = userEvent.setup()
renderWithProviders(<ContactPage />)
await user.type(screen.getByLabelText(/name/i), 'Alice')
await user.type(screen.getByLabelText(/email/i), 'not-an-email')
await user.click(screen.getByRole('button', { name: /send message/i }))
expect(screen.getByText(/enter a valid email/i)).toBeInTheDocument()
expect(
screen.queryByText(/message sent successfully/i)
).not.toBeInTheDocument()
})
test('disables submit button while request is in flight', async () => {
const user = userEvent.setup()
renderWithProviders(<ContactPage />)
await user.type(screen.getByLabelText(/name/i), 'Alice')
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/message/i), 'Hello')
await user.click(screen.getByRole('button', { name: /send message/i }))
expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled()
})
Notice we're testing the user-visible behavior, not the implementation. We don't check if useState was called or if the validation function returned an error object. We type, click, and verify what the user sees. If you refactor from controlled inputs to useFormStatus, from Zod to Yup, from fetch to axios — these tests still pass because the behavior didn't change.
Testing Data Fetching with MSW
Mock Service Worker (MSW) intercepts network requests at the service worker level. Your components use fetch or axios exactly as they do in production — no mocking fetch, no injecting fake HTTP clients. The requests actually fire, MSW intercepts them, and returns your test data.
This is the gold standard for integration testing data fetching because your component code doesn't know it's being tested.
Setting Up MSW
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const handlers = [
http.get('/api/courses', () => {
return HttpResponse.json([
{ id: '1', title: 'Integration Testing', progress: 45 },
{ id: '2', title: 'React Fundamentals', progress: 100 },
])
}),
http.post('/api/contact', async ({ request }) => {
const body = await request.json()
if (!body.email.includes('@')) {
return HttpResponse.json(
{ error: 'Invalid email' },
{ status: 400 }
)
}
return HttpResponse.json({ success: true }, { status: 201 })
}),
]
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Testing the Happy Path
test('displays course list from API', async () => {
renderWithProviders(<CourseDashboard />)
expect(screen.getByText(/loading courses/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Integration Testing')).toBeInTheDocument()
})
expect(screen.getByText('React Fundamentals')).toBeInTheDocument()
expect(screen.getByText('45%')).toBeInTheDocument()
})
Testing Error States
This is where MSW really shines. You can override handlers per test to simulate errors:
test('shows error message when API fails', async () => {
server.use(
http.get('/api/courses', () => {
return HttpResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
})
)
renderWithProviders(<CourseDashboard />)
await waitFor(() => {
expect(screen.getByText(/failed to load courses/i)).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
The server.use() call overrides only for the current test. After the test, server.resetHandlers() in afterEach restores the default handlers. No test pollution.
Testing Network Delays
test('shows skeleton loader during slow requests', async () => {
server.use(
http.get('/api/courses', async () => {
await delay(2000)
return HttpResponse.json([])
})
)
renderWithProviders(<CourseDashboard />)
expect(screen.getAllByTestId('course-skeleton')).toHaveLength(3)
})
Why MSW over vi.mock for fetch
When you vi.mock('fetch'), you're replacing the global fetch with a fake. Your component's code path for constructing the request (headers, URL params, body serialization) is never exercised. MSW intercepts at the network level, so your entire request pipeline runs for real. This catches bugs like wrong URLs, missing headers, incorrect Content-Type, and malformed request bodies that mocking fetch would miss entirely.
Testing Navigation
Navigation integration tests verify that clicking links and buttons actually takes the user where they expect. With MemoryRouter, you control the starting route and assert on route changes.
test('clicking a course card navigates to the course page', async () => {
const user = userEvent.setup()
renderWithProviders(<CourseDashboard />, {
route: '/dashboard',
})
await waitFor(() => {
expect(screen.getByText('Integration Testing')).toBeInTheDocument()
})
await user.click(screen.getByText('Integration Testing'))
expect(
screen.getByRole('heading', { name: /integration testing/i })
).toBeInTheDocument()
})
For testing route-based rendering, render your router directly:
test('renders 404 page for unknown routes', () => {
renderWithProviders(<App />, { route: '/this-does-not-exist' })
expect(screen.getByText(/page not found/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /go home/i })).toHaveAttribute(
'href',
'/'
)
})
Testing Error Boundaries
Error boundaries catch rendering errors in their child tree. Testing them requires triggering a render error and verifying the fallback UI appears.
function ProblemChild({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error('Component crashed')
}
return <div>Working fine</div>
}
test('error boundary catches render errors and shows fallback', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
renderWithProviders(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ProblemChild shouldThrow={true} />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.queryByText('Working fine')).not.toBeInTheDocument()
consoleSpy.mockRestore()
})
The console.error spy is important. React logs errors to the console when an error boundary catches them. Without the spy, your test output fills with scary-looking red error messages even though the test is passing. Mocking console.error keeps your test output clean while verifying the boundary works.
Testing Error Recovery
test('user can recover from error by clicking retry', async () => {
const user = userEvent.setup()
let shouldThrow = true
function UnstableComponent() {
if (shouldThrow) throw new Error('Oops')
return <div>Recovered successfully</div>
}
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
renderWithProviders(
<ErrorBoundary>
<UnstableComponent />
</ErrorBoundary>
)
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
shouldThrow = false
await user.click(screen.getByRole('button', { name: /try again/i }))
expect(screen.getByText('Recovered successfully')).toBeInTheDocument()
consoleSpy.mockRestore()
})
Testing Loading, Error, and Empty States
Every data-dependent component has three states beyond the happy path: loading, error, and empty. If you don't test all three, you're leaving gaps where bugs silently ship.
The Three States Pattern
describe('CourseList', () => {
test('shows loading skeleton initially', () => {
renderWithProviders(<CourseList />)
expect(screen.getByRole('status')).toHaveTextContent(/loading/i)
})
test('shows courses when data loads', async () => {
renderWithProviders(<CourseList />)
await waitFor(() => {
expect(screen.getByText('Integration Testing')).toBeInTheDocument()
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
test('shows error with retry when request fails', async () => {
server.use(
http.get('/api/courses', () => {
return HttpResponse.json(null, { status: 500 })
})
)
renderWithProviders(<CourseList />)
await waitFor(() => {
expect(screen.getByText(/couldn't load courses/i)).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
test('shows empty state when no courses exist', async () => {
server.use(
http.get('/api/courses', () => {
return HttpResponse.json([])
})
)
renderWithProviders(<CourseList />)
await waitFor(() => {
expect(
screen.getByText(/no courses yet/i)
).toBeInTheDocument()
})
expect(
screen.getByRole('link', { name: /browse catalog/i })
).toBeInTheDocument()
})
})
The empty state test is the one people forget. An empty array from the API is a valid response, not an error. But if your component doesn't handle it, users see a blank screen with no guidance. That's a UX bug, and integration tests are exactly the right place to catch it.
Organizing Integration Tests
A quick note on structure that saves headaches at scale. Group your integration tests by user flow, not by component:
src/
features/
dashboard/
__tests__/
dashboard-flow.test.tsx // user opens dashboard, sees courses
search-flow.test.tsx // user searches, filters, selects
Dashboard.tsx
SearchBar.tsx
CourseCard.tsx
Each test file represents a journey a user takes through your app. This makes it obvious what's covered and what's missing. When a test breaks, the file name tells you which user flow is affected, not which component has a bug.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Mocking child components in integration tests with vi.mock The whole point of integration tests is verifying that components work together. Mocking children turns it into a unit test with extra steps and zero additional confidence. | Use real child components. Mock only external boundaries like the network (MSW) or browser APIs (IntersectionObserver). |
| Sharing a QueryClient across tests QueryClient caches responses. A shared instance means test A's cached data bleeds into test B, causing flaky tests that pass alone but fail when run together. | Create a new QueryClient in every renderWithProviders call. |
| Using getByTestId as the default query data-testid attributes tell you nothing about accessibility. getByRole queries the accessibility tree, so a passing test proves screen readers can find the element too. | Follow the Testing Library query priority: getByRole, getByLabelText, getByText, then getByTestId as a last resort. |
| Testing implementation details like state values or internal method calls Tests coupled to implementation break on every refactor, even when behavior is unchanged. That kills confidence in the test suite and encourages skipping tests. | Test what the user sees. Type in a field, click a button, verify text or elements on screen. |
| Wrapping assertions in try-catch to handle async timing try-catch swallows the real error message and makes debugging harder. waitFor retries the assertion with proper error reporting on timeout. | Use waitFor for async assertions. Use findByText (which is getByText + waitFor) for elements that appear after async work. |
Quick Reference: The Integration Test Checklist
Before shipping an integration test, verify:
- Providers — are all required context providers wrapped?
- User simulation — are you using
userEvent.setup()for realistic interactions? - Queries — are you querying by role/label first, testid last?
- Async — are you using
waitFororfindBy*for async content? - Cleanup — does each test start with a clean state (fresh QueryClient, reset MSW handlers)?
- All states — did you cover loading, error, empty, and success states?
- No implementation coupling — would this test survive a refactor that preserves behavior?