End-to-End Testing with Playwright
- playwright
- e2e-testing
- testing
- typescript
- nextjs
- react
- qa
- automation
- cicd
- softwaretesting
- webdev
- javascript
In Testing React Components, I covered how to test components in isolation: render a piece of UI, interact with it the way a user would, and assert on what shows up. That gives you fast, reliable feedback on individual pieces. What it can't tell you is whether the login form actually talks to the real auth endpoint, whether the redirect after checkout lands on the right page, or whether a change to a shared layout silently broke the flow three screens later. That's the gap end-to-end tests are for.
This is part of the React & Next.js Complete Guide series, which builds up from React Fundamentals for Professionals toward production Next.js apps. Playwright is where testing stops being about components and starts being about the product: a real browser, a real (or realistic) backend, and assertions written from the perspective of someone actually using the thing.
I want to walk through where E2E tests fit relative to component tests, how to set Playwright up against a real Next.js app, what a production-grade test for a critical path looks like, and the pitfalls that make E2E suites either genuinely valuable or a source of constant noise nobody trusts.
Where Playwright fits, and where it doesn't
E2E tests are expensive relative to unit and component tests. They spin up a browser, they usually need a running server and a database, and they're slower by an order of magnitude or more. That cost buys you something real. Component tests can't confirm the pieces actually fit together, across real network calls, real routing, real cookies and sessions.
The mistake I've seen teams make in both directions is treating E2E as either the default or an afterthought. Writing E2E tests for every button state and form validation message duplicates work you already did with component tests, except slower and flakier. Skipping E2E entirely and relying only on unit and component coverage means your test suite can be green while the signup flow is broken. No single component test exercises the full path from form submission through redirect.
The rule I use: E2E tests cover critical user journeys, not edge cases. Sign up, log in, complete a purchase, invite a teammate, upgrade a plan. These are the paths where a regression costs real money or real trust, and where the failure mode (something silently breaks in production) is worse than the cost of a slower test suite. Edge cases, validation messages, and conditional rendering belong in component tests, where they run in milliseconds and don't need a browser.
Setting up Playwright against a real Next.js app
Playwright's own scaffolding (npm init playwright@latest) gets you a working config, but for a real app you want a few adjustments: pointing at your dev server, running against a seeded database, and keeping the config readable as the suite grows.
// 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 ? 4 : undefined,
reporter: process.env.CI ? "github" : "html",
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
webServer: {
command: "npm run start:e2e",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
A few of these choices matter more than they look. retries: 2 in CI, and 0 locally, reflects a deliberate tradeoff. CI environments have more sources of transient flakiness (shared runners, cold caches, network jitter). Retrying there catches genuine flakiness without hiding it from you locally, where you want failures to surface immediately. trace: "on-first-retry" means you only pay the overhead of recording a full trace when a test actually needs a retry, which keeps the happy path fast while still giving you a debuggable artifact when something goes wrong. The webServer block boots your app against a known build (start:e2e, distinct from your normal dev script) so the suite isn't accidentally testing against whatever state your local dev server happens to be in.
Running against two browser engines, not just Chromium, catches a real class of bugs: Safari's stricter cookie and storage behavior has broken more than one auth flow that worked fine in Chrome. It's not free, since it roughly doubles run time, but for anything touching authentication or payments it's worth the cost.
Writing a test for a critical path
Here's a test for a login flow that ends in a protected action, structured the way I'd actually write it for a production suite rather than a toy example.
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("authentication", () => {
test("user can log in and create a project", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("e2e-user@example.com");
await page.getByLabel("Password").fill("correct-horse-battery-staple");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(
page.getByRole("heading", { name: "Your projects" })
).toBeVisible();
await page.getByRole("button", { name: "New project" }).click();
await page.getByLabel("Project name").fill("Q3 Roadmap");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByText("Q3 Roadmap")).toBeVisible();
});
test("shows an error for invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("e2e-user@example.com");
await page.getByLabel("Password").fill("wrong-password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("alert")).toHaveText(
"Invalid email or password."
);
await expect(page).toHaveURL("/login");
});
});
Note the selectors: getByLabel and getByRole, not CSS classes or test IDs sprinkled through the DOM. This matters for two reasons. First, it means the test is asserting on the same thing an accessibility tool or a real user relies on, so if the label disappears or the button loses its role, the test fails for the same reason a screen reader user would have trouble. Second, it makes the test resilient to the kind of refactor that changes class names or component structure without changing behavior, which is exactly the noise you don't want a fragile E2E suite generating.
I'd deliberately keep this test at the level of "log in, then do one thing that requires being logged in." It's tempting to chain five unrelated assertions into one long test because the login setup is expensive, but a test that fails on step four of five tells you less than two focused tests would. If your login setup really is expensive, that's what fixtures are for.
Test data, fixtures, and database isolation
The hardest part of a real E2E suite is rarely the browser automation. It's making sure each test run starts from a known state and doesn't leave one test's data lying around to poison the next.
For a stack with PostgreSQL and Docker, the approach I'd reach for is a dedicated test database that gets reset between runs. Seed it through the same migration and seed scripts you already use for local development, not a separate hand-maintained fixture file that drifts out of sync with your schema.
// e2e/fixtures.ts
import { test as base } from "@playwright/test";
import { Pool } from "pg";
type TestFixtures = {
db: Pool;
};
export const test = base.extend<TestFixtures>({
db: async ({}, use) => {
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
await use(pool);
await pool.end();
},
});
export { expect } from "@playwright/test";
// e2e/auth.spec.ts (excerpt, using the custom fixture)
import { test, expect } from "./fixtures";
test.beforeEach(async ({ db }) => {
await db.query("DELETE FROM projects WHERE owner_email = $1", [
"e2e-user@example.com",
]);
});
A custom fixture extending Playwright's test gives every test in the file typed access to a real connection, without each test file reinventing pool setup and teardown. Resetting only the rows a given test owns, rather than truncating the whole database between every test, keeps the suite fast enough to run in parallel. Playwright's fullyParallel setting is only useful if your tests aren't all fighting over the same global reset.
The alternative, mocking the API layer entirely and never touching a real database, is faster and more deterministic, but it stops being an end-to-end test at that point. It becomes a component test with a browser attached, and it can pass while the real integration between your Next.js app and your NestJS API is actually broken. I'd reserve API mocking for third-party services you don't control, like a payment provider's sandbox mode being unreliable, and let everything internal to your own stack run for real.
Flakiness, network mocking, and CI pitfalls
Flaky E2E tests are the single fastest way to lose a team's trust in a test suite. Once "just re-run it" becomes the standard response to a red build, the suite has stopped doing its job.
Most flakiness traces back to timing: asserting before an async action has settled. Playwright's expect with auto-retrying assertions (toBeVisible, toHaveText, and similar) already handles most of this by polling instead of checking once, so resist the urge to add manual waitForTimeout calls. A fixed sleep either wastes time waiting longer than necessary or isn't long enough under load, and it hides the actual race condition instead of fixing it. If a specific interaction needs a real wait, wait for a specific condition, a network response, a URL change, an element appearing, not a fixed duration.
The other common source of flakiness is shared state between tests running in parallel. Two tests hitting the same seeded row at the same time will occasionally interfere with each other in ways that only show up under CI's parallelism, not on a single developer's machine running tests serially. Scoping test data per test, using unique emails or IDs generated per run rather than fixed fixture values, avoids this category of bug entirely.
In CI specifically, I'd keep the webServer timeout generous and cache dependencies aggressively (Playwright's browser binaries are large downloads), since a slow CI pipeline erodes the same trust a flaky one does, just more slowly.
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
playwright:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run db:migrate:test
- run: npx playwright test
env:
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Uploading the HTML report only on failure keeps CI artifacts small on the happy path while still giving you the trace, screenshots, and video for the runs that actually need debugging.
Key takeaways
- Reserve Playwright for critical user journeys where a regression is expensive: auth, checkout, invitations, plan changes. Push edge cases and validation states down to component tests.
- Prefer `getByRole` and `getByLabel` selectors over CSS classes or ad hoc test IDs, so tests stay resilient to refactors and double as an accessibility check.
- Reset only the data a test owns between runs, using your real migration and seed scripts, so the database stays consistent with your actual schema instead of a separate fixture file that drifts.
- Let Playwright's auto-retrying assertions handle timing instead of adding fixed `waitForTimeout` calls, which either waste time or paper over a real race condition.
- Retry in CI, not locally, so transient infrastructure flakiness gets absorbed automatically while genuine failures still surface immediately on your machine.
- Mock only the third-party services you don't control. Let calls to your own backend run for real, or you're testing a component with a browser attached, not an integration.
Frequently asked questions
Should E2E tests replace unit and component tests?
No, they cover different failure modes. Unit and component tests are fast and precise about a single piece of logic or UI; E2E tests confirm the pieces work together across real routing, sessions, and network calls. A healthy suite has far more unit and component tests than E2E tests, with E2E reserved for the paths that actually matter to the business.
How many E2E tests is too many?
There's no fixed number, but a useful signal is whether you can name the business reason each test exists. If a test exists because "we happened to write it" rather than because the path it covers is critical, it's a candidate for either deletion or downgrading to a component test.
Can Playwright test against a staging environment instead of a local server?
Yes. Set `baseURL` and remove the `webServer` block, and point `E2E_BASE_URL` at staging in CI. This trades speed and isolation (you're sharing staging's database and state with whatever else is happening there) for testing against an environment closer to production. Many teams run a fast local-server suite on every PR and a slower staging-targeted suite before release.
Why did my test pass locally but fail in CI?
Usually timing or parallelism. CI runners are often slower and more resource-constrained than a developer machine, which surfaces race conditions that a fast local machine never hits. Shared test data across parallel workers is the other common cause. Start by checking whether the failing assertion depends on a fixed wait or on data another test might also be touching.
Do I need to run every test against multiple browsers?
Not necessarily for every test, but for anything touching authentication, cookies, storage, or payment redirects, at least one non-Chromium engine is worth the extra run time. Safari and Firefox have genuinely different behavior around storage partitioning and third-party cookies that Chromium won't surface.
How do I keep Playwright tests from becoming a maintenance burden as the app grows?
Centralize selectors and multi-step flows (logging in, completing a purchase) into fixtures or helper functions instead of repeating them in every test file. When the login flow changes, you want to update one helper, not every test that happens to start with a login.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.