Playwright’s E2E tests are actually running your application in a real browser, not simulating it.

Let’s see this in action. Imagine you have a simple Next.js app with a page that fetches data.

pages/index.js

import Head from 'next/head';

export async function getServerSideProps() {
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();
  return { props: { data } };
}

export default function Home({ data }) {
  return (
    <div>
      <Head>
        <title>Data Fetcher</title>
      </Head>
      <h1>Data from API:</h1>
      <p>{data.message}</p>
    </div>
  );
}

And a simple API endpoint that returns JSON:

pages/api/data.js

export default function handler(req, res) {
  res.status(200).json({ message: 'Hello from the API!' });
}

Now, let’s write an E2E test using Playwright to verify this page.

tests/example.spec.js

import { test, expect } from '@playwright/test';

test('should display data from API', async ({ page }) => {
  await page.goto('http://localhost:3000'); // Navigates to your running Next.js app
  await expect(page.getByText('Hello from the API!')).toBeVisible();
});

When you run npm run test:e2e (assuming you’ve configured Playwright), Playwright will spin up a real browser (like Chromium, Firefox, or WebKit), navigate to http://localhost:3000, and look for the text "Hello from the API!". It’s not mocking the browser; it is the browser.

This is powerful because it catches issues that unit tests can’t, like race conditions, network latency, or browser-specific rendering bugs.

The Mental Model: Bridging the Gap

Next.js applications have a few distinct layers when it comes to testing:

  1. Unit Tests (Jest): These are fast and focus on individual functions, components, or hooks in isolation. Jest is your go-to here. It mocks out dependencies, so you’re testing a small piece of logic without worrying about the network, the DOM, or the Next.js server. For example, testing a utility function or a React component without its getServerSideProps.

    components/Button.test.js

    import { render, screen } from '@testing-library/react';
    import Button from './Button';
    
    test('renders button with text', () => {
      render(<Button>Click Me</Button>);
      expect(screen.getByText('Click Me')).toBeInTheDocument();
    });
    
  2. Integration Tests: These test how multiple units work together. You might test a page that uses several components, but still mock external services.

  3. End-to-End Tests (Playwright): These are the highest level. Playwright launches a real browser and interacts with your application as a user would. It hits your Next.js server (either the dev server or a built production version), makes real API calls (or you can mock them at a higher level), and interacts with the DOM. This is the closest you get to simulating a real user’s experience.

The key is understanding when to use which. Jest gives you confidence in your individual building blocks. Playwright gives you confidence that those blocks assemble correctly in a real environment.

The Levers You Control

  • Jest:
    • jest.mock(): To isolate components and functions from external dependencies.
    • @testing-library/react: To interact with your rendered components in a user-centric way (e.g., getByText, fireEvent).
    • jest.spyOn(): To track function calls and their arguments.
  • Playwright:
    • page.goto(): To navigate to specific URLs.
    • page.fill(), page.click(): To simulate user input and interactions.
    • expect(locator).toBeVisible(): To assert that elements are present and visible.
    • page.route(): To intercept network requests and mock responses (useful for isolating E2E tests from flaky external APIs or speeding them up).
    • page.waitForSelector(), page.waitForNavigation(): To handle asynchronous operations and ensure the page is ready.

The "Gotcha"

When running Playwright tests against a Next.js development server (next dev), the fetch calls made by getServerSideProps or getStaticProps are actually going to your local Next.js API routes (like pages/api/data.js) or external services if that’s what your API route calls. If you have a pages/api/data.js route that calls https://api.example.com/data, Playwright will trigger that API route, which in turn might call the real https://api.example.com/data. This is different from Jest unit tests where you’d typically mock fetch directly.

This means your E2E tests are testing the integration of your frontend pages with your Next.js API routes, and potentially with external services. If you want to test the page without hitting any actual network or API routes, you’d use page.route() to intercept those requests.

The next logical step after mastering these two testing approaches is to explore how to effectively integrate them into your CI/CD pipeline to ensure every commit is thoroughly tested.

Want structured learning?

Take the full Nextjs course →