Skip to main content

Next.js Cypress Integration

Introduction

End-to-end (E2E) testing is a critical part of ensuring your Next.js application works correctly from a user's perspective. Unlike unit or integration tests that verify individual components or functions, E2E tests simulate real user interactions with your application, testing the entire workflow from start to finish.

Cypress is a popular JavaScript testing framework designed specifically for modern web applications. It provides a robust set of features for writing, running, and debugging E2E tests.

In this guide, we'll learn how to integrate Cypress with a Next.js application and write effective tests to ensure your application works as expected.

Prerequisites

Before we begin, you should have:

  • Basic knowledge of Next.js
  • A Next.js application set up
  • Node.js and npm/yarn installed

Setting Up Cypress in a Next.js Project

Step 1: Install Cypress

First, let's add Cypress as a development dependency to your Next.js project:

bash
# Using npm
npm install --save-dev cypress

# Using yarn
yarn add --dev cypress

Step 2: Initialize Cypress

After installation, initialize Cypress by running:

bash
# Using npm
npx cypress open

# Using yarn
yarn cypress open

This command will:

  1. Create a cypress folder in your project
  2. Generate the necessary configuration files
  3. Open the Cypress Test Runner interface

Step 3: Configure Cypress for Next.js

Create or modify the cypress.config.js file in your project root:

javascript
const { defineConfig } = require("cypress");

module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});

The baseUrl configuration sets the default URL for your tests, which should be the local development URL of your Next.js application.

Step 4: Update package.json

Add Cypress scripts to your package.json file for convenience:

json
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"cypress": "cypress open",
"cypress:headless": "cypress run",
"e2e": "start-server-and-test dev http://localhost:3000 cypress",
"e2e:headless": "start-server-and-test dev http://localhost:3000 cypress:headless"
}

Install the start-server-and-test package:

bash
npm install --save-dev start-server-and-test
# or
yarn add --dev start-server-and-test

This setup allows you to run:

  • npm run cypress or yarn cypress to open the Cypress Test Runner
  • npm run e2e or yarn e2e to start both your Next.js server and Cypress

Writing Your First Test

Let's create a basic test to verify that our homepage loads correctly.

Create a new file cypress/e2e/homepage.cy.js:

javascript
describe('Homepage', () => {
it('should navigate to the homepage', () => {
// Start from the index page
cy.visit('/')

// The homepage should contain an h1 with "Welcome to"
cy.get('h1').contains('Welcome to', { matchCase: false })

// The page should contain a link to the about page
cy.get('a').contains('About').should('have.attr', 'href', '/about')
})
})

This simple test:

  1. Visits the homepage of your application
  2. Checks if there's an h1 element containing the text "Welcome to"
  3. Verifies that there's a link to the about page

Testing Navigation in Next.js

Navigation is a crucial part of any web application. Let's create a test that verifies our navigation works correctly:

javascript
describe('Navigation', () => {
it('should navigate between pages', () => {
// Start from the index page
cy.visit('/')

// Find a link with an href attribute containing "about" and click it
cy.get('a[href*="about"]').click()

// The new url should include "/about"
cy.url().should('include', '/about')

// The new page should contain an h1 with "About"
cy.get('h1').contains('About')

// Go back to the homepage
cy.get('a[href="/"]').click()

// The url should be back to the root
cy.url().should('eq', Cypress.config().baseUrl + '/')
})
})

This test:

  1. Visits the homepage
  2. Clicks a link that navigates to the about page
  3. Verifies the URL has changed and the correct content appears
  4. Navigates back to the homepage and verifies the URL

Testing Form Submissions

Forms are common in web applications. Let's test a simple contact form:

javascript
describe('Contact Form', () => {
it('should submit the contact form', () => {
// Visit the contact page
cy.visit('/contact')

// Fill out form fields
cy.get('input[name="name"]').type('Test User')
cy.get('input[name="email"]').type('[email protected]')
cy.get('textarea[name="message"]').type('This is a test message')

// Submit the form
cy.get('form').submit()

// Verify success message appears
cy.get('.success-message').should('be.visible')
cy.get('.success-message').contains('Thank you for your message')

// Alternatively, if the form uses a button:
// cy.get('button[type="submit"]').click()
})
})

This test:

  1. Visits the contact page
  2. Fills out each form field
  3. Submits the form
  4. Verifies that a success message appears

Testing API Interactions

Next.js applications often interact with APIs. Cypress allows you to test these interactions by intercepting network requests:

javascript
describe('API Interactions', () => {
it('should display data from API', () => {
// Stub the API response
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
]
}).as('getUsers')

// Visit the users page
cy.visit('/users')

// Wait for the API call to complete
cy.wait('@getUsers')

// Verify the user data is displayed correctly
cy.get('.user-item').should('have.length', 2)
cy.get('.user-item').first().contains('John Doe')
cy.get('.user-item').last().contains('Jane Smith')
})

it('should handle API errors gracefully', () => {
// Stub an API error
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { message: 'Server error' }
}).as('getUsersError')

// Visit the users page
cy.visit('/users')

// Wait for the API call to complete
cy.wait('@getUsersError')

// Verify error message is displayed
cy.get('.error-message').should('be.visible')
cy.get('.error-message').contains('Failed to load users')
})
})

These tests:

  1. Intercept API calls and provide mock responses
  2. Verify the application correctly displays the data or handles errors

Testing Authentication

Authentication is a common requirement in applications. Let's test a login workflow:

javascript
describe('Authentication', () => {
beforeEach(() => {
// Clear cookies and localStorage before each test
cy.clearCookies()
cy.clearLocalStorage()
})

it('should allow a user to log in', () => {
// Visit the login page
cy.visit('/login')

// Fill out login form
cy.get('input[name="email"]').type('[email protected]')
cy.get('input[name="password"]').type('password123')

// Intercept the login API request
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: {
token: 'fake-jwt-token',
user: { id: 1, name: 'Test User', email: '[email protected]' }
}
}).as('loginRequest')

// Submit the login form
cy.get('form').submit()

// Wait for the API call
cy.wait('@loginRequest')

// Verify we're redirected to the dashboard after login
cy.url().should('include', '/dashboard')

// Verify user information is displayed
cy.get('.user-info').contains('Test User')
})

it('should show error message with invalid credentials', () => {
// Visit the login page
cy.visit('/login')

// Fill out login form with invalid credentials
cy.get('input[name="email"]').type('[email protected]')
cy.get('input[name="password"]').type('wrongpassword')

// Intercept the login API request
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { message: 'Invalid credentials' }
}).as('loginRequest')

// Submit the login form
cy.get('form').submit()

// Wait for the API call
cy.wait('@loginRequest')

// Verify we're still on the login page
cy.url().should('include', '/login')

// Verify error message is displayed
cy.get('.error-message').contains('Invalid credentials')
})
})

These tests:

  1. Test successful login and verify the user is redirected to the dashboard
  2. Test login with invalid credentials and verify an error message is shown

Best Practices for Cypress Tests in Next.js

Here are some best practices to follow when writing Cypress tests for your Next.js application:

1. Use data-* attributes for testing

Instead of relying on CSS selectors that might change during development, use dedicated data-* attributes:

jsx
// In your React component
<button data-testid="submit-button" type="submit">
Submit
</button>

// In your Cypress test
cy.get('[data-testid="submit-button"]').click()

2. Set up a testing database

For production applications, consider setting up a separate testing database to avoid corrupting production data during tests.

3. Clean up after tests

Make sure your tests clean up any data they create to ensure test independence:

javascript
afterEach(() => {
// Cleanup code here, e.g., deleting test users
})

4. Use custom commands for common operations

For operations you perform frequently, create Cypress custom commands:

javascript
// In cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login')
cy.get('input[name="email"]').type(email)
cy.get('input[name="password"]').type(password)
cy.get('form').submit()
})

// In your test
cy.login('[email protected]', 'password123')

5. Organize tests with folders

As your test suite grows, organize tests into logical folders:

cypress/
e2e/
authentication/
login.cy.js
logout.cy.js
navigation/
routing.cy.js
forms/
contact.cy.js

Real-world Example: Testing a Todo App

Let's put everything together and write comprehensive tests for a simple Todo application built with Next.js.

First, let's assume our Todo app has the following features:

  • Display a list of todos
  • Add new todos
  • Mark todos as completed
  • Delete todos

Here's how we might test it:

javascript
describe('Todo App', () => {
beforeEach(() => {
// Setup: Visit the app and clear any existing todos
cy.visit('/')
cy.get('body').then(($body) => {
if ($body.find('[data-testid="delete-todo"]').length > 0) {
cy.get('[data-testid="delete-todo"]').each(($el) => {
cy.wrap($el).click()
})
}
})
})

it('should add a new todo', () => {
const todoText = 'Buy groceries'

// Add a new todo
cy.get('[data-testid="new-todo-input"]').type(todoText)
cy.get('[data-testid="add-todo-button"]').click()

// Verify the todo was added
cy.get('[data-testid="todo-item"]').should('have.length', 1)
cy.get('[data-testid="todo-text"]').should('contain', todoText)
})

it('should mark a todo as completed', () => {
const todoText = 'Learn Cypress'

// Add a new todo
cy.get('[data-testid="new-todo-input"]').type(todoText)
cy.get('[data-testid="add-todo-button"]').click()

// Mark it as completed
cy.get('[data-testid="complete-checkbox"]').click()

// Verify it's marked as completed
cy.get('[data-testid="todo-item"]').should('have.class', 'completed')
})

it('should delete a todo', () => {
const todoText = 'Delete me'

// Add a new todo
cy.get('[data-testid="new-todo-input"]').type(todoText)
cy.get('[data-testid="add-todo-button"]').click()

// Verify it was added
cy.get('[data-testid="todo-item"]').should('have.length', 1)

// Delete it
cy.get('[data-testid="delete-todo"]').click()

// Verify it was deleted
cy.get('[data-testid="todo-item"]').should('have.length', 0)
})

it('should persist todos on page reload', () => {
const todoText = 'Persist after reload'

// Add a new todo
cy.get('[data-testid="new-todo-input"]').type(todoText)
cy.get('[data-testid="add-todo-button"]').click()

// Reload the page
cy.reload()

// Verify the todo still exists
cy.get('[data-testid="todo-item"]').should('have.length', 1)
cy.get('[data-testid="todo-text"]').should('contain', todoText)
})
})

This comprehensive test suite:

  1. Cleans up existing todos before each test
  2. Tests adding new todos
  3. Tests marking todos as completed
  4. Tests deleting todos
  5. Tests persistence after page reload

Integrating Cypress with CI/CD

For continuous integration, you can run Cypress tests in headless mode. Here's an example of how to integrate with GitHub Actions:

Create a file .github/workflows/cypress.yml:

yaml
name: Cypress Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16

- name: Install dependencies
run: npm ci

- name: Build Next.js app
run: npm run build

- name: Cypress run
uses: cypress-io/github-action@v5
with:
build: npm run build
start: npm start
wait-on: 'http://localhost:3000'

This workflow:

  1. Checks out your code
  2. Sets up Node.js
  3. Installs dependencies
  4. Builds the Next.js application
  5. Runs Cypress tests against the built application

Summary

In this guide, we've learned how to:

  1. Set up Cypress in a Next.js project
  2. Write basic tests for navigation and content
  3. Test forms and form submissions
  4. Mock API responses for testing
  5. Test authentication flows
  6. Apply best practices for Cypress testing
  7. Create a comprehensive test suite for a Todo application
  8. Integrate Cypress with GitHub Actions for CI/CD

By implementing end-to-end tests with Cypress, you can ensure your Next.js application works correctly from a user's perspective, catching bugs before they reach production.

Additional Resources

Exercise

  1. Implement Cypress tests for a basic Next.js blog with:

    • A homepage listing blog posts
    • Individual blog post pages
    • A search function for blog posts
    • A contact form
  2. Extend the Todo app example with:

    • Filtering todos (all, active, completed)
    • Editing existing todos
    • Adding due dates to todos
  3. Create a custom Cypress command for testing a Next.js authentication flow that persists login state across tests.

Happy testing!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)