Playwright, end to end testing and browser automation
Playwright is a browser automation tool used mainly for end to end testing. Microsoft develops it under Apache 2.0, and the current version is 1.62.1, released on 30 July 2026. What separates it from the competition is support for three browser engines from one test file, plus a mechanism that waits for an element to be ready on its own.
Why this tool won
For years browser tests carried a reputation as something written reluctantly and maintained even more so. The reason was concrete: tests failed at random. An element appeared a fraction of a second late, an animation was still running, a network request had not returned. The response was sprinkling artificial delays, which made matters worse, since tests became slow and still flaky.
That problem was solved at the source here. Before clicking an element, the tool checks that the element exists, is visible, is not obscured, and accepts events. It waits until all those conditions hold and only then acts. The upshot is that artificial delays stop being necessary and tests stop failing for no reason.
The second thing is three browser engines from one test file. This is not about compatibility with old browsers but about catching differences that genuinely occur: form controls behaving differently, touch events handled differently, different default styles. In a Next.js application what surfaces here most often concerns hydration and components that behave differently on first render.
Know, though, that running every test on all three engines triples execution time, and the value of that is uneven. A sensible arrangement is the full suite on one engine for every change and a run across all three once a day or before a release. Differences between engines are real but rarely appear overnight, so paying for them daily in full execution time is waste.
The third is diagnostic tooling. A recorded execution trace captures screenshots before and after each operation, network state, log entries, and page structure. A test that failed on a build server stops being a mystery, because you open the trace file and see exactly what happened.
The fourth, mentioned less often, is recording tests by interaction. You start the tool, click through the application as a user would, and it writes the corresponding code. That is not a way to write production tests, since generated code tends to be literal and brittle, but it works well as a starting point and as a way to find out which locator to use for an awkward element.
Visual tests and when they earn their place
Comparing screenshots against a baseline is a feature people reach for eagerly and abandon quickly, so it is worth knowing where it works well and where it causes more harm than good.
The mechanism is simple: the first run creates a baseline, subsequent runs compare the current screenshot against it and report differences above a tolerance threshold. That sounds perfect for catching unintended visual changes.
The problem is that rendering differs between systems. The same code on macOS and on Linux produces different font antialiasing, and the difference is enough to fail a comparison. Baselines therefore have to be generated in the same environment the tests run in, meaning the container used on the build server rather than the author's laptop.
The second trap is scope. A full page screenshot breaks with every content change, including copy nobody was testing. Comparisons are worth narrowing to a specific component and to interface elements whose appearance is genuinely a contract with users.
The third is variable elements. A date, a counter, an avatar drawn at random, and an animation will make a test fail every time. They can be masked before comparison, and that is the right order of operations, not raising the tolerance threshold, since a raised threshold stops detecting anything.
If after the first month the team starts updating baselines reflexively without looking at the differences, that means there are too many visual tests or they are too broad. A few well aimed comparisons carry value; several dozen arbitrary ones become a ritual.
Locators, or how not to write brittle tests
This is where the worst decisions are most often made, with consequences paid over the following year.
Targeting elements by CSS classes or document tree structure is tempting. It works immediately and breaks with the first styling change, because the test stops finding a button that is still there and still works.
The approach that holds up targets elements the way a user does: by role and visible name. A button reading "Save" is found as a button with that name, not as the third element carrying a class beginning with a random hash.
await page.getByRole('button', { name: 'Save' }).click()
await page.getByLabel('Email address').fill('anna@example.com')There is a side effect worth knowing, since it is often the best argument for this approach in a conversation with a team. If an element cannot be targeted by role and name, that means it has no accessible name, so a screen reader will not describe it either. The test then forces a fix that repairs accessibility along the way.
When there genuinely is no other option, a test attribute remains. That is a last resort rather than a default, because it creates an element existing solely for the test, which somebody will eventually delete as unused. If you reach for it regularly, ask whether the problem sits in the interface itself: a button without text, an icon without a description, and a field without a label are hard to target in a test for exactly the reason they are hard to use without sight.
Choosing between waiting for an element and waiting for a state is a separate matter. The automatic mechanism covers a single operation, but it does not know that after clicking save the list still has to reload. Checking the consequence, meaning waiting for a new row to appear, works better here than any countdown, since the test ends exactly when the condition holds rather than after a fixed time.
The new component testing model
Version 1.62 changed how components are tested, and it is worth knowing, since older tutorials describe the previous approach.
The new model rests on stories and galleries. A story wraps a component in one specific scenario, with fixed props, mock data, and providers, while a gallery is a page rendering those stories on demand. A test mounts a chosen story and checks what appeared.
The change has a practical rationale. The previous approach required building the component inside the test context, which grew brittle with complex dependencies. Now the component renders where it normally lives, in your application with your configuration, and the test merely reaches for it.
It is worth considering, though, whether browser based component tests are what you need. Launching a real browser costs time, and most component logic is checked faster by a tool that runs without one, Vitest for instance. A browser is needed where actual rendering matters: layout, conditional styling, behaviour at different viewport sizes.
What is Playwright?
Playwright is a modern browser automation and end-to-end testing framework created by Microsoft. It supports all major browsers (Chromium, Firefox, WebKit) with a single consistent API, offering fast, reliable, and cross-browser testing of web applications.
Unlike older tools such as Selenium, Playwright was designed from the ground up with modern applications in mind - it handles auto-waiting, web components, shadow DOM, iframes, and many other challenging scenarios without additional configuration.
Why Playwright?
Key advantages
- Cross-browser - Chromium, Firefox, WebKit with a single API
- Auto-waiting - Automatic waiting for elements
- Speed - Parallel test execution
- Reliability - No flaky tests thanks to smart retries
- Codegen - Recording tests through interaction
- Trace Viewer - Debugging with full context
- API Testing - Built-in REST API testing
Playwright vs Cypress vs Selenium
| Feature | Playwright | Cypress | Selenium |
|---|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium and Edge, Firefox, experimental WebKit | All |
| Speed | Very fast | Fast | Slow |
| Auto-waiting | Built-in | Built-in | Manual |
| Parallel | Native | Via Cypress Cloud, capped free plan | Requires Grid |
| iframes | Full support | Limited | Full |
| Mobile emulation | Yes | No | Via Appium |
| API testing | Built-in | Plugin | External |
| Languages | JS/TS, Python, .NET, Java | JS/TS only | All |
Installation and configuration
New project
# Initialize a Playwright project
npm init playwright@latest
# Answer the prompts:
# - TypeScript or JavaScript
# - Test folder (tests/)
# - Add GitHub Actions workflow?
# - Install browsers?Project structure
my-project/
├── tests/
│ ├── example.spec.ts
│ └── auth.spec.ts
├── playwright.config.ts
├── package.json
└── .github/
└── workflows/
└── playwright.ymlConfiguring playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// Test directory
testDir: './tests',
// Run tests in parallel
fullyParallel: true,
// Fail build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry failed tests on CI
retries: process.env.CI ? 2 : 0,
// Workers - parallel processes
workers: process.env.CI ? 1 : undefined,
// Reporter
reporter: [
['html'],
['list'],
process.env.CI ? ['github'] : ['line']
],
// Global test settings
use: {
// Base URL
baseURL: 'http://localhost:3000',
// Trace on failure
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'on-first-retry',
},
// Projects = browser configurations
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})Writing tests
Basic structure
import { test, expect } from '@playwright/test'
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(/My App/)
})
test('navigation works', async ({ page }) => {
await page.getByRole('link', { name: 'About' }).click()
await expect(page).toHaveURL('/about')
})
})Locators - finding elements
import { test, expect } from '@playwright/test'
test('locators examples', async ({ page }) => {
await page.goto('/form')
// Recommended - semantic locators
// Role-based (accessibility)
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByRole('link', { name: 'Home' }).click()
await page.getByRole('heading', { name: 'Welcome' })
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
await page.getByRole('checkbox', { name: 'Accept terms' }).check()
// Label-based
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('secret123')
// Placeholder
await page.getByPlaceholder('Enter your email').fill('test@example.com')
// Text content
await page.getByText('Welcome back')
await page.getByText(/welcome/i) // Regex, case-insensitive
// Test ID (for complex cases)
await page.getByTestId('submit-button').click()
// Last resort - CSS/XPath, less stable
await page.locator('.submit-btn').click()
await page.locator('#email-input').fill('test@example.com')
await page.locator('button[type="submit"]').click()
await page.locator('//button[text()="Submit"]').click() // XPath
})Chaining locators
test('chained locators', async ({ page }) => {
// Find an element within the context of another
const form = page.locator('form#login')
await form.getByLabel('Email').fill('test@example.com')
await form.getByLabel('Password').fill('secret')
await form.getByRole('button', { name: 'Login' }).click()
// Filtering
const rows = page.getByRole('row')
const activeRow = rows.filter({ hasText: 'Active' })
await activeRow.getByRole('button', { name: 'Edit' }).click()
// Nth element
await page.getByRole('listitem').nth(2).click() // Third element
await page.getByRole('listitem').first().click()
await page.getByRole('listitem').last().click()
})Assertions
import { test, expect } from '@playwright/test'
test('assertions examples', async ({ page }) => {
await page.goto('/dashboard')
// Page assertions
await expect(page).toHaveTitle('Dashboard')
await expect(page).toHaveURL(/dashboard/)
await expect(page).toHaveURL('http://localhost:3000/dashboard')
// Element visibility
await expect(page.getByText('Welcome')).toBeVisible()
await expect(page.getByText('Error')).toBeHidden()
await expect(page.getByTestId('loading')).not.toBeVisible()
// Element state
await expect(page.getByRole('button')).toBeEnabled()
await expect(page.getByRole('button')).toBeDisabled()
await expect(page.getByRole('checkbox')).toBeChecked()
await expect(page.getByRole('textbox')).toBeEditable()
await expect(page.getByRole('textbox')).toBeFocused()
// Element content
await expect(page.getByRole('heading')).toHaveText('Dashboard')
await expect(page.getByRole('heading')).toContainText('Dash')
await expect(page.getByRole('textbox')).toHaveValue('initial value')
await expect(page.getByRole('textbox')).toBeEmpty()
// Element attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/about')
await expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)
// CSS
await expect(page.getByTestId('box')).toHaveCSS('color', 'rgb(0, 0, 0)')
await expect(page.getByRole('button')).toHaveClass(/primary/)
// Count
await expect(page.getByRole('listitem')).toHaveCount(5)
// Soft assertions (do not stop the test)
await expect.soft(page.getByText('Optional')).toBeVisible()
})Interactions
test('interactions', async ({ page }) => {
await page.goto('/form')
// Clicking
await page.getByRole('button').click()
await page.getByRole('button').click({ button: 'right' }) // Right click
await page.getByRole('button').dblclick() // Double click
await page.getByRole('button').click({ modifiers: ['Control'] }) // Ctrl+click
// Hover
await page.getByText('Menu').hover()
// Filling forms
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Email').clear()
await page.getByLabel('Email').type('test@example.com') // Keystroke by keystroke
// Press keys
await page.getByLabel('Search').press('Enter')
await page.keyboard.press('Escape')
await page.keyboard.press('Control+A')
// Select dropdown
await page.getByLabel('Country').selectOption('poland')
await page.getByLabel('Country').selectOption({ label: 'Poland' })
await page.getByLabel('Multi').selectOption(['opt1', 'opt2'])
// Checkbox & radio
await page.getByLabel('Accept').check()
await page.getByLabel('Accept').uncheck()
await page.getByLabel('Accept').setChecked(true)
// File upload
await page.getByLabel('Upload').setInputFiles('path/to/file.pdf')
await page.getByLabel('Upload').setInputFiles(['file1.pdf', 'file2.pdf'])
// Drag and drop
await page.getByTestId('source').dragTo(page.getByTestId('target'))
// Focus
await page.getByLabel('Email').focus()
await page.getByLabel('Email').blur()
})Waiting and timeouts
test('waiting', async ({ page }) => {
await page.goto('/dashboard')
// Auto-waiting - Playwright automatically waits for elements
// The following by itself waits until the element is visible and clickable
await page.getByRole('button').click()
// Explicit waits
await page.waitForSelector('.loaded')
await page.waitForLoadState('networkidle')
await page.waitForLoadState('domcontentloaded')
await page.waitForURL('/dashboard')
// Wait for response
const responsePromise = page.waitForResponse('**/api/data')
await page.getByRole('button', { name: 'Load' }).click()
const response = await responsePromise
// Wait for request
const requestPromise = page.waitForRequest('**/api/submit')
await page.getByRole('button', { name: 'Submit' }).click()
await requestPromise
// Custom wait
await page.waitForFunction(() => {
return document.querySelector('.counter')?.textContent === '10'
})
// Timeout override
await page.getByRole('button').click({ timeout: 10000 })
// Poll until condition
await expect(async () => {
const count = await page.getByTestId('count').textContent()
expect(parseInt(count!)).toBeGreaterThan(5)
}).toPass({ timeout: 10000 })
})Advanced scenarios
Authentication
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const authFile = 'playwright/.auth/user.json'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
// Wait for redirect after login
await page.waitForURL('/dashboard')
// Save authentication state
await page.context().storageState({ path: authFile })
})// playwright.config.ts
export default defineConfig({
projects: [
// Setup project
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// Tests that need auth
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
})// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test'
// This test uses the logged-in state
test('dashboard shows user data', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByText('Welcome, User')).toBeVisible()
})Multiple contexts (multi-user)
test('admin and user interaction', async ({ browser }) => {
// Admin context
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
})
const adminPage = await adminContext.newPage()
// User context
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json'
})
const userPage = await userContext.newPage()
// Admin creates something
await adminPage.goto('/admin/posts')
await adminPage.getByRole('button', { name: 'New Post' }).click()
await adminPage.getByLabel('Title').fill('Test Post')
await adminPage.getByRole('button', { name: 'Publish' }).click()
// User sees it
await userPage.goto('/posts')
await expect(userPage.getByText('Test Post')).toBeVisible()
// Cleanup
await adminContext.close()
await userContext.close()
})API testing
import { test, expect } from '@playwright/test'
test.describe('API Tests', () => {
test('GET /api/users', async ({ request }) => {
const response = await request.get('/api/users')
expect(response.ok()).toBeTruthy()
expect(response.status()).toBe(200)
const users = await response.json()
expect(users).toHaveLength(10)
expect(users[0]).toHaveProperty('email')
})
test('POST /api/users', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com'
}
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user.name).toBe('John Doe')
})
test('with authentication', async ({ request }) => {
const response = await request.get('/api/me', {
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
})
expect(response.ok()).toBeTruthy()
})
})Visual regression testing
import { test, expect } from '@playwright/test'
test('visual regression', async ({ page }) => {
await page.goto('/dashboard')
// Full page screenshot
await expect(page).toHaveScreenshot('dashboard.png')
// Element screenshot
const chart = page.getByTestId('revenue-chart')
await expect(chart).toHaveScreenshot('revenue-chart.png')
// With options
await expect(page).toHaveScreenshot('dashboard-full.png', {
fullPage: true,
maxDiffPixels: 100,
threshold: 0.2,
})
})# Update baseline screenshots
npx playwright test --update-snapshotsMocking and intercepting
test('mock API responses', async ({ page }) => {
// Mock a specific endpoint
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mock User' }
])
})
})
await page.goto('/users')
await expect(page.getByText('Mock User')).toBeVisible()
})
test('modify response', async ({ page }) => {
await page.route('**/api/products', async route => {
const response = await route.fetch()
const json = await response.json()
// Modify response
json.products = json.products.slice(0, 3)
await route.fulfill({ response, json })
})
await page.goto('/products')
})
test('abort requests', async ({ page }) => {
// Block images
await page.route('**/*.{png,jpg,jpeg}', route => route.abort())
// Block analytics
await page.route('**/analytics/**', route => route.abort())
await page.goto('/page')
})
test('delay response', async ({ page }) => {
await page.route('**/api/slow', async route => {
await new Promise(resolve => setTimeout(resolve, 3000))
await route.continue()
})
await page.goto('/page')
// Test loading state
await expect(page.getByText('Loading...')).toBeVisible()
})Fixtures (custom setup)
// fixtures.ts
import { test as base, expect } from '@playwright/test'
// Extend base test with custom fixtures
export const test = base.extend<{
adminPage: Page
userPage: Page
todoApp: TodoApp
}>({
// Admin page fixture
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
})
const page = await context.newPage()
await use(page)
await context.close()
},
// User page fixture
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json'
})
const page = await context.newPage()
await use(page)
await context.close()
},
// Page Object fixture
todoApp: async ({ page }, use) => {
const todoApp = new TodoApp(page)
await todoApp.goto()
await use(todoApp)
},
})
export { expect }
// Page Object Model
class TodoApp {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/todos')
}
async addTodo(text: string) {
await this.page.getByPlaceholder('What needs to be done?').fill(text)
await this.page.keyboard.press('Enter')
}
async toggleTodo(text: string) {
await this.page.getByRole('listitem')
.filter({ hasText: text })
.getByRole('checkbox')
.click()
}
async deleteTodo(text: string) {
await this.page.getByRole('listitem')
.filter({ hasText: text })
.hover()
await this.page.getByRole('button', { name: 'Delete' }).click()
}
async getTodoCount() {
return await this.page.getByRole('listitem').count()
}
}// tests/todo.spec.ts
import { test, expect } from '../fixtures'
test('can add and complete todos', async ({ todoApp }) => {
await todoApp.addTodo('Buy groceries')
await todoApp.addTodo('Do laundry')
expect(await todoApp.getTodoCount()).toBe(2)
await todoApp.toggleTodo('Buy groceries')
await todoApp.deleteTodo('Do laundry')
expect(await todoApp.getTodoCount()).toBe(1)
})Running tests
Basic commands
# Run all tests
npx playwright test
# Specific file
npx playwright test tests/login.spec.ts
# Specific test (grep)
npx playwright test -g "login works"
# Specific browser
npx playwright test --project=chromium
npx playwright test --project=firefox
# Headed mode (visible browser)
npx playwright test --headed
# Debug mode
npx playwright test --debug
# UI mode (interactive)
npx playwright test --ui
# Only failed tests
npx playwright test --last-failed
# Repeat test X times
npx playwright test --repeat-each=3Reports
# HTML report
npx playwright show-report
# Trace viewer
npx playwright show-trace trace.zipCodegen - recording tests
# Record interactions and generate code
npx playwright codegen https://example.com
# With device emulation
npx playwright codegen --device="iPhone 13"
# With viewport
npx playwright codegen --viewport-size=800,600
# Save to file
npx playwright codegen -o tests/recorded.spec.tsCI/CD integration
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30Sharding (parallel CI)
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- name: Run tests
run: npx playwright test --shard=${{ matrix.shard }}Best practices
Stable selectors
// Good - semantic, stable
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByTestId('login-form')
// Bad - brittle, implementation dependent
page.locator('.btn-primary')
page.locator('#submit-btn')
page.locator('div > button:nth-child(2)')Test isolation
// Good - each test independent
test.beforeEach(async ({ page }) => {
// Reset state before each test
await page.goto('/')
})
// Bad - dependency between tests
// Test A should not rely on state from Test BTest ID for difficult elements
<!-- In the application -->
<button data-testid="checkout-button">Complete Purchase</button>// In the test
await page.getByTestId('checkout-button').click()FAQ - Frequently asked questions
How to debug failed tests?
- Run with
--debug:npx playwright test --debug - Use Trace Viewer to analyze trace.zip
- Add
await page.pause()in the test code - Use UI mode:
npx playwright test --ui
How to test responsiveness?
test('mobile layout', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
})How to handle a popup/dialog?
page.on('dialog', dialog => dialog.accept())
await page.getByRole('button', { name: 'Delete' }).click()How to test a new window/tab?
const [newPage] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: 'Open in new tab' }).click()
])
await expect(newPage).toHaveURL('/new-page')Flaky tests, the biggest enemy
Auto waiting removes the most common causes of random failures, but not all of them. The remaining ones are worth knowing, because they destroy a team's trust in the suite, and without trust nobody reads those tests.
The first is shared state. Tests run in parallel, so two of them operating on the same database record will break each other in an order dependent way. The fix is for each test to create its own data and clean up after itself rather than relying on what a previous one left behind.
The second is dependence on wall clock time. A test checking that a notification disappears after five seconds will pass on a fast machine and fail on a loaded build server. Rather than waiting for time to elapse, control the browser's clock or assert on the outcome rather than the moment.
The third is external services. A test calling a real payment API will fail on the day that service has an outage, rightly so, but that was not what the test was about. Intercepting requests and returning a fixed response settles it.
The fourth is animations. An element mid transition can be technically visible and not yet visually in place, which particularly breaks screenshot comparisons. Disabling animations during tests is standard practice here.
Once a test starts flaking, do not disable it and do not add retries hoping it passes on the third attempt. Retries make sense as a safety net rather than as a way of hiding a problem, since a test passing one time in three guarantees nothing.
Cost, and when not to write browser tests
End to end tests are the most expensive rung of the testing pyramid, and that is worth accounting for when planning rather than writing them reflexively for everything.
They cost execution time. Launching a real browser, loading the application, and walking through the interface takes seconds, while a unit test finishes in milliseconds. A suite of five hundred such tests can stretch a pipeline to a quarter of an hour, and that is time the team spends waiting.
They also cost maintenance. Every interface change potentially breaks a test even when the logic works correctly. The more tests assert on presentation details, the more often you fix tests instead of writing code.
A practical rule that holds up: use browser tests for the paths you earn on or whose breakage would hurt most. Signup, login, placing an order, payment. Everything else, form validation, data formatting, conditional logic, gets checked more cheaply and faster at a lower level, in TypeScript without launching a browser.
Twenty well chosen end to end tests give more confidence than two hundred checking everything in sequence, because somebody will fix those twenty when they start failing, and nobody will maintain two hundred.
Documentation and the full API are on the project site, and the source lives in the GitHub repository.