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.
- 01Run Playwright against the built appLet Playwright build and start the app with
webServer, and drive the Chrome on your computer. - 02Test what only the running app doesCheck async pages, streamed parts, client-side navigations and Server Action forms, after hydration and without one fixed wait.
- 03Sign 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.
playwright.config.tsimport { 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, }, ],});$ 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 contentOpen 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.
webServer builds and starts the app before the first test, and stops it after the last.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
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:
npm install --save-dev @playwright/testBy 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:
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, runsnpm run build && npm run start:next build, thennext 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 inSTOCK_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 anext devyou 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
CIis 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:
| Setting | What it does | Why it matters here |
|---|---|---|
webServer with npm run build && npm run start | Builds the app and starts it before the tests, then stops it after them. | The tests check the production build: what visitors get. |
url: baseURL | Playwright waits until this address answers before the first test. | No test starts while next build is still running. |
reuseExistingServer: false | Always 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.CI | In 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:
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:
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:
// 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:
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.
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:
| The call | What it waits for | When 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. |
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:
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:
e2e/auth.setup.tsimport { 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 });});$ 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 contente2e/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.
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:
# 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):
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.
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:
e2e/teas.spec.tsimport { 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();});$ 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 contentSix tests, one worker, in file order: each ran once and passed, so there's no retry and no trace.
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:
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:
`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);`
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?
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, thennext start, as visitors get it. - "A short
waitForTimeoutmakes a streaming test work." It makes it pass on fast machines. Control what the page waits for, and useexpect, which waits for the thing itself. - "
page.gotoreturns as soon as the page shows something." By default it waits forload, after the last streamed part. UsewaitUntil: "commit"to check what shows first. - "Server Actions can't work without JavaScript." A form whose
actionis 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.
Which command does Playwright run to build and start the tea shop before the tests?
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,
},
],
});
npm run build && npm run start, on line 23: next build, then next start once the build has passed. Playwright waits until baseURL answers before the first test.
How many tests passed in the tea shop's recorded run?
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();
});
6: two tests each in navigation.spec.ts, newsletter.spec.ts and teas.spec.ts. The report's last line says 6 passed.
The second test types mint, then reloads. What does the search box hold after the reload?
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("");
});
Nothing: the box is empty. The reload loads a new document, so the layout starts again with useState(""). Line 25 checks it, and the test passed.
With JavaScript off, which status does the form's POST get?
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");
});
});
303. Next.js runs the action, and redirect answers a plain form post with 303 See Other, which the browser follows to /newsletter/thanks. With JavaScript, the same click got a 200 RSC payload.
Lines 2 and 3 make this test flaky. Which one line replaces them?
await page.goto("/teas/mint");
await page.waitForTimeout(2000);
expect(await page.getByText("12 boxes in stock").isVisible()).toBe(true);await page.goto("/teas/mint");
await expect(page.getByText("12 boxes in stock")).toBeVisible();await expect(page.getByText("12 boxes in stock")).toBeVisible() replaces both lines. It passes as soon as the stock shows, and waits up to 5 seconds. The recorded teas test does this on line 16.
The first test opens /account and finds Asha signed in. Which file gave its browser the session cookie?
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();
});
});
playwright/.auth/asha.json. The setup project saved the browser's cookies there, and the chrome project starts every test's browser from it. Keep that folder out of git.
Check your understanding
Question 1 of 7Why does the config start the app with
npm run build && npm run start, notnext dev?Choose an answer to see the explanation.
Question 2 of 7Line 12 passes
waitUntil: "commit"topage.goto. Why?Read the code, then predictTypeScriptimport { 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.
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.
Question 4 of 7In the navigation test, what does
expect(documents).toEqual([])prove?Choose an answer to see the explanation.
Question 5 of 7With JavaScript off, what answers the newsletter form's POST?
Choose an answer to see the explanation.
Question 6 of 7In CI, how many times did Playwright run the failing stock test?
Choose an answer to see the explanation.
Question 7 of 7Why does the setup project save
storageStateto a file?Choose an answer to see the explanation.
Key takeaways
- Let Playwright's
webServerrunnext buildandnext 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.