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.mockto replace network calls. - Call
getStaticPropsdirectly – 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 opensession for interactive debugging. - Add accessibility checks (
cypress-axeorjest-axe) to catch a11y regressions early. - Document custom commands (
cy.login,cy.resetDb) in asupport/commands.tsfile 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:
- Fast, isolated component tests catch logic errors early.
- Page‑level tests validate data‑fetching contracts without a full server.
- Cypress flows ensure the whole system (auth, API, routing, UI) works together.
Adopt the folder conventions, shared types, and CI setup outlined
Member discussion