Skip to main content

End-to-end tests with Playwright

An end-to-end (E2E) test opens a real browser, uses your application the way a person would and checks what it sees. It does not care how the code is written: it checks that the whole system (interface, server, cookies, data) works together.

Playwright is the tool we will use. It works with any web application, not only Next.js; the examples use a sign-in because it is the flow we built in week 14, but everything else applies to any project.


1. Why E2E tests​

Pick an error and see at which level of the pyramid it is best detected.

Test pyramid: where each error is detected
UnitIntegrationE2E
What they testA function or a use case.Several pieces with simulated dependencies.The whole system, in a real browser.
SpeedMilliseconds.Hundreds of milliseconds.Seconds.
How manyMany.Some.Few: the important flows.
When they failThey point to the exact line.They point to a module.They point out that something, somewhere, failed.
What they do not seeThat the pieces work together.The real system.Edge cases (they are too slow for that).
What they are used for in this course

The flows you cannot test with a function: signing in, the cookie being stored, a protected route redirecting, creating a routine and seeing it in the list.


2. Install and configure​

In the project root:

npm init playwright@latest

The wizard asks a few questions:

QuestionWhat to answer
JavaScript or TypeScript?TypeScript, like the rest of the project.
Where to put the tests?e2e, so they are not mixed with unit tests.
Add a GitHub Actions workflow?Yes, if you are going to use CI (see section 8).
Install the browsers?Yes: it downloads Chromium, Firefox and WebKit.

See what it creates and how to organize it in your project:

Folder structure of a project with Playwright

The configuration lives in playwright.config.ts. This is the one for a Next.js application with a session:

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

export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // in CI, a forgotten test.only makes the run fail
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000', // lets you write page.goto('/login')
trace: 'on-first-retry', // saves a trace when a retry fails
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
// Starts the application before the tests
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000, // the build can take longer than the default value
},
})

Add scripts so you do not have to remember the commands:

package.json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
With Next.js, test against the production build

The Next.js guide recommends testing against npm run build and npm run start: it is what users will see and it avoids next dev compiling each page on the first visit. With webServer Playwright starts the server for you. To get started quickly there is also npx create-next-app@latest --example with-playwright.


3. Your first test​

Walk through the test line by line: each one is linked to what happens in the browser. A test has three moments: set up (arrange), act (act) and check (assert).

Anatomy of a Playwright test
e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test('inicia sesión y ve sus rutinas', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Correo').fill('ana@icesi.edu.co')
await page.getByLabel('Contraseña').fill('123456')
await page.getByRole('button', { name: 'Iniciar sesión' }).click()

await expect(page).toHaveURL('/routines')
await expect(page.getByRole('heading', { name: 'Mis rutinas' })).toBeVisible()
})

Run it:

npm run test:e2e
npx playwright show-report

4. How to find elements​

A fragile test breaks when the design changes even though the application works. The defense is to locate elements the way a user perceives them.

Playwright locators, from the most robust to the most fragile

5. Auto-waiting​

Web applications are asynchronous: a button takes time to become enabled, a page takes time to load. Playwright waits for you: before acting it checks that the element is ready, and assertions retry. Turn on "La aplicación tarda en responder" (The application is slow to respond) to see the retries.

Playwright auto-waiting: click and expect retry on their own
Do it this wayDo not do it this wayWhy
await expect(el).toBeVisible()expect(await el.isVisible()).toBe(true)The first one retries; the second checks only once.
await expect(page).toHaveURL('/routines')expect(page.url()).toBe(...)Same reason: the URL can take time to change.
Wait for a conditionawait page.waitForTimeout(3000)Sleeping makes the test slow and unstable at the same time.

6. The session in tests​

Each test() starts with a clean browser: no cookies. To test the protected part, does every test have to sign in? No: it is done once and reused.

Reuse the session in tests with storageState
e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'

const authFile = 'playwright/.auth/user.json'

setup('autenticar', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Correo').fill('ana@icesi.edu.co')
await page.getByLabel('Contraseña').fill('123456')
await page.getByRole('button', { name: 'Iniciar sesión' }).click()
await expect(page).toHaveURL('/routines')

// Saves the browser cookies, including the httpOnly session cookie
await page.context().storageState({ path: authFile })
})

The tests for the login itself must start without a session; the ones for the protected part already arrive authenticated:

e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test.use({ storageState: { cookies: [], origins: [] } }) // start without a session

test('muestra un error con credenciales incorrectas', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Correo').fill('ana@icesi.edu.co')
await page.getByLabel('Contraseña').fill('incorrecta')
await page.getByRole('button', { name: 'Iniciar sesión' }).click()

await expect(page.getByText('Credenciales inválidas')).toBeVisible()
await expect(page).toHaveURL('/login')
})

test('redirige a /login si no hay sesión', async ({ page }) => {
await page.goto('/routines')
await expect(page).toHaveURL('/login')
})

test('la cookie de sesión es httpOnly', async ({ page, context }) => {
await page.goto('/login')
await page.getByLabel('Correo').fill('ana@icesi.edu.co')
await page.getByLabel('Contraseña').fill('123456')
await page.getByRole('button', { name: 'Iniciar sesión' }).click()
await expect(page).toHaveURL('/routines')

const session = (await context.cookies()).find((c) => c.name === 'session')
expect(session?.httpOnly).toBe(true)
})
e2e/routines.spec.ts
import { test, expect } from '@playwright/test'

// Uses the session saved by auth.setup.ts (configured in the "chromium" project)
test('ve la lista de rutinas', async ({ page }) => {
await page.goto('/routines')
await expect(page.getByRole('heading', { name: 'Mis rutinas' })).toBeVisible()
})
Be careful with playwright/.auth/

user.json contains real session cookies. Add it to .gitignore (/playwright/.auth/) and never commit it to the repository.

Test data in the mocked datasource

If the server keeps the data in memory, all tests share the same state. Use unique names when creating data (Rutina ${Date.now()}) or set workers: 1 so they do not step on each other.


7. When a test fails​

ToolCommandWhat it is for
UI Modenpx playwright test --uiRun and walk through each step with a timeline and the DOM at each moment.
Inspectornpx playwright test --debugGo step by step with the browser visible.
See the browsernpx playwright test --headedWatch the test while it runs.
Codegennpx playwright codegen http://localhost:3000Record your actions and get code and locators.
HTML reportnpx playwright show-reportSee what happened in the last run.
Trace Viewernpx playwright show-trace <file>.zipReplay a run with DOM, network and console. It is saved with trace: 'on-first-retry'.
A single testnpx playwright test -g "inicia sesión"Do not run everything while debugging.
Start with --ui

It is the fastest way to understand why a test fails: you see each action, the state of the page before and after, and the error messages.


8. Run them on every change (CI)​

The wizard can generate a GitHub Actions workflow. This is the idea:

.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30