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

What we test

What we avoid

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.


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:

Examples:


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:


// 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?

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:

When not to mock:

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: