Skip to main content
Back to Blog
Tutorials
4 min read
November 26, 2024

How to Add End-to-End Testing with Playwright in Next.js

Set up end-to-end testing with Playwright for your Next.js app including page tests, API tests, and CI integration.

Ryel Banfield

Founder & Lead Developer

End-to-end tests verify your application works from the user's perspective. Playwright makes this reliable and fast.

Step 1: Install Playwright

pnpm add -D @playwright/test
npx playwright install

Step 2: Configuration

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? "github" : "html",

  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "on-first-retry",
  },

  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
    { name: "mobile-safari", use: { ...devices["iPhone 13"] } },
  ],

  webServer: {
    command: "pnpm dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Step 3: Homepage Test

// e2e/homepage.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Homepage", () => {
  test("should display the hero section", async ({ page }) => {
    await page.goto("/");

    await expect(page).toHaveTitle(/RCB Software/);
    await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
    await expect(page.getByRole("link", { name: /get started|contact/i })).toBeVisible();
  });

  test("should navigate to services page", async ({ page }) => {
    await page.goto("/");
    await page.getByRole("link", { name: /services/i }).first().click();
    await expect(page).toHaveURL(/\/services/);
  });

  test("should load within performance budget", async ({ page }) => {
    const start = Date.now();
    await page.goto("/");
    await page.waitForLoadState("networkidle");
    const loadTime = Date.now() - start;

    expect(loadTime).toBeLessThan(5000);
  });
});

Step 4: Contact Form Test

// e2e/contact.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Contact Form", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/contact");
  });

  test("should show validation errors for empty form", async ({ page }) => {
    await page.getByRole("button", { name: /send|submit/i }).click();

    await expect(page.getByText(/name is required|please enter/i)).toBeVisible();
    await expect(page.getByText(/email is required|valid email/i)).toBeVisible();
  });

  test("should submit form successfully", async ({ page }) => {
    // Mock the API response
    await page.route("**/api/contact", async (route) => {
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({ success: true }),
      });
    });

    await page.getByLabel(/name/i).fill("Test User");
    await page.getByLabel(/email/i).fill("test@example.com");
    await page.getByLabel(/company/i).fill("Test Company");
    await page.getByLabel(/message/i).fill("This is a test message for e2e testing.");

    await page.getByRole("button", { name: /send|submit/i }).click();

    await expect(page.getByText(/thank you|success|sent/i)).toBeVisible();
  });

  test("should handle API errors gracefully", async ({ page }) => {
    await page.route("**/api/contact", async (route) => {
      await route.fulfill({ status: 500 });
    });

    await page.getByLabel(/name/i).fill("Test User");
    await page.getByLabel(/email/i).fill("test@example.com");
    await page.getByLabel(/message/i).fill("Test message");

    await page.getByRole("button", { name: /send|submit/i }).click();

    await expect(page.getByText(/error|try again|failed/i)).toBeVisible();
  });
});

Step 5: Navigation and Responsive Tests

// e2e/navigation.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Navigation", () => {
  test("desktop navigation links work", async ({ page }) => {
    await page.goto("/");

    const navLinks = [
      { name: /services/i, url: "/services" },
      { name: /about/i, url: "/about" },
      { name: /pricing/i, url: "/pricing" },
      { name: /blog/i, url: "/blog" },
      { name: /contact/i, url: "/contact" },
    ];

    for (const { name, url } of navLinks) {
      await page.goto("/");
      const link = page.getByRole("navigation").getByRole("link", { name }).first();
      if (await link.isVisible()) {
        await link.click();
        await expect(page).toHaveURL(new RegExp(url));
      }
    }
  });

  test("mobile menu opens and closes", async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto("/");

    // Open menu
    const menuButton = page.getByRole("button", { name: /menu|toggle/i });
    if (await menuButton.isVisible()) {
      await menuButton.click();
      await expect(page.getByRole("navigation")).toBeVisible();

      // Close menu
      await menuButton.click();
    }
  });
});

Step 6: Authentication Flow Test

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("should redirect unauthenticated users to login", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page).toHaveURL(/\/login|\/sign-in/);
  });

  test("should show login form", async ({ page }) => {
    await page.goto("/login");
    await expect(page.getByLabel(/email/i)).toBeVisible();
    await expect(page.getByLabel(/password/i)).toBeVisible();
    await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
  });
});

Step 7: Visual Regression Testing

// e2e/visual.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Visual Regression", () => {
  test("homepage matches snapshot", async ({ page }) => {
    await page.goto("/");
    await page.waitForLoadState("networkidle");
    await expect(page).toHaveScreenshot("homepage.png", {
      maxDiffPixels: 100,
      fullPage: true,
    });
  });

  test("dark mode matches snapshot", async ({ page }) => {
    await page.goto("/");
    await page.emulateMedia({ colorScheme: "dark" });
    await page.waitForLoadState("networkidle");
    await expect(page).toHaveScreenshot("homepage-dark.png", {
      maxDiffPixels: 100,
      fullPage: true,
    });
  });
});

Step 8: Accessibility Tests

// e2e/accessibility.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("Accessibility", () => {
  test("homepage should have no accessibility violations", async ({ page }) => {
    await page.goto("/");
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
  });

  test("contact page should have no accessibility violations", async ({ page }) => {
    await page.goto("/contact");
    const results = await new AxeBuilder({ page })
      .exclude(".third-party-widget") // Exclude elements you cannot control
      .analyze();
    expect(results.violations).toEqual([]);
  });
});

Step 9: CI/CD Integration

# .github/workflows/e2e.yml
name: E2E Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: npx playwright install --with-deps
      - run: pnpm exec playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Step 10: Package.json Scripts

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:debug": "playwright test --debug",
    "test:e2e:update": "playwright test --update-snapshots"
  }
}

Summary

  • Test real user flows: navigation, forms, authentication
  • Mock API responses for reliable tests
  • Visual regression catches unintended UI changes
  • Accessibility tests with axe-core integration
  • Run in CI with artifact uploads for debugging failures

Need Quality Assurance?

We build tested, reliable web applications with comprehensive quality assurance processes. Contact us to discuss your project.

testingPlaywrighte2eNext.jsCI/CDtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles