cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

End-to-End Testing Next.js with Playwright

Test a Next.js app in real Chrome with Playwright: async Server Components, streaming, navigations, forms with and without JavaScript, sign-in, and CI.

By the end, you can
  • 01
    Run Playwright against the built appLet Playwright build and start the app with webServer, and drive the Chrome on your computer.
  • 02
    Test what only the running app doesCheck async pages, streamed parts, client-side navigations and Server Action forms, after hydration and without one fixed wait.
  • 03
    Sign in once and run in CIReuse a signed-in session with storageState, and run the tests in GitHub Actions with traces for failures.

The tea shop, tested in real Chrome

Testing components ended with a list of what a unit test can't check, because Next.js isn't running in it: an async Server Component, a part that streams in, a client-side navigation, and a Server Action's real request. An end-to-end test checks them from the outside. It builds and starts the whole app, opens its pages in a real browser, and clicks and types the way a visitor does.

Playwright is the tool Next.js's docs set up for it (version 1.64.1 on October 30, 2026). Its tests read like the React course's: find by role and label, click, check (Testing React components, and Testing the DOM & end to end for Playwright on its own). Here is the tea shop with three files of Playwright tests, a real Next.js 16.4.3 project. The recorder ran npx playwright test in it the way a job in continuous integration (CI) does: a server that runs the tests on every change, then built it again and asked next start for two pages.

The tea shop and its Playwright testsNext.js
app/newsletter/thanks/teas/[id]/e2e/lib/
playwright.config.ts
import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.PORT ?? 3000);const baseURL = `http://localhost:${port}`;const stockURL = `http://localhost:${port + 1}`; export default defineConfig({  testDir: "./e2e",  forbidOnly: !!process.env.CI,  retries: process.env.CI ? 2 : 0,  workers: process.env.CI ? 1 : undefined,  reporter: "list",  use: { baseURL, trace: "on-first-retry" },  projects: [{ name: "chrome", use: { ...devices["Desktop Chrome"], channel: "chrome" } }],  webServer: [    {      command: "node e2e/stock-api.mjs",      url: `${stockURL}/stock/mint`,      env: { STOCK_PORT: String(port + 1) },      reuseExistingServer: false,    },    {      command: "npm run build && npm run start",      url: baseURL,      env: { STOCK_API_URL: stockURL },      reuseExistingServer: false,      timeout: 120_000,    },  ],});
Recorded from Next.js 16.4.3
$ npx playwright testRunning 6 tests using 1 worker   ✓  1 [chrome] › e2e/navigation.spec.ts:4:5 › a link keeps what you typed in the layout  ✓  2 [chrome] › e2e/navigation.spec.ts:19:5 › a reload starts the layout again  ✓  3 [chrome] › e2e/newsletter.spec.ts:4:5 › joins with JavaScript: one POST, answered with an RSC payload  ✓  4 [chrome] › e2e/newsletter.spec.ts:20:7 › without JavaScript › joins with a plain form post and a 303  ✓  5 [chrome] › e2e/teas.spec.ts:4:5 › the teas page lists every tea  ✓  6 [chrome] › e2e/teas.spec.ts:10:5 › the stock streams in after its loading message   6 passed $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (10/10)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /newsletter├ ○ /newsletter/thanks├ ○ /teas└   /teas/[id]  ├ ◐ /teas/[id]  ├ ◐ /teas/green  ├ ◐ /teas/mint  └ ◐ /teas/lemon ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

Open the Terminal tab. Playwright printed Running 6 tests using 1 worker, then a ✓ for each test, in file order: two in e2e/navigation.spec.ts, two in e2e/newsletter.spec.ts and two in e2e/teas.spec.ts, and 6 passed. Before the first test, Playwright had built the app and started it, because of line 23 of playwright.config.ts. In each test, it opened the Google Chrome installed on the computer (line 14), not a browser of its own. After the tests, the recorder's own $ next build printed the routes: ○ /teas, and ◐ /teas/mint with its stock streamed in.

Left to right: Playwright's test drives Chrome; Chrome asks next start, which serves what next build made; next start asks the stand-in stock API. Below, a note: webServer runs next build, then next start, and the stand-in, before the first test.teas.spec.tsChrome/teas/mintnext startstockstand-innext buildwebServer runs next build, next start and the stand-in first
Every part is real but the stock API, which the test controls. Playwright's webServer builds and starts the app before the first test, and stops it after the last.
Real-life analogyA dress rehearsal

Actors rehearse their lines alone many times. Before opening night, the whole cast plays the show once on the real stage, with the lights and costumes, while the director watches from the seats. A stagehand brings each prop on cue, so every scene starts the same way, every time.

In real life: Actors practicing their lines alone
In Next.js: Unit tests: one component or function at a time
In real life: The whole play on the real stage, with lights and costumes
In Next.js: The built app, served by next start, in real Chrome
In real life: A director who watches from the seats, the way the audience will
In Next.js: Playwright, which only sees and clicks what's on the page
In real life: A stagehand who brings a prop on cue
In Next.js: The stand-in stock API, which answers when the test releases it
The main idea

An end-to-end test runs the built app in a real browser and checks what a visitor sees. It's where async Server Components, streaming, navigations and Server Action forms get tested, because Next.js runs all of it. Each check waits for the thing it checks, never for a fixed time, and anything outside the app, like a supplier's API, is a stand-in the test controls.

Playwright against next build and next start

npm init playwright@latest asks a few questions and writes a config, an example test and a GitHub Actions workflow. To add only the test runner yourself:

Installing Playwright's test runnerShell
npm install --save-dev @playwright/test

By default, Playwright runs its own builds of Chromium, Firefox and WebKit, which npx playwright install downloads. This config uses the Google Chrome already on the computer instead, so nothing is downloaded. That tests one browser: to check Firefox and Safari's engine too, add projects that use Playwright's Firefox and WebKit. The whole config, from the tea shop's project:

playwright.config.tsTypeScript
import { defineConfig, devices } from "@playwright/test"; const port = Number(process.env.PORT ?? 3000);const baseURL = `http://localhost:${port}`;const stockURL = `http://localhost:${port + 1}`; export default defineConfig({  testDir: "./e2e",  forbidOnly: !!process.env.CI,  retries: process.env.CI ? 2 : 0,  workers: process.env.CI ? 1 : undefined,  reporter: "list",  use: { baseURL, trace: "on-first-retry" },  projects: [{ name: "chrome", use: { ...devices["Desktop Chrome"], channel: "chrome" } }],  webServer: [    {      command: "node e2e/stock-api.mjs",      url: `${stockURL}/stock/mint`,      env: { STOCK_PORT: String(port + 1) },      reuseExistingServer: false,    },    {      command: "npm run build && npm run start",      url: baseURL,      env: { STOCK_API_URL: stockURL },      reuseExistingServer: false,      timeout: 120_000,    },  ],}); 
  • Lines 3 to 5 pick the addresses. The app listens on PORT, 3000 unless something sets it, and the stand-in stock API on the port after it.
  • Lines 15 to 29 are webServer: the programs Playwright starts before the first test and stops after the last. The second one, on lines 22 to 28, runs npm run build && npm run start: next build, then next start. Line 24 tells Playwright to wait until that address answers, and line 27 gives the build two minutes. Line 25 passes the stand-in's address to the app in STOCK_API_URL.
  • Lines 20 and 26 set reuseExistingServer: false: Playwright always starts its own programs. If something already answers at one of the addresses, like a next dev you left running, the run stops with an error instead of testing it. So every run tests a fresh build of your current code.
  • Lines 9 to 11 change three settings when CI is set, as it is on GitHub's runners and in the recordings.

Next.js's Playwright guide recommends testing the production build, because that's what visitors get. next dev compiles each page the first time it's asked for and adds checks that only run in development, so a test against it waits longer and checks something else. Each row is one setting, what it does, and why it matters for a Next.js app:

The settings in playwright.config.ts
SettingWhat it doesWhy it matters here
webServer with npm run build && npm run startBuilds the app and starts it before the tests, then stops it after them.The tests check the production build: what visitors get.
url: baseURLPlaywright waits until this address answers before the first test.No test starts while next build is still running.
reuseExistingServer: falseAlways starts a fresh build, and stops with an error if something already answers at the address.No run tests an old build, or a next dev left running, by mistake.
use: { baseURL }Lets page.goto("/teas") and toHaveURL("/teas") leave out the address.The port can change without touching any test.
channel: "chrome"Drives the Google Chrome installed on the computer, instead of Playwright's own browser download.The tests run in the installed Google Chrome. Add Firefox and WebKit projects to cover other browsers.
retries, workers, forbidOnly with process.env.CIIn CI: two more tries for a failed test, one test at a time, and no test.only left behind.A failure in CI is retried and recorded, and runs don't depend on each other's order.
trace: "on-first-retry"Records a trace of a test's first retry: each step, the page, and the network.A failure in CI comes with what the page looked like.

Async Server Components, end to end

In the last lesson, Testing Library couldn't render the async teas page: React DOM printed that it was an async Client Component, and the test failed. Here is the same page, with a link on each tea, and its first end-to-end test:

e2e/teas.spec.tsTypeScript
import { expect, test } from "@playwright/test";import { holdStock, releaseStock } from "./stock"; test("the teas page lists every tea", async ({ page }) => {  await page.goto("/teas");  await expect(page.getByRole("heading", { name: "Our teas" })).toBeVisible();  await expect(page.getByRole("listitem")).toHaveText(["Green tea: $4", "Mint tea: $3", "Lemon tea: $3"]);}); test("the stock streams in after its loading message", async ({ page, request }) => {  await holdStock(request);  await page.goto("/teas/mint", { waitUntil: "commit" });  await expect(page.getByRole("heading", { name: "Mint tea" })).toBeVisible();  await expect(page.getByText("Checking stock…")).toBeVisible();  await releaseStock(request);  await expect(page.getByText("12 boxes in stock")).toBeVisible();  await expect(page.getByText("Checking stock…")).toBeHidden();}); 

Line 5 opens /teas on the running app. Playwright adds the path to baseURL from the config, and waits for the page's load event. Line 6 checks the heading. Line 7 checks that the list items, in order, say exactly the three teas. Next.js rendered the async TeasPage on the server, as it does for every visitor, so the test needs no trick and no mock: the report shows ✓ 5 [chrome] › e2e/teas.spec.ts:4:5 › the teas page lists every tea.

Open the Browser tab of the example above and pick /teas: that's the page next start sent, the same three teas. An end-to-end test doesn't care how a page was made, prerendered as ○ or streamed as ◐. It checks what the visitor ends up with.

Streaming and loading states, without fixed waits

The Mint tea page shows its name at once and the stock when the supplier answers, with Checking stock… in between, as Streaming showed:

app/teas/[id]/page.tsxTSX
import { Suspense } from "react";import { notFound } from "next/navigation";import { connection } from "next/server";import { teas } from "@/lib/teas"; export function generateStaticParams() {  return teas.map((tea) => ({ id: tea.id }));} async function Stock({ id }: { id: string }) {  await connection();  const response = await fetch(`${process.env.STOCK_API_URL}/stock/${id}`);  const { boxes } = await response.json();  return <p>{boxes} boxes in stock</p>;} export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {  const { id } = await params;  const tea = teas.find((item) => item.id === id);  if (!tea) notFound();  return (    <main>      <h1>{tea.name}</h1>      <Suspense fallback={<p>Checking stock…</p>}>        <Stock id={tea.id} />      </Suspense>    </main>  );} 

Line 11, connection(), makes the stock part wait for a request, so it's never made during the build. Line 12 asks the supplier, at STOCK_API_URL, for each request. Lines 24 to 26 show Checking stock… until it answers. The recorder's own request shows both states: in the Network tab, the first HTML of /teas/mint had Checking stock…, and the streamed part replaced it with 12 boxes in stock.

To test the loading message, the test must look while it's on the screen. How long that is depends on the supplier, and on how busy the computer is. A test that waits two seconds and then looks will pass on one machine and fail on a slower one. So the test controls the supplier instead. In the tests, the supplier is a stand-in, which the config starts on lines 16 to 21:

e2e/stock-api.mjs: a stand-in the tests can hold and releaseJavaScript
// A stand-in for the supplier's stock API, for end-to-end tests only.// POST /hold makes stock requests wait; POST /release answers them.import { createServer } from "node:http"; let held = false;const waiting = []; createServer((request, response) => {  if (request.method === "POST" && request.url === "/hold") {    held = true;    return response.end("held");  }  if (request.method === "POST" && request.url === "/release") {    held = false;    for (const answer of waiting.splice(0)) answer();    return response.end("released");  }  const answer = () => response.end(JSON.stringify({ boxes: 12 }));  if (held) waiting.push(answer);  else answer();}).listen(Number(process.env.STOCK_PORT)); 

After POST /hold (lines 9 to 12), each stock request waits in a list (line 19). POST /release answers them all, with 12 boxes (lines 13 to 17). Two small functions in e2e/stock.ts send those posts:

e2e/stock.tsTypeScript
import type { APIRequestContext } from "@playwright/test";

// The stand-in stock API listens on the port after the app's (see playwright.config.ts).
const stockURL = `http://localhost:${Number(process.env.PORT ?? 3000) + 1}`;

export async function holdStock(request: APIRequestContext) {
  await request.post(`${stockURL}/hold`);
}

export async function releaseStock(request: APIRequestContext) {
  await request.post(`${stockURL}/release`);
}

Now the second test of e2e/teas.spec.ts, above, reads like what a visitor sees. Line 11 holds the stock. Line 12 opens the page with waitUntil: "commit". Playwright's docs say goto waits for the load event by default, and the page doesn't finish loading until its last streamed part has arrived, which the hold prevents. With commit, goto returns as soon as the answer starts. Lines 13 and 14 check the heading and the loading message. Line 15 releases the stock, line 16 waits for 12 boxes in stock, and line 17 checks that the loading message is gone.

Two rows over time. The test row: hold the stock, open /teas/mint, check Checking stock…, release, check 12 boxes in stock. The page row under it: nothing, then the heading and Checking stock…, then 12 boxes in stock. Arrows go from each test step to the page it checks.The testholdgotocheckreleasecheckThe page/teas/mintMint teaChecking stock…laterMint tea12 boxesno step waits a fixed time
The page can't finish until the test releases the stock, so the loading message is on the screen when line 14 checks it, on any machine. Then line 16 waits for the stock.

Every check waited for what it checks. expect(…).toBeVisible() looks again and again until the element is visible, for up to 5 seconds by default. Each row is one way to wait in Playwright, and what it waits for:

Ways to wait, and what each waits for
The callWhat it waits forWhen to use it
page.goto(url)The page's load event, after the last streamed part.When the test checks the finished page.
page.goto(url, { waitUntil: "commit" })The first bytes of the answer.When the test checks what shows before the stream ends.
await expect(locator).toBeVisible()Until the element is visible, checking again and again, for up to 5 seconds.For every check of what's on the screen.
locator.isVisible()Nothing: it answers for this moment.Rarely; never as a test's check.
page.waitForTimeout(2000)Two seconds, whatever happens.Never in a test: too short on a slow machine, wasted time on a fast one.
Instant navigations have a test helper

The @next/playwright package's instant() checks what's on the screen the moment a navigation starts, before anything streams in. Instant navigations shows it.

Forms and Server Actions, with and without JavaScript

The newsletter form's action is a Server Action, join, which redirects to a thanks page (Server Actions). Anyone may join, so it checks the email, not who is asking, and the list is a stand-in. The form works before the page's JavaScript has loaded, and differently after. One test for each:

e2e/newsletter.spec.tsTypeScript
import { expect, test } from "@playwright/test";import { waitForHydration } from "./hydration"; test("joins with JavaScript: one POST, answered with an RSC payload", async ({ page }) => {  await page.goto("/newsletter");  await waitForHydration(page);  await page.getByLabel("Email").fill("asha@example.com");  const answer = page.waitForResponse((response) => response.request().method() === "POST");  await page.getByRole("button", { name: "Join" }).click();  const response = await answer;  expect(response.status()).toBe(200);  expect(response.headers()["content-type"]).toContain("text/x-component");  await expect(page.getByRole("heading", { name: "Thanks for joining" })).toBeVisible();  await expect(page).toHaveURL("/newsletter/thanks");}); test.describe("without JavaScript", () => {  test.use({ javaScriptEnabled: false });   test("joins with a plain form post and a 303", async ({ page }) => {    await page.goto("/newsletter");    await page.getByLabel("Email").fill("ben@example.com");    const answer = page.waitForResponse((response) => response.request().method() === "POST");    await page.getByRole("button", { name: "Join" }).click();    expect((await answer).status()).toBe(303);    await expect(page.getByRole("heading", { name: "Thanks for joining" })).toBeVisible();    await expect(page).toHaveURL("/newsletter/thanks");  });}); 

In the first test, line 6 waits for hydration: without it, the click could post the form before React handles it, and the test would get the answer the second test expects. Line 8 starts waiting for the page's next POST before the click, so it can't miss it. Lines 11 and 12 check its answer: status 200, with the type text/x-component, an RSC payload: the React data Next.js's router uses to update the page, instead of a new HTML page. With JavaScript, the router sent the action itself and got the redirect back as that data, in one round trip. Lines 13 and 14 check the thanks page and its address.

Line 18 turns JavaScript off for every test in its describe block, as for a visitor whose JavaScript hasn't loaded yet. There's nothing to hydrate, so this test doesn't wait for it. The browser posts the form itself, and line 25 checks the answer: 303, a redirect the browser follows to the thanks page. Both passed: the same form, the same action, two different journeys.

Both tests also passed through Next.js's action layer, which a unit test skips: the action was found by its id, and its origin check passed, because the browser sent the post from the app's own page.

Signing in once with storageState

The account pages from Authentication need a signed-in visitor. Signing in through the form in every test is slow and repeats the same steps. Playwright can sign in once, save the browser's cookies to a file, and start other tests from it. That saved state is storageState:

The account pages: sign in once, test many timesNext.js
app/account/sign-in/e2e/lib/
e2e/auth.setup.ts
import { expect, test as setup } from "@playwright/test";import { waitForHydration } from "./hydration"; const asha = "playwright/.auth/asha.json"; setup("sign in as Asha", async ({ page }) => {  await page.goto("/sign-in");  await waitForHydration(page);  await page.getByLabel("Email").fill("asha@example.com");  // Asha's password in the stand-in users table: a test fixture, never a real account's.  await page.getByLabel("Password").fill("green-tea-123");  await page.getByRole("button", { name: "Sign in" }).click();  await expect(page.getByText("Signed in as Asha")).toBeVisible();  await page.context().storageState({ path: asha });});
Recorded from Next.js 16.4.3
$ npx playwright testRunning 3 tests using 1 worker   ✓  1 [setup] › e2e/auth.setup.ts:6:6 › sign in as Asha  ✓  2 [chrome] › e2e/account.spec.ts:3:5 › Asha sees her orders  ✓  3 [chrome] › e2e/account.spec.ts:12:7 › a visitor who isn't signed in › is asked to sign in   3 passed $ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (5/5)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ◐ /account└ ○ /sign-in ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

e2e/auth.setup.ts opens the real sign-in page (line 7) and waits for hydration (line 8), as the shop's tests do; this layout has the same marker. It fills in Asha's email and password (lines 9 and 11) and clicks Sign in (line 12). The password is the stand-in users table's, a fixture: fixed data made only for tests, never a real account's. Line 13 waits until the account page says Signed in as Asha, so the session cookie is set. Line 14 saves the browser's cookies to the file on line 4.

In playwright.config.ts, line 14 makes a setup project for files ending in .setup.ts. The chrome project, on lines 15 to 19, starts each test's browser from playwright/.auth/asha.json (line 17), and line 18 makes it run after setup. In e2e/account.spec.ts, the first test opens /account already signed in. The second, in a describe block whose line 10 sets an empty storageState, starts with no cookies and is asked to sign in. The Terminal tab shows the order: [setup] first, then the two [chrome] tests.

Top: the setup project signs in through the real form and saves the browser's cookies to playwright/.auth/asha.json. Bottom: each test in the chrome project starts a browser from that file, so /account shows Signed in as Asha. The signed-out test starts with no cookies.setup projectsign in onceplaywright/.auth/asha.jsonchrome projectSigned in as Ashaaccount.spec.tsno cookies: not signed insigned-out test
The form is filled in once, by auth.setup.ts. Every test in the chrome project starts with the cookies it saved; the signed-out test asks for none.

The saved file holds a working session cookie: anyone who has it is signed in as Asha until the session ends. Keep it out of git. The project's .gitignore does:

.gitignoreText
# Dependencies, build output and secrets (create-next-app's .gitignore, shortened)/node_modules/.next/.env* # Playwright: the saved sign-in, a working session cookie, and each run's results/playwright/.auth//test-results//playwright-report/ 

Line 7 keeps playwright/.auth out, and lines 8 and 9 the folders each run writes. Line 4 keeps .env files out too: the session secret in this project's .env is the Authentication lesson's recording fixture. In CI, the app reads a secret made for tests from the job's environment instead.

Running the tests in CI

A test is most useful when it runs on every change, before anyone merges it. In GitHub Actions, a workflow file does it. This is the workflow Playwright's own setup writes, changed for this config (not recorded: it runs on GitHub's computers):

.github/workflows/e2e.ymlYAML
name: End-to-end testson:  push:    branches: [main]  pull_request:    branches: [main]jobs:  e2e:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v6      - uses: actions/setup-node@v6        with:          node-version: lts/*      - run: npm ci      - run: npx playwright install --with-deps chrome      - run: npx playwright test        env:          SESSION_SECRET: ${{ secrets.TEST_SESSION_SECRET }}      - uses: actions/upload-artifact@v4        if: ${{ !cancelled() }}        with:          name: test-results          path: test-results/          retention-days: 30 

Lines 11 to 15 check out the code, set up Node.js and install the packages. Line 16 installs Google Chrome and what it needs on Linux, for channel: "chrome". Line 17 runs the same command as the recordings, and GitHub sets CI on its runners, so the config's CI settings apply. Line 19 gives the app a session secret from the repository's secrets; webServer passes the job's environment on to next build and next start. Lines 20 to 25 keep test-results/ after the run, failed or not, unless it was cancelled.

Five steps left to right: check out the code, npm ci, install Chrome, playwright test (which builds and starts the app), and upload test-results with the traces.checkoutnpm ciChrometestuploadbuild, start, runtracesCI=true on GitHub's runners turns on retries and one worker
The job runs the same command you run. When a test fails, its retries leave a trace and an error context in test-results/, which the last step keeps for 30 days.

Here is what a failure looks like in CI. Pick each run and read the Terminal tab:

Playground: a run that passes, and a run that fails in CI
The tea shop's Playwright tests, run with CI=1Next.js
app/newsletter/thanks/teas/[id]/e2e/lib/
e2e/teas.spec.ts
import { expect, test } from "@playwright/test";import { holdStock, releaseStock } from "./stock"; test("the teas page lists every tea", async ({ page }) => {  await page.goto("/teas");  await expect(page.getByRole("heading", { name: "Our teas" })).toBeVisible();  await expect(page.getByRole("listitem")).toHaveText(["Green tea: $4", "Mint tea: $3", "Lemon tea: $3"]);}); test("the stock streams in after its loading message", async ({ page, request }) => {  await holdStock(request);  await page.goto("/teas/mint", { waitUntil: "commit" });  await expect(page.getByRole("heading", { name: "Mint tea" })).toBeVisible();  await expect(page.getByText("Checking stock…")).toBeVisible();  await releaseStock(request);  await expect(page.getByText("12 boxes in stock")).toBeVisible();  await expect(page.getByText("Checking stock…")).toBeHidden();});
Recorded from Next.js 16.4.3
$ npx playwright testRunning 6 tests using 1 worker   ✓  1 [chrome] › e2e/navigation.spec.ts:4:5 › a link keeps what you typed in the layout  ✓  2 [chrome] › e2e/navigation.spec.ts:19:5 › a reload starts the layout again  ✓  3 [chrome] › e2e/newsletter.spec.ts:4:5 › joins with JavaScript: one POST, answered with an RSC payload  ✓  4 [chrome] › e2e/newsletter.spec.ts:20:7 › without JavaScript › joins with a plain form post and a 303  ✓  5 [chrome] › e2e/teas.spec.ts:4:5 › the teas page lists every tea  ✓  6 [chrome] › e2e/teas.spec.ts:10:5 › the stock streams in after its loading message   6 passed $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (10/10)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /newsletter├ ○ /newsletter/thanks├ ○ /teas└   /teas/[id]  ├ ◐ /teas/[id]  ├ ◐ /teas/green  ├ ◐ /teas/mint  └ ◐ /teas/lemon ○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
Try it yourself
Every test passes

Six tests, one worker, in file order: each ran once and passed, so there's no retry and no trace.

Both ran npx playwright test with Playwright 1.64.1 against Next.js 16.4.3, in Chrome, with the same playwright.config.ts. Times are left out of the reports. Reset goes back to the first version.

The failing test ran three times: once, then (retry #1) and (retry #2), because of retries: 2. A test that fails and then passes on a retry is marked flaky, which is worth fixing too. Each failure printed the locator, the 5-second timeout, and the line. Each attempt also wrote an error-context.md: in Playwright 1.64.1, a Markdown file with the error, a snapshot of the page as a tree of roles and names, and the test's code. The first retry also saved trace.zip, because of trace: "on-first-retry". Download the job's test-results and open the trace with npx playwright show-trace: it steps through every action, with the page, its network requests and the console at each one.

Does this line wait for the right thing?

Most flaky end-to-end tests wait for the wrong thing: a fixed time, a quiet network, a button that's drawn but not hydrated, or nothing at all. This is the kind of line to look for in a review:

A test that waits two seconds, then looks once (don't copy it)TypeScript
await page.goto("/teas/mint");
await page.waitForTimeout(2000);
expect(await page.getByText("12 boxes in stock").isVisible()).toBe(true);

Line 2 waits two seconds, whether the stock took one second or three, and line 3 looks once. Sort each line from the tea shop's tests and from code like this:

Does it wait for what the test checks?
  • `await expect(page.getByText("12 boxes in stock")).toBeVisible();`
  • `await page.waitForTimeout(2000);`
  • `expect(await page.getByText("12 boxes in stock").isVisible()).toBe(true);`
  • `await expect(page).toHaveURL("/teas");`
  • `await page.getByRole("button", { name: "Join" }).click();`
  • `await page.waitForLoadState("networkidle");`
  • `expect(await page.getByRole("listitem").count()).toBe(3);`
  • `await expect(page.locator("html")).toHaveAttribute("data-hydrated", "true");`
  • `await expect(page.getByRole("listitem")).toHaveCount(3);`
Try it yourself
0 of 9 correct

Sort each Playwright line: does it wait until the thing the test checks is true, or does it wait for something else, or not at all?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

A good mix for the tea shop: Vitest tests for every component, action and helper, and a handful of end-to-end tests for the journeys visitors take: browse the teas, open one, sign in, join the newsletter. Each journey is worth more than a dozen checks of one page, and each end-to-end test takes seconds where a unit test takes milliseconds.

Common mix-ups

  • "End-to-end tests should run against next dev." Next.js's guide recommends the production build: next build, then next start, as visitors get it.
  • "A short waitForTimeout makes a streaming test work." It makes it pass on fast machines. Control what the page waits for, and use expect, which waits for the thing itself.
  • "page.goto returns as soon as the page shows something." By default it waits for load, after the last streamed part. Use waitUntil: "commit" to check what shows first.
  • "Server Actions can't work without JavaScript." A form whose action is one posts like any form, and the redirect comes back as a 303. The recorded test passed with JavaScript off.
  • "Each test should sign in through the form." Sign in once in a setup project and reuse its storageState; test the sign-in form itself in one test.
  • "Playwright waits until the page is ready before it clicks." It waits until the element is visible, still and enabled, not until React has hydrated it. Wait for a hydration marker first.
  • "Playwright always downloads its own browsers." With channel: "chrome", it drives the installed Google Chrome.

Practice exercises

These exercises are about Next.js projects and their tests, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upWhat builds the app?

Which command does Playwright run to build and start the tea shop before the tests?

Starter codeTypeScript
import { defineConfig, devices } from "@playwright/test";

const port = Number(process.env.PORT ?? 3000);
const baseURL = `http://localhost:${port}`;
const stockURL = `http://localhost:${port + 1}`;

export default defineConfig({
  testDir: "./e2e",
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "list",
  use: { baseURL, trace: "on-first-retry" },
  projects: [{ name: "chrome", use: { ...devices["Desktop Chrome"], channel: "chrome" } }],
  webServer: [
    {
      command: "node e2e/stock-api.mjs",
      url: `${stockURL}/stock/mint`,
      env: { STOCK_PORT: String(port + 1) },
      reuseExistingServer: false,
    },
    {
      command: "npm run build && npm run start",
      url: baseURL,
      env: { STOCK_API_URL: stockURL },
      reuseExistingServer: false,
      timeout: 120_000,
    },
  ],
});

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upCount the passes

    How many tests passed in the tea shop's recorded run?

    Starter codeTypeScript
    import { expect, test } from "@playwright/test";
    import { holdStock, releaseStock } from "./stock";
    
    test("the teas page lists every tea", async ({ page }) => {
      await page.goto("/teas");
      await expect(page.getByRole("heading", { name: "Our teas" })).toBeVisible();
      await expect(page.getByRole("listitem")).toHaveText(["Green tea: $4", "Mint tea: $3", "Lemon tea: $3"]);
    });
    
    test("the stock streams in after its loading message", async ({ page, request }) => {
      await holdStock(request);
      await page.goto("/teas/mint", { waitUntil: "commit" });
      await expect(page.getByRole("heading", { name: "Mint tea" })).toBeVisible();
      await expect(page.getByText("Checking stock…")).toBeVisible();
      await releaseStock(request);
      await expect(page.getByText("12 boxes in stock")).toBeVisible();
      await expect(page.getByText("Checking stock…")).toBeHidden();
    });
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAfter the reload

      The second test types mint, then reloads. What does the search box hold after the reload?

      Starter codeTypeScript
      import { expect, test } from "@playwright/test";
      import { waitForHydration } from "./hydration";
      
      test("a link keeps what you typed in the layout", async ({ page }) => {
        await page.goto("/");
        await waitForHydration(page);
        await page.getByLabel("Search teas").fill("mint");
        const documents: string[] = [];
        page.on("request", (request) => {
          if (request.resourceType() === "document") documents.push(request.url());
        });
        await page.getByRole("link", { name: "Teas", exact: true }).click();
        await expect(page).toHaveURL("/teas");
        await expect(page.getByRole("heading", { name: "Our teas" })).toBeVisible();
        await expect(page.getByLabel("Search teas")).toHaveValue("mint");
        expect(documents).toEqual([]);
      });
      
      test("a reload starts the layout again", async ({ page }) => {
        await page.goto("/");
        await waitForHydration(page);
        await page.getByLabel("Search teas").fill("mint");
        await page.reload();
        await waitForHydration(page);
        await expect(page.getByLabel("Search teas")).toHaveValue("");
      });
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe post without JavaScript

        With JavaScript off, which status does the form's POST get?

        Starter codeTypeScript
        import { expect, test } from "@playwright/test";
        import { waitForHydration } from "./hydration";
        
        test("joins with JavaScript: one POST, answered with an RSC payload", async ({ page }) => {
          await page.goto("/newsletter");
          await waitForHydration(page);
          await page.getByLabel("Email").fill("asha@example.com");
          const answer = page.waitForResponse((response) => response.request().method() === "POST");
          await page.getByRole("button", { name: "Join" }).click();
          const response = await answer;
          expect(response.status()).toBe(200);
          expect(response.headers()["content-type"]).toContain("text/x-component");
          await expect(page.getByRole("heading", { name: "Thanks for joining" })).toBeVisible();
          await expect(page).toHaveURL("/newsletter/thanks");
        });
        
        test.describe("without JavaScript", () => {
          test.use({ javaScriptEnabled: false });
        
          test("joins with a plain form post and a 303", async ({ page }) => {
            await page.goto("/newsletter");
            await page.getByLabel("Email").fill("ben@example.com");
            const answer = page.waitForResponse((response) => response.request().method() === "POST");
            await page.getByRole("button", { name: "Join" }).click();
            expect((await answer).status()).toBe(303);
            await expect(page.getByRole("heading", { name: "Thanks for joining" })).toBeVisible();
            await expect(page).toHaveURL("/newsletter/thanks");
          });
        });
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFix the fixed wait

          Lines 2 and 3 make this test flaky. Which one line replaces them?

          Starter codeTypeScript
          await page.goto("/teas/mint");
          await page.waitForTimeout(2000);
          expect(await page.getByText("12 boxes in stock").isVisible()).toBe(true);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhere is Asha's session?

            The first test opens /account and finds Asha signed in. Which file gave its browser the session cookie?

            Starter codeTypeScript
            import { expect, test } from "@playwright/test";
            
            test("Asha sees her orders", async ({ page }) => {
              await page.goto("/account");
              await expect(page.getByText("Signed in as Asha")).toBeVisible();
              await expect(page.getByRole("listitem")).toHaveText(["2 boxes of Mint tea", "3 boxes of Green tea"]);
            });
            
            test.describe("a visitor who isn't signed in", () => {
              test.use({ storageState: { cookies: [], origins: [] } });
            
              test("is asked to sign in", async ({ page }) => {
                await page.goto("/account");
                await expect(page.getByText("You're not signed in.")).toBeVisible();
              });
            });
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The End-to-end testing quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why does the config start the app with npm run build && npm run start, not next dev?

                Choose an answer to see the explanation.

              2. Question 2 of 7Line 12 passes waitUntil: "commit" to page.goto. Why?

                Read the code, then predictTypeScript
                import { expect, test } from "@playwright/test";
                import { holdStock, releaseStock } from "./stock";
                
                test("the teas page lists every tea", async ({ page }) => {
                  await page.goto("/teas");
                  await expect(page.getByRole("heading", { name: "Our teas" })).toBeVisible();
                  await expect(page.getByRole("listitem")).toHaveText(["Green tea: $4", "Mint tea: $3", "Lemon tea: $3"]);
                });
                
                test("the stock streams in after its loading message", async ({ page, request }) => {
                  await holdStock(request);
                  await page.goto("/teas/mint", { waitUntil: "commit" });
                  await expect(page.getByRole("heading", { name: "Mint tea" })).toBeVisible();
                  await expect(page.getByText("Checking stock…")).toBeVisible();
                  await releaseStock(request);
                  await expect(page.getByText("12 boxes in stock")).toBeVisible();
                  await expect(page.getByText("Checking stock…")).toBeHidden();
                });
                

                Choose an answer to see the explanation.

              3. Question 3 of 7What does await expect(page.getByText("Checking stock…")).toBeVisible() do when the text isn't on the page yet?

                Choose an answer to see the explanation.

              4. Question 4 of 7In the navigation test, what does expect(documents).toEqual([]) prove?

                Choose an answer to see the explanation.

              5. Question 5 of 7With JavaScript off, what answers the newsletter form's POST?

                Choose an answer to see the explanation.

              6. Question 6 of 7In CI, how many times did Playwright run the failing stock test?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why does the setup project save storageState to a file?

                Choose an answer to see the explanation.

              Key takeaways

              • Let Playwright's webServer run next build and next start, and test the production build in the Chrome visitors use.
              • Async Server Components need nothing special end to end: open the page and check what it shows.
              • For a streamed part, control what it waits for, open the page with waitUntil: "commit", check the fallback, release, and check the content. Never wait a fixed time.
              • Before a test clicks or types something that needs JavaScript, wait until React has hydrated the page: Playwright's own waiting doesn't cover it.
              • A client-side navigation keeps the layout's state and loads no new document; a reload does neither. A Server Action form answers 200 with an RSC payload with JavaScript, and 303 without.
              • Sign in once in a setup project and reuse its storageState. In CI, retries and a trace on the first retry show what went wrong.

              Remember the one-liner.
              Build the app, open it in a real browser, and wait for what a visitor would see, never for a clock.

              Coming next: Debugging. The error overlay, the server's debugger and the browser's DevTools, for when a test tells you something is wrong.

              CompleteFrontend Clear concepts. Working examples.