7 min read

Testing Next.js Apps with React Testing Library & Cypress in TypeScript: A Pragmatic Playbook

Introduction

Next.js has become the de‑facto framework for modern React applications, offering file‑based routing, server‑side rendering, and API routes out of the box. While these features accelerate development, they also introduce new testing challenges: you need to verify React components, page‑level behavior, API routes, and full‑stack user flows.

This article walks you through a cohesive testing strategy that combines React Testing Library (RTL) for unit‑ and integration‑level tests with Cypress for end‑to‑end (E2E) verification, all written in TypeScript. The goal is to give you a repeatable workflow, concrete code examples, and practical tips you can drop into any Next.js codebase.


1. Setting the Groundwork

1.1 Project scaffolding

npx create-next-app@latest my-next-app --typescript
cd my-next-app
npm i -D @testing-library/react @testing-library/jest-dom @testing-library/user-event \
        jest ts-jest jest-environment-jsdom \
        cypress cypress-axe
  • @testing-library/* – utilities for testing React components the way users interact with them.
  • jest – test runner for RTL.
  • cypress – powerful browser‑based E2E runner.
  • cypress-axe – optional accessibility assertions.

Add a minimal Jest config (jest.config.js):

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/components/$1',
    '^@/pages/(.*)$': '<rootDir>/pages/$1',
  },
};

jest.setup.ts:

import '@testing-library/jest-dom';

1.2 Folder conventions

src/
  components/
  pages/
  lib/
  __tests__/          # RTL unit & integration tests
cypress/
  e2e/
    specs/
    fixtures/
cypress.config.ts

Keeping test files close to the code they verify (but under a dedicated __tests__ folder) makes navigation easy while still allowing Jest’s testMatch pattern to locate them.


2. Unit & Integration Testing with React Testing Library

2.1 Why RTL?

RTL encourages behavior‑driven testing: you query the DOM the way a user would (by role, label text, placeholder, etc.) instead of reaching into component internals. This aligns perfectly with Next.js’s component‑centric architecture.

2.2 A simple component

src/components/Counter.tsx

import { useState } from 'react';

export const Counter = () => {
  const [count, setCount] = useState<number>(0);
  return (
    <div>
      <p aria-live="polite">Current count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
    </div>
  );
};

2.3 RTL test

src/__tests__/Counter.test.tsx

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from '@/components/Counter';

describe('Counter component', () => {
  it('increments and decrements the count', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const countText = screen.getByRole('paragraph');
    expect(countText).toHaveTextContent('Current count: 0');

    const incBtn = screen.getByRole('button', { name: /increment/i });
    const decBtn = screen.getByRole('button', { name: /decrement/i });

    await user.click(incBtn);
    expect(countText).toHaveTextContent('Current count: 1');

    await user.click(decBtn);
    expect(countText).toHaveTextContent('Current count: 0');
  });
});

Notice the use of aria-live and role="paragraph" (or you could query by text). The test stays resilient to implementation changes.

2.4 Testing a page that uses getStaticProps

src/pages/products.tsx

import { GetStaticProps, NextPage } from 'next';
import { ProductList } from '@/components/ProductList';
import { fetchProducts } from '@/lib/api';

type Props = {
  products: { id: string; name: string }[];
};

export const getStaticProps: GetStaticProps<Props> = async () => {
  const products = await fetchProducts();
  return { props: { products } };
};

const ProductsPage: NextPage<Props> = ({ products }) => (
  <main>
    <h1>Products</h1>
    <ProductList items={products} />
  </main>
);

export default ProductsPage;

Mocking getStaticProps

src/__tests__/products.page.test.tsx

import { render, screen } from '@testing-library/react';
import ProductsPage, { getStaticProps } from '@/pages/products';
import * as api from '@/lib/api';

jest.mock('@/lib/api');

describe('Products page', () => {
  const mockProducts = [
    { id: '1', name: 'Alpha' },
    { id: '2', name: 'Beta' },
  ];

  beforeAll(() => {
    (api.fetchProducts as jest.Mock).mockResolvedValue(mockProducts);
  });

  it('renders a list of products', async () => {
    const { props } = await getStaticProps({});
    render(<ProductsPage {...(props as any)} />);

    expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Products');
    mockProducts.forEach(p => {
      expect(screen.getByText(p.name)).toBeInTheDocument();
    });
  });
});

Key points:

  • Use jest.mock to replace network calls.
  • Call getStaticProps directly – it returns a plain object, making it trivial to test data‑fetching logic without a full Next.js runtime.

3. End‑to‑End Testing with Cypress

3.1 Cypress configuration (TypeScript)

cypress.config.ts

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
    supportFile: 'cypress/support/e2e.ts',
    video: false,
  },
});

cypress/support/e2e.ts

import './commands';
import '@testing-library/cypress/add-commands';

The @testing-library/cypress plugin lets you reuse RTL queries inside Cypress (cy.findByRole, cy.findByText, etc.), keeping the test language consistent across unit and E2E layers.

3.2 A realistic user flow

Scenario: A logged‑in user creates a new post via a form that lives at /posts/new. The form posts to an API route (/api/posts) which returns the created post ID. After submission, the UI redirects to /posts/[id].

3.2.1 API route (simplified)

src/pages/api/posts.ts

import type { NextApiRequest, NextApiResponse } from 'next';
import { createPost } from '@/lib/db';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).end();
  }
  const { title, content } = req.body;
  const id = await createPost({ title, content });
  res.status(201).json({ id });
}

3.2.2 Cypress test

cypress/e2e/create-post.cy.ts

/// <reference types="cypress" />

describe('Create a post', () => {
  beforeEach(() => {
    // Stub the API call to keep the test deterministic
    cy.intercept('POST', '/api/posts', {
      statusCode: 201,
      body: { id: '123' },
    }).as('createPost');
  });

  it('submits the form and navigates to the new post page', () => {
    // Assume a custom command that logs in via UI or token injection
    cy.login(); // defined in cypress/support/commands.ts

    cy.visit('/posts/new');

    cy.findByLabelText(/title/i).type('My Cypress Post');
    cy.findByLabelText(/content/i).type('Testing with Cypress is fun!');

    cy.findByRole('button', { name: /publish/i }).click();

    // Wait for the stubbed request and assert payload
    cy.wait('@createPost').its('request.body').should('deep.equal', {
      title: 'My Cypress Post',
      content: 'Testing with Cypress is fun!',
    });

    // Verify navigation
    cy.url().should('include', '/posts/123');
    cy.findByRole('heading', { level: 1 }).should('contain', 'My Cypress Post');
  });
});

Why this matters

  • Stubbing the API isolates the UI test from backend changes and speeds up the suite.
  • Using RTL queries (findByLabelText, findByRole) makes the test resilient to markup refactors.
  • The test covers authentication, form handling, network interaction, and client‑side navigation – a true end‑to‑end scenario.

3.3 Testing Next.js API routes directly

Cypress can also hit API routes as pure HTTP endpoints, which is handy for contract verification.

cypress/e2e/api-posts.cy.ts

describe('API: POST /api/posts', () => {
  it('returns 201 with an id', () => {
    cy.request({
      method: 'POST',
      url: '/api/posts',
      body: { title: 'API test', content: 'Hello' },
      failOnStatusCode: false, // we want to assert manually
    }).then(resp => {
      expect(resp.status).to.eq(201);
      expect(resp.body).to.have.property('id').and.to.be.a('string');
    });
  });
});

Running this against a test‑only environment (e.g., npm run dev with a separate test DB) gives you confidence that the contract between front‑end and back‑end stays intact.


4. Type‑Safety Across the Stack

4.1 Shared types

Create a src/types/api.ts file:

export interface PostPayload {
  title: string;
  content: string;
}

export interface PostResponse {
  id: string;
}

Use these types in both the API route and client‑side fetch:

// src/lib/client.ts
import type { PostPayload, PostResponse } from '@/types/api';

export const createPost = async (payload: PostPayload): Promise<PostResponse> => {
  const res = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error('Failed to create post');
  return res.json();
};

Now the Cypress test can import the same types for type‑checked fixtures:

import type { PostPayload } from '@/types/api';

const newPost: PostPayload = {
  title: 'Typed Cypress',
  content: 'All data shapes are verified at compile time',
};

4.2 Enforcing types in Jest mocks

When mocking fetchProducts earlier, TypeScript will warn if the mock returns a shape that doesn’t match Props['products']. This catches mismatches early, preventing flaky tests.


5. Organising Tests for Scale

Layer Tool Typical file pattern Example
Unit / Component RTL + Jest src/__tests__/**/*.test.{ts,tsx} Counter.test.tsx
Page / Integration RTL + Jest (with getStaticProps/getServerSideProps) src/__tests__/pages/**/*.test.{ts,tsx} products.page.test.tsx
API contract Cypress (or Jest with supertest) cypress/e2e/api/**/*.cy.{ts,js} api-posts.cy.ts
Full user flow Cypress cypress/e2e/**/*.cy.{ts,js} create-post.cy.ts

Tips

  • Keep snapshot tests out of the main flow; they’re useful for visual regression but should be isolated in a __snapshots__ folder.
  • Use custom Cypress commands (cy.login, cy.resetDb) to avoid duplication.
  • Run RTL tests in parallel CI jobs (Jest’s --maxWorkers) and Cypress in a separate stage to keep feedback fast.

6. CI Integration

A typical GitHub Actions pipeline:

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U test"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      - run: npm ci
      - name: Run unit & integration tests
        run: npm run test -- --ci --coverage
      - name: Start Next.js for Cypress
        run: npm run dev &
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test
      - name: Cypress E2E
        run: npx cypress run --headless

The npm run dev & line starts the dev server in the background, allowing Cypress to hit a live Next.js instance.

Why separate stages?

  • Unit tests are fast and give immediate feedback on logic errors.
  • Cypress tests are slower (they spin up a browser) but catch integration bugs that unit tests miss.

7. Debugging Tips

Symptom Quick Fix
Tests hang on await Ensure you’re using await userEvent.setup() and that the component isn’t waiting on an unmocked network request.
Cypress “cy.visit” fails with 404 Verify that the dev server is running on the same baseUrl and that the page exists in pages/.
Jest “Cannot find module '@/components/…'” Check moduleNameMapper in jest.config.js matches your tsconfig.json paths.
Flaky Cypress test after navigation Add cy.wait('@apiCall') or use cy.findByRole(...).should('be.visible') to wait for UI stabilization.
Type errors in mocks Use as jest.MockedFunction<typeof fn> to give the mock the same signature as the real function.

8. Best‑Practice Checklist

  • Write tests at three levels: component (RTL), page/integration (RTL + getStaticProps), and user flow (Cypress).
  • Keep tests deterministic: mock external services, stub API routes, and use a dedicated test database.
  • Leverage shared TypeScript types to avoid duplication between client, server, and test code.
  • Prefer RTL queries (getByRole, findByLabelText) over CSS selectors.
  • Run Cypress in headless mode on CI, but keep a local cypress open session for interactive debugging.
  • Add accessibility checks (cypress-axe or jest-axe) to catch a11y regressions early.
  • Document custom commands (cy.login, cy.resetDb) in a support/commands.ts file for team onboarding.

Conclusion

Testing a Next.js application doesn’t have to be a fragmented experience. By unifying React Testing Library for unit/integration work and Cypress for full‑stack E2E verification—both powered by TypeScript—you gain a clear, maintainable testing pyramid:

  1. Fast, isolated component tests catch logic errors early.
  2. Page‑level tests validate data‑fetching contracts without a full server.
  3. Cypress flows ensure the whole system (auth, API, routing, UI) works together.

Adopt the folder conventions, shared types, and CI setup outlined