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:
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:
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:
// 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:
npx playwright test
To see the test results in a browser:
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:
npm install cypress --save-dev
Initialize Cypress:
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:
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:
// 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:
npx cypress run
To open the Cypress UI for interactive testing:
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:
// 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');
});
// 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:
// 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('$');
});
// 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:
// 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');
});
// 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:
// 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:
// 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:
-
Test critical user flows first: Focus on the journeys most important to your application's functionality.
-
Keep tests independent: Each test should be able to run independently of others.
-
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(); -
Don't over-test: E2E tests are slower than unit tests. Focus on key user flows rather than testing every edge case.
-
Handle environment variables: Use different environments for testing vs. production.
-
Set appropriate timeouts: Adjust timeouts for operations that might take longer, like API calls or animations.
-
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
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
// Add this to pause execution and open inspector
await page.pause();
// Or run tests with the debug flag
// npx playwright test --debug
Cypress Debugging
// 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
- Official Playwright Documentation
- Official Cypress Documentation
- Next.js Testing Documentation
- Testing Library - Useful companion for both Playwright and Cypress
Exercises
-
Basic Navigation Test: Create a test that verifies navigation between different pages of your Next.js app.
-
Form Validation Test: Write a test for a registration form that checks both successful submission and validation errors.
-
API Integration Test: Create a test that mocks different API responses and verifies your app handles them correctly.
-
Authentication Flow: Test the complete login, authentication, and logout flow of your application.
-
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! :)