Skip to main content

Next.js E2E Testing

Introduction

End-to-End (E2E) testing is a crucial part of ensuring your Next.js application works as expected from a user's perspective. Unlike unit or integration tests that focus on individual parts of your code, E2E tests validate the entire application flow by simulating real user interactions.

In this guide, we'll learn how to set up and write E2E tests for Next.js applications using popular testing frameworks like Playwright and Cypress. We'll cover everything from basic setup to advanced testing scenarios to help you build reliable applications.

Why E2E Testing Matters

E2E testing helps you:

  • Catch issues that might only appear when all parts of your application work together
  • Ensure critical user journeys work as expected
  • Test your application in conditions similar to how users will experience it
  • Validate both frontend and backend integrations

Setting Up E2E Testing in Next.js

There are several excellent tools available for E2E testing Next.js applications. We'll focus on the two most popular options: Playwright and Cypress.

Option 1: Playwright

Playwright is a modern E2E testing framework developed by Microsoft that allows testing across all modern browsers.

Installation

Let's start by adding Playwright to your Next.js project:

bash
npm init playwright@latest

When prompted, select the following options:

  • Choose TypeScript (or JavaScript if that's what your project uses)
  • Add a GitHub Actions workflow for testing
  • Install browsers (Chrome, Firefox, WebKit)

This command sets up a playwright.config.ts file and a tests folder in your project.

Basic Configuration

The generated playwright.config.ts file will look something like this:

typescript
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

This configuration:

  • Sets the test directory
  • Configures which browsers to test on (Chrome, Firefox, Safari)
  • Automatically starts your Next.js development server before tests

Writing Your First Playwright Test

Let's create a simple test to verify that your homepage loads correctly:

typescript
// tests/home.spec.ts
import { test, expect } from '@playwright/test';

test('homepage has the correct title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/My Next.js App/);
});

test('navigation works correctly', async ({ page }) => {
// Start from the homepage
await page.goto('/');

// Find and click a navigation link to the About page
await page.click('text=About');

// Verify URL has changed to the About page
await expect(page).toHaveURL(/.*about/);

// Verify some content on the About page
await expect(page.locator('h1')).toContainText('About Us');
});

Running Playwright Tests

To run your tests:

bash
npx playwright test

To see the test results in a browser:

bash
npx playwright show-report

Option 2: Cypress

Cypress is another popular E2E testing framework with an easy-to-use interface.

Installation

Add Cypress to your Next.js project:

bash
npm install cypress --save-dev

Initialize Cypress:

bash
npx cypress open

This will create a cypress directory with example tests and configurations.

Basic Configuration

Update the cypress.config.js file to work with your Next.js app:

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

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

Writing Your First Cypress Test

Create a test file in the cypress/e2e directory:

javascript
// cypress/e2e/home.cy.js
describe('Homepage', () => {
beforeEach(() => {
// Visit the homepage before each test
cy.visit('/');
});

it('displays the correct title', () => {
// Check if the page has the expected title
cy.title().should('include', 'My Next.js App');
});

it('allows navigation to other pages', () => {
// Click on the About link
cy.contains('About').click();

// Check if URL changes to About page
cy.url().should('include', '/about');

// Verify content on the About page
cy.get('h1').should('contain', 'About Us');
});
});

Running Cypress Tests

To run your tests in headless mode:

bash
npx cypress run

To open the Cypress UI for interactive testing:

bash
npx cypress open

Advanced E2E Testing Techniques

Now that we have the basics set up, let's explore more advanced E2E testing scenarios.

Testing Forms

Forms are a common element in web applications. Here's how to test a login form:

typescript
// Using Playwright
test('login form works correctly', async ({ page }) => {
// Navigate to login page
await page.goto('/login');

// Fill in login form
await page.fill('input[name="email"]', '[email protected]');
await page.fill('input[name="password"]', 'password123');

// Submit the form
await page.click('button[type="submit"]');

// Verify user is redirected to dashboard after login
await expect(page).toHaveURL(/.*dashboard/);

// Verify user-specific content is visible
await expect(page.locator('.user-greeting')).toContainText('Welcome, Test User');
});
javascript
// Using Cypress
describe('Login Form', () => {
it('allows users to login', () => {
cy.visit('/login');

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

// Submit the form
cy.get('button[type="submit"]').click();

// Verify redirect to dashboard
cy.url().should('include', '/dashboard');

// Verify user content
cy.get('.user-greeting').should('contain', 'Welcome, Test User');
});
});

Testing API Interactions

Modern web apps interact with APIs. Here's how to test components that fetch data:

typescript
// Using Playwright
test('product list loads from API', async ({ page }) => {
// Navigate to products page
await page.goto('/products');

// Wait for API data to load
await page.waitForSelector('.product-item');

// Verify multiple products are displayed
const productCount = await page.locator('.product-item').count();
expect(productCount).toBeGreaterThan(0);

// Verify product details
await expect(page.locator('.product-item').first()).toContainText('Product Name');
await expect(page.locator('.product-item').first()).toContainText('$');
});
javascript
// Using Cypress
describe('Product List', () => {
it('displays products from API', () => {
cy.visit('/products');

// Wait for products to load
cy.get('.product-item').should('exist');

// Check if multiple products are displayed
cy.get('.product-item').should('have.length.greaterThan', 0);

// Check product details
cy.get('.product-item').first().should('contain', 'Product Name');
cy.get('.product-item').first().should('contain', '$');
});
});

Mocking API Responses

Sometimes you want to test how your app behaves with specific API responses:

typescript
// Using Playwright
test('displays error message when API fails', async ({ page }) => {
// Mock the API to return an error
await page.route('/api/products', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' })
});
});

// Navigate to products page
await page.goto('/products');

// Verify error message is displayed
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Failed to load products');
});
javascript
// Using Cypress
describe('API Error Handling', () => {
it('shows error message when product API fails', () => {
// Intercept and mock the API request
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Server error' }
}).as('productsError');

cy.visit('/products');

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

// Verify error message
cy.get('.error-message')
.should('be.visible')
.and('contain', 'Failed to load products');
});
});

Testing Authentication

Testing protected routes is essential for security:

typescript
// Using Playwright
test('protected route redirects unauthenticated users', async ({ page }) => {
// Try to access protected route directly
await page.goto('/dashboard');

// Verify redirect to login
await expect(page).toHaveURL(/.*login/);
});

test('authenticated user can access protected route', async ({ page }) => {
// Set up authentication cookie
await page.context().addCookies([{
name: 'auth-token',
value: 'valid-test-token',
url: 'http://localhost:3000',
}]);

// Access protected route
await page.goto('/dashboard');

// Verify user stays on dashboard
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('h1')).toContainText('Dashboard');
});

Real-World Example: E-commerce Checkout Flow

Let's put everything together with a comprehensive test for an e-commerce checkout flow:

typescript
// Using Playwright
test('complete checkout process', async ({ page }) => {
// 1. Log in
await page.goto('/login');
await page.fill('input[name="email"]', '[email protected]');
await page.fill('input[name="password"]', 'securepassword');
await page.click('button[type="submit"]');

// 2. Browse products
await page.goto('/products');
await page.waitForSelector('.product-item');

// 3. Add product to cart
await page.click('.product-item:first-child .add-to-cart');

// 4. Verify cart updates
await expect(page.locator('.cart-count')).toContainText('1');

// 5. Go to cart
await page.click('.cart-icon');
await expect(page).toHaveURL(/.*cart/);

// 6. Proceed to checkout
await page.click('button:text("Checkout")');
await expect(page).toHaveURL(/.*checkout/);

// 7. Fill shipping information
await page.fill('input[name="address"]', '123 Main St');
await page.fill('input[name="city"]', 'Anytown');
await page.fill('input[name="zip"]', '12345');
await page.selectOption('select[name="country"]', 'United States');

// 8. Select shipping method
await page.click('input[name="shipping"][value="standard"]');

// 9. Continue to payment
await page.click('button:text("Continue to Payment")');

// 10. Fill payment details (in a real iframe, this would be more complex)
await page.fill('input[name="cardNumber"]', '4111111111111111');
await page.fill('input[name="cardExpiry"]', '12/25');
await page.fill('input[name="cardCvc"]', '123');

// 11. Complete order
await page.click('button:text("Place Order")');

// 12. Verify order confirmation
await expect(page).toHaveURL(/.*order-confirmation/);
await expect(page.locator('.order-status')).toContainText('Order Confirmed');
await expect(page.locator('.order-number')).toBeVisible();
});

Best Practices for E2E Testing

To get the most out of your E2E tests:

  1. Test critical user flows first: Focus on the journeys most important to your application's functionality.

  2. Keep tests independent: Each test should be able to run independently of others.

  3. Use data attributes for testing: Add data-testid attributes to elements you want to select in tests:

    jsx
    <button data-testid="submit-button">Submit</button>

    Then select them in tests:

    typescript
    // Playwright
    await page.click('[data-testid="submit-button"]');

    // Cypress
    cy.get('[data-testid="submit-button"]').click();
  4. Don't over-test: E2E tests are slower than unit tests. Focus on key user flows rather than testing every edge case.

  5. Handle environment variables: Use different environments for testing vs. production.

  6. Set appropriate timeouts: Adjust timeouts for operations that might take longer, like API calls or animations.

  7. Visual testing: Consider adding visual testing to catch UI regressions:

    typescript
    // Playwright visual comparison
    await expect(page).toHaveScreenshot('homepage.png');

Setting Up CI/CD Integration

To run your E2E tests in a CI/CD pipeline:

GitHub Actions Example

yaml
name: E2E Tests

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

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: playwright-report
path: playwright-report/

Debugging E2E Tests

When tests fail, you'll need to debug them:

Playwright Debugging

typescript
// Add this to pause execution and open inspector
await page.pause();

// Or run tests with the debug flag
// npx playwright test --debug

Cypress Debugging

javascript
// Use cy.pause() to pause test execution
cy.get('.element').click().pause();

// Or use .debug() to log information
cy.get('.element').debug();

Summary

E2E testing in Next.js applications provides confidence that your application works as expected from the user's perspective. By using tools like Playwright or Cypress, you can:

  • Automate testing of critical user flows
  • Catch regressions before they reach production
  • Test complex interactions like forms, API calls, and authentication
  • Ensure your application works across different browsers

Remember that E2E tests complement (not replace) unit and integration tests. A balanced testing strategy includes all types of tests, with more unit tests at the base and fewer but critical E2E tests at the top.

Additional Resources

Exercises

  1. Basic Navigation Test: Create a test that verifies navigation between different pages of your Next.js app.

  2. Form Validation Test: Write a test for a registration form that checks both successful submission and validation errors.

  3. API Integration Test: Create a test that mocks different API responses and verifies your app handles them correctly.

  4. Authentication Flow: Test the complete login, authentication, and logout flow of your application.

  5. Responsive Design Test: Write tests that verify your application works correctly on different screen sizes.



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