Modern Testing Techniques for Next.js Developers: Ship Confidently
This guide explains how we can approach testing headless WordPress + Next.js project. It’s written to help you understand the “what,” “why,” and “how” of our testing approach, even if you’re new to this setup.
Philosophy
- Test real behavior that users rely on, including rendering, interactions, data flow, validation, and accessibility.
- Use minimal mocking, only for external integrations or unstable dependencies.
- Prefer realistic API responses through MSW instead of stubbing functions.
- Combine existence checks with behavioral assertions to avoid redundant tests.
- Use data factories to keep tests clear, consistent, and maintainable.
What we test
- How data is transformed, validated, and rendered in components.
- How users interact through clicking, typing, navigation, filtering, and search.
- How the app handles missing data, network errors, and preview authentication.
- How accessible the experience is through semantics, ARIA, keyboard use, and alt text.
- How data flows correctly from GraphQL or REST through Apollo into the UI.
What we avoid
- Testing framework behavior such as React hooks or Next.js routing.
- Testing browser behavior or rendering performance.
- Testing mocked components instead of real ones.
- Writing redundant tests that only confirm something renders without behavior.
Test Organization and Structure
Tests are grouped by type inside a top-level tests/ directory. This keeps unit, component, and integration tests clearly separated while allowing shared utilities and mocks to live in one place.
tests/
├── components/
│ ├── UserList.test.tsx
│ ├── ProductGrid.test.tsx
│ └── Header.test.tsx
├── integration/
│ └── api.contract.test.ts
├── mocks/
│ ├── handlers.ts
│ └── server.ts
└── unit/
├── utils.test.ts
└── dataParser.test.ts
The mocks/ folder is where Mock Service Worker (MSW) is configured.
handlers.ts defines how REST and GraphQL requests should respond, and server.ts sets up the MSW server used during testing. In a Node environment, the server intercepts network calls before they leave the process, while in the browser it registers a service worker that handles requests the same way a real API would.
Component Tests
Use Vitest to test how components behave in realistic conditions. I like to use Vitest as my testing engine because it’s faster than Jest. It supports TypeScript out of the box, uses the same syntax as Jest, and integrates cleanly with React Testing Library. In short, it's a lightweight, modern alternative that makes testing in Next.js projects faster and simpler to maintain.
What to be mindful of when testing components.
- Render actual React components using React Testing Library.
- Test real interactions such as typing and clicking, along with validation messages and accessibility behavior.
- Prefer real components, mocking only external or unreliable dependencies like routers or network modules.
- Negative and edge cases might include handling API failures, missing or null content, and validation errors.
describe('UserProfileHeader', () => {
describe('basic rendering', () => {
it('renders user information with formatted ID and profile image', () => {
render(
<UserProfileHeader
user={mockUser}
recentUsers={mockRecentUsers}
activityChange={5.2}
/>
)
expect(screen.getByText('01')).toBeInTheDocument()
expect(screen.getByText('Jane Doe')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
const image = screen.getByAltText('Jane Doe profile picture')
expect(image).toHaveAttribute('src', expect.stringContaining('jane.jpg'))
})
})
describe('activity change display', () => {
it('shows positive change with upward icon', () => {
render(
<UserProfileHeader
user={mockUser}
recentUsers={mockRecentUsers}
activityChange={5.2}
/>
)
expect(screen.getByText('+5.2%')).toBeInTheDocument()
expect(screen.getByText('+5.2%')).toHaveClass('text-success')
const icon = screen.getByAltText('increase icon')
expect(icon).toBeInTheDocument()
})
})
describe('validation and error states', () => {
it('displays an error message when recentUsers is empty', () => {
render(
<UserProfileHeader
user={mockUser}
recentUsers={[]}
activityChange={5.2}
/>
)
expect(screen.getByText(/recent users data is not available/i)).toBeInTheDocument()
})
})
})
Accessibility should always be part of your component tests. Check ARIA attributes like aria-haspopup, aria-current, and aria-label to ensure proper semantic structure.
describe('accessibility', () => {
it('includes appropriate accessibility attributes', () => {
render(
<UserProfileHeader
user={mockUser}
recentUsers={mockRecentUsers}
activityChange={5.2}
/>
)
const heading = screen.getByRole('heading', { level: 2 })
expect(heading).toBeInTheDocument()
const dropdownButton = screen.getByRole('button', { name: /01 Jane Doe dropdown/i })
expect(dropdownButton).toHaveAttribute('aria-haspopup', 'listbox')
expect(dropdownButton).toHaveAttribute('name', 'select-user')
const image = screen.getByAltText('Jane Doe profile picture')
expect(image).toBeInTheDocument()
})
})
Integration Tests
The UI often depends on the shape and structure of API responses. Changes in fields, nullability, or response fragments can easily cause rendering issues.
Data transformation adds another layer of complexity, converting raw API data into UI state such as formatted values, options, or display labels.
Integration Tests:
- Exercise fetch and API integration paths end to end using mock services.
- Validate data fetching, transformation, and component interaction as a single flow.
- Check headers, authentication handling, error responses, and caching behavior.
Examples:
- Fetch page data and confirm the UI-ready structure matches expectations.
- Retrieve recent records and ensure required fields are present.
- Simulate API errors and confirm they’re handled gracefully in the interface.
describe('GraphQL Query Contracts', () => {
it('fetches and processes recent records correctly', async () => {
const result = await makeApiRequest(GetRecentRecordsQuery, {
limit: 100,
region: 'global',
preview: false,
})
// Verify response structure and data processing
expect(result.data).toBeDefined()
expect(result.data?.recentRecords).toBeDefined()
const records = result.data?.recentRecords || []
expect(records.length).toBeGreaterThan(0)
// Verify data structure matches expected contract
const firstRecord = records[0]
expect(firstRecord).toBeDefined()
expect(firstRecord).toHaveProperty('id')
expect(firstRecord).toHaveProperty('details')
expect(firstRecord.details).toHaveProperty('name')
expect(typeof firstRecord.details.name).toBe('string')
expect(firstRecord.details).toHaveProperty('rank')
expect(typeof firstRecord.details.rank).toBe('number')
})
it('handles REST API error responses gracefully', async () => {
// Override REST handler to return 500
server.use(
http.get('*/api/getHistory', () => {
return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 })
})
)
await expect(
fetchHistoricalData(['Example'], 'global')
).rejects.toThrow('Failed to fetch historical data')
})
})
Testing these paths ensures the frontend doesn’t silently regress when schemas evolve or requests change.
Simulating Network Behavior in Tests
Integration tests rely on realistic network conditions to be meaningful.
If the frontend only talks to hardcoded mocks, it can’t validate real fetch behavior, request headers, or response structures.
To simulate these conditions reliably, we use MSW (Mock Service Worker).
MSW intercepts network requests for both REST and GraphQL and returns realistic responses that mimic live API behavior. It lets integration tests hit actual fetch logic, including URLs, headers, and variables, instead of relying on artificial function stubs.
This keeps tests stable, fast, and deterministic while allowing per-test overrides for custom responses or error cases without modifying application code.
It also avoids tight coupling by not mocking Apollo Client (the GraphQL client library often used for data fetching in React and Next.js apps) or fetch globally. MSW instead operates at the network layer and works seamlessly in both Node (Vitest) and browser environments.
In a typical setup, MSW is initialized once and shared across all tests. The handlers define the mocked endpoints, and the server uses those handlers to intercept requests before they reach the network.
// tests/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// Create a mock server using the request handlers
export const server = setupServer(...handlers)
// Lifecycle hooks for Vitest
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
In practice, handlers are defined for key endpoints:
- GraphQL (*/graphql): Returns data for specific operations such as page queries or detailed views.
- REST (for example, /api/history): Returns historical data or triggers errors to validate error handling.
// tests/mocks/handlers.ts
export const handlers = [
// GraphQL endpoint
http.post('*/graphql', async ({ request }) => {
const body = await request.json()
const { operationName, variables } = body
if (operationName === 'GetRecentRecords') {
return HttpResponse.json({
data: {
recentRecords: mockRecordsData,
},
})
} else if (operationName === 'GetRecordDetails') {
const { slug } = variables || {}
const currentRecord = mockRecordsData.find(
r => r.details?.slug === slug
) || null
return HttpResponse.json({
data: {
recordDetails: {
currentRecord,
allRecords: mockRecordsData,
},
},
})
}
return HttpResponse.json({ data: {} })
}),
// REST endpoint for historical data
http.get('*/api/getHistory', ({ request }) => {
const url = new URL(request.url)
const itemNames = url.searchParams.get('itemNames')?.split(',')
if (itemNames?.includes('Example')) {
return HttpResponse.json([
{ name: 'Example', year: 2023, value: 480, rank: 1 },
{ name: 'Example', year: 2024, value: 500, rank: 1 },
])
}
return HttpResponse.json([])
}),
]
This setup lets us test full data flows end to end without depending on a live backend.
Why not mock Apollo Client directly?
- Because that approach tests the mocks themselves rather than the network layer.
- Using MSW validates the entire fetch process, including headers, variables, and request flow, so integration tests behave much closer to production.
Mocks
Mocking Strategy: Real Components First.
Mocks are most effective when used deliberately. The goal is to replace only what’s outside the scope of the test, not to avoid real behavior entirely.
When to mock:
- External dependencies such as APIs or network calls.
- Heavy or flaky components that make tests unstable.
- Framework features like the router or image components to keep the environment consistent.
- Utilities that don’t affect core behavior but introduce unnecessary noise.
When not to mock:
- Real components whose behavior we actually want to verify.
- Core business logic functions.
- DOM and ARIA behavior, since those are part of the real user experience.
MSW reduces the need to mock lower-level fetch or Apollo functions.
Instead, we mock at the network layer, which is the right level of abstraction for integration testing.
vi.mock('@/components/common/Loader', () => ({
__esModule: true,
default: () => <div data-testid="loader">Loading...</div>,
}))
vi.mock('@/utils/HtmlToNodes', () => ({
HtmlToNodes: ({ html }) =>
<div dangerouslySetInnerHTML={{ __html: html }} />,
}))
vi.mock('@/components/LinkCta', () => ({
LinkCta: ({ link, text }) => <a href={link.url}>{text}</a>,
}))
Real components (not mocked) for accurate testing:
- Dropdown component (tests ARIA attributes such as aria-haspopup).
- Icon component (tests SVG rendering and alt text).
- Image component (tests image loading and display).