End-to-End Testing Next.js Apps with Cypress and TypeScript

Build reliable, type-safe E2E tests that validate your Next.js applications from the user's perspective. Complete guide to Cypress setup, test patterns, and best practices.

Why End-to-End Testing Matters for Next.js

Modern web development demands rigorous testing to deliver reliable, performant applications. Next.js provides the foundation for building React applications with built-in performance optimizations and SEO benefits, but ensuring those applications work correctly across all user interactions requires a comprehensive testing strategy.

End-to-end testing occupies a unique position in the testing pyramid. Unlike unit tests that verify individual functions in isolation or integration tests that check component interactions, E2E tests validate your application from the user's perspective. A well-crafted E2E test clicks through your application exactly as a user would, verifying that forms submit correctly, navigation works as expected, and the entire application flow functions seamlessly.

For Next.js applications--which often serve as the public face of businesses--this level of verification becomes critical. A broken checkout flow or malfunctioning contact form directly impacts revenue and customer trust, making comprehensive testing essential for business-critical applications. Additionally, integrating automated testing into your CI/CD pipeline ensures that every code change gets validated before deployment.

Key benefits of E2E testing for Next.js:

  • Validates hybrid rendering (SSR, CSR, static generation)
  • Catches regressions before they reach production
  • Documents expected user behavior
  • Tests complete user journeys, not just components
Why Cypress for Next.js Testing

A powerful combination for comprehensive application testing

Browser-Based Testing

Cypress operates directly in the browser, providing accurate testing of your application's real behavior rather than simulated DOM behavior.

TypeScript Support

Full TypeScript integration provides type safety, autocompletion, and early error detection in your test code.

Automatic Waiting

Cypress automatically waits for elements to appear and animations to complete, reducing flaky tests caused by timing issues.

Time-Travel Debugging

Inspect your application at any point in the test's execution, making it easy to understand why tests fail.

Component Testing

Test individual React components in isolation, not just full pages, enabling faster and more targeted testing.

Network Control

Mock API responses, stub network requests, and test edge cases without relying on backend services.

Setting Up Cypress with TypeScript in Next.js

Installing Cypress in a Next.js project requires a few straightforward steps that establish the testing infrastructure. Once installed, you'll initialize Cypress to create the configuration file and example tests that demonstrate the framework's capabilities.

Installation Steps

# Install Cypress as a development dependency
npm install --save-dev cypress

# Initialize Cypress (creates configuration and example files
npx cypress open

TypeScript Configuration

TypeScript integration enhances Cypress testing by providing type inference for DOM elements, assertions, and custom commands. When you write tests in TypeScript, your IDE can alert you to incorrect method calls, missing properties, and type mismatches before you even run the tests. This proactive error detection catches mistakes early in the development process, reducing the time spent debugging test failures.

Add TypeScript support:

# Install TypeScript and Cypress types
npm install --save-dev typescript
npm install --save-dev @types/node

Create a tsconfig.json for Cypress:

{
 "compilerOptions": {
 "target": "es5",
 "lib": ["es5", "dom"],
 "types": ["cypress", "node"],
 "esModuleInterop": true
 },
 "include": ["cypress/**/*.ts"]
}

Next.js Configuration

Configure Cypress to work with your Next.js application's routing and server-side rendering characteristics. Modern Next.js projects using the App Router require special handling for server components that don't have client-side JavaScript execution. For comprehensive quality assurance, consider pairing Cypress with component-level testing approaches that validate individual pieces of your application.

cypress.config.ts
1import { defineConfig } from 'cypress';2 3export default defineConfig({4 e2e: {5 // Base URL for your Next.js application6 baseUrl: 'http://localhost:3000',7 8 // Viewport sizes for responsive testing9 viewportWidth: 1280,10 viewportHeight: 720,11 12 // Test file patterns13 specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',14 15 // Video and screenshot options16 video: true,17 screenshotOnRunFailure: true,18 19 // Timeout for element commands20 defaultCommandTimeout: 10000,21 22 // Setup node events23 setupNodeEvents(on, config) {24 // Implement custom task handlers25 },26 27 // Support for component testing28 component: {29 devServer: {30 framework: 'next',31 bundler: 'webpack',32 },33 },34 },35});

Writing Your First Cypress Test for Next.js

A Cypress test begins with the describe function that groups related tests and the it function that defines individual test cases. Within each test, you use Cypress commands to interact with your application--visiting pages, finding elements, clicking buttons, and asserting that expected conditions are met. The command chain reads almost like natural language: visit a page, find a heading, verify it contains the expected text.

Basic Test Structure

// cypress/e2e/navigation.cy.ts

describe('Navigation Flow', () => {
 beforeEach(() => {
 // Start each test from the home page
 cy.visit('/')
 })

 it('navigates to the products page', () => {
 // Find and click a navigation link
 cy.findByRole('link', { name: /products/i })
 .click()
 
 // Verify the URL changed
 cy.url().should('include', '/products')
 
 // Verify the page loaded correctly
 cy.findByRole('heading', { name: /products/i })
 .should('be.visible')
 })

 it('displays error for invalid form submission', () => {
 // Navigate to contact page
 cy.visit('/contact')
 
 // Submit empty form
 cy.findByRole('button', { name: /submit/i })
 .click()
 
 // Verify validation error appears
 cy.findByText(/email is required/i)
 .should('be.visible')
 })
})

Working with Forms

Forms represent a critical testing surface for most Next.js applications. Users submit data through forms to create accounts, make purchases, contact businesses, and perform other valuable actions. A form testing strategy covers multiple scenarios: successful submissions, validation errors, network failures, and edge cases like duplicate submissions.

// cypress/e2e/forms.cy.ts

describe('Contact Form', () => {
 beforeEach(() => {
 cy.visit('/contact')
 })

 it('successfully submits a valid form', () => {
 // Fill in form fields
 cy.findByLabelText(/name/i)
 .type('John Doe')
 
 cy.findByLabelText(/email/i)
 .type('[email protected]')
 
 cy.findByLabelText(/message/i)
 .type('I need a quote for web development services.')
 
 // Submit the form
 cy.findByRole('button', { name: /send message/i })
 .click()
 
 // Verify success state
 cy.findByText(/thank you for your message/i)
 .should('be.visible')
 })
})

Testing API Routes and Server-Side Functionality

Next.js applications often include API routes that handle form submissions, data fetching, and backend logic. Testing these routes requires a different approach than testing client-side interactions. Cypress can make requests directly to your API routes, verifying that they return the correct status codes, headers, and response bodies. This capability proves essential for applications that rely heavily on server-side processing.

Testing API Endpoints

// cypress/e2e/api.cy.ts

describe('API Routes', () => {
 it('returns products from the products API', () => {
 cy.request('/api/products')
 .its('status')
 .should('equal', 200)
 
 cy.request('/api/products')
 .its('body')
 .should('be.an', 'array')
 .and('not.be.empty')
 })

 it('validates POST requests to the contact API', () => {
 cy.request({
 method: 'POST',
 url: '/api/contact',
 body: {
 name: 'Test User',
 email: '[email protected]',
 message: 'Test message'
 },
 failOnStatusCode: false
 })
 .its('status')
 .should('equal', 200)
 })
})

Network Interception for Testing

Mock API responses to test edge cases without relying on actual backend services:

// cypress/e2e/network-mocking.cy.ts

describe('Products Page with Network Mocking', () => {
 beforeEach(() => {
 // Intercept API calls and provide mock responses
 cy.intercept('GET', '/api/products', {
 statusCode: 200,
 body: [
 { id: 1, name: 'Product 1', price: 99.99 },
 { id: 2, name: 'Product 2', price: 149.99 }
 ]
 }).as('getProducts')
 })

 it('displays mocked products', () => {
 cy.visit('/products')
 
 // Wait for the intercepted request
 cy.wait('@getProducts')
 
 // Verify products display
 cy.findByText('Product 1').should('be.visible')
 cy.findByText('Product 2').should('be.visible')
 })

 it('handles API errors gracefully', () => {
 // Mock an error response
 cy.intercept('GET', '/api/products', {
 statusCode: 500,
 body: { error: 'Internal Server Error' }
 }).as('productsError')
 
 cy.visit('/products')
 cy.wait('@productsError')
 
 // Verify error state displays
 cy.findByText(/something went wrong/i)
 .should('be.visible')
 })
})

Best Practices for Maintainable Cypress Tests

Organize Tests by Feature

Group related tests together for easier maintenance and understanding:

cypress/
 e2e/
 checkout/
 cart.cy.ts
 shipping.cy.ts
 payment.cy.ts
 confirmation.cy.ts
 products/
 listing.cy.ts
 details.cy.ts
 filters.cy.ts
 navigation/
 header.cy.ts
 footer.cy.ts
 breadcrumbs.cy.ts

Use Custom Commands

Encapsulate repeated logic to reduce duplication across your test suite:

// cypress/support/commands.ts

// Custom command for logging in
Cypress.Commands.add('login', (email: string, password: string) => {
 cy.findByLabelText(/email/i).type(email)
 cy.findByLabelText(/password/i).type(password)
 cy.findByRole('button', { name: /sign in/i }).click()
})

// Custom command for waiting for API
Cypress.Commands.add('waitForApi', (alias: string) => {
 cy.wait(alias)
 cy.get('[data-testid="loading"]').should('not.exist')
})

Avoid Common Pitfalls

Don't use arbitrary waits:

// Bad - causes flakiness
cy.wait(5000)
cy.get('.element').click()

// Good - Cypress waits automatically
cy.get('.element').click()

Use data-testid for stable selectors:

// Prefer data-testid over CSS classes
cy.get('[data-testid="submit-button"]').click()

Performance Optimization

E2E tests provide confidence but consume time and resources. Optimizing test execution improves the feedback loop during development. For teams building scalable web applications, implementing parallel test execution with Cypress Dashboard and grouping related assertions to fail fast can significantly reduce test runtime. Clean up test data between runs and implement selective test execution for faster development cycles.

Advanced Patterns and Techniques

Page Objects for Complex Tests

Page objects encapsulate page-specific selectors and operations, improving test maintainability when page structure changes. Rather than scattering selectors throughout tests, a page object class centralizes element definitions and provides methods for common interactions:

// cypress/support/pages/ProductPage.ts

class ProductPage {
 elements = {
 addToCart: () => cy.findByRole('button', { name: /add to cart/i }),
 quantity: () => cy.findByLabelText(/quantity/i),
 price: () => cy.findByTestId('product-price'),
 breadcrumb: () => cy.findByRole('navigation', { name: /breadcrumbs/i })
 }

 addToCart(quantity: number = 1) {
 this.elements.quantity().clear().type(String(quantity))
 this.elements.addToCart().click()
 }

 verifyPrice(expectedPrice: string) {
 this.elements.price().should('contain', expectedPrice)
 }
}

export const productPage = new ProductPage()

Component Testing in Next.js

Cypress supports component testing for individual React components in isolation, complementing the integration testing strategies used for full application testing:

// cypress/components/Button.cy.tsx

import Button from '@/components/Button'

describe('Button Component', () => {
 it('renders correctly with default props', () => {
 cy.mount(<Button>Click me</Button>)
 cy.findByRole('button').should('contain', 'Click me')
 })

 it('calls onClick when clicked', () => {
 const onClick = cy.stub().as('clickHandler')
 cy.mount(<Button onClick={onClick}>Click me</Button>)
 cy.findByRole('button').click()
 cy.get('@clickHandler').should('have.been.calledOnce')
 })

 it('is disabled when disabled prop is set', () => {
 cy.mount(<Button disabled>Click me</Button>)
 cy.findByRole('button').should('be.disabled')
 })
})

CI/CD Integration

# .github/workflows/e2e-tests.yml
name: E2E Tests

on: [push, pull_request]

jobs:
 e2e-tests:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v3
 
 - name: Setup Node.js
 uses: actions/setup-node@v3
 with:
 node-version: '18'
 
 - name: Install dependencies
 run: npm ci
 
 - name: Build Next.js app
 run: npm run build
 
 - name: Run Cypress tests
 uses: cypress-io/github-action@v5
 with:
 build: npm run build
 start: npm run start
 wait-on: 'http://localhost:3000'
 env:
 CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

Frequently Asked Questions

Ready to Build Reliable Next.js Applications?

Our team specializes in building performant, tested web applications with Next.js. Let's discuss how we can help improve your testing strategy.

Sources

  1. Next.js Documentation: Testing - Cypress - Official Next.js guidance on Cypress integration, including setup commands and configuration details.

  2. Cypress Documentation: Writing Your First Test - Core Cypress testing concepts and assertion patterns.

  3. LogRocket: E2E testing in Next.js with Cypress and TypeScript - Comprehensive tutorial covering TypeScript configuration, custom commands, and practical code examples.