Learning Session Summary

Playwright & E2E Testing

End-to-end testing fundamentals — test structure, page interaction, assertions, auto-waiting, and fixtures

What is E2E Testing?

Unit Tests
Verify a single function or component in isolation. Everything around it is mocked. Fast, focused, and plentiful.
E2E Tests
Simulate a real user interacting with your full, running application — browser, server, database, everything together.
Key analogy

Unit tests cover individual bricks. Integration tests check that bricks fit together. E2E tests verify that the whole building stands and the doors open.

How E2E differs from unit testing
  • You test user flows — "can a user sign up, log in, and place an order?" instead of "does this function return the right value?"
  • You need a running app — server, database, and services must all be up
  • They're slower — seconds per test instead of milliseconds, because real pages render and real network requests fire
  • Async is everywhere — page loads, API calls, elements appearing after JS runs
  • Selectors target the real DOM — you query actual rendered pages, not shallow-rendered components

Test Structure

test.describe() — grouping tests

Groups related tests under a label, like a test suite. The label appears as a section header in the test report.

test.describe('Login', () => {
  // all login-related tests go here
});
test() — a single test case

The page parameter is a fixture — Playwright automatically creates a fresh browser tab for each test and destroys it after. You never manage browsers yourself.

test('should redirect after login', async ({ page }) => {
  // page is a fresh browser tab — isolated from other tests
});
beforeAll
Runs once before all tests in the describe block. Use for expensive setup like database seeding. Runs once per suite, not per test.
beforeEach
Runs before each individual test. Use for per-test setup like navigating to the starting page so every test begins in the same state.
test.beforeAll(async () => {
  await resetAndSeed(); // DB in known state — runs once
});

test.beforeEach(async ({ page }) => {
  await page.goto('/login'); // every test starts on the login page
});

Fixtures

What is a fixture?

Something Playwright prepares before your test runs and cleans up after it finishes. You declare what you need, Playwright handles the lifecycle. Similar in spirit to JUnit's @BeforeEach / @AfterEach, but with cleaner syntax.

Key analogy

Like fixtures in a physical workshop that hold a piece steady while you work on it — they're "fixed in place" before the real work begins.

FixtureWhat it provides
pageA fresh browser tab — the most common fixture. Isolated per test.
contextA full browser context — useful when you need multiple tabs.
browserThe browser instance itself — rarely needed directly.
requestAn API request context — useful for setting up preconditions via API calls.
Custom fixtures

You can create your own fixtures for reusable preconditions — e.g. a loggedInPage that handles login before each test. This keeps tests clean and avoids repetitive setup.

export const test = base.extend({
  loggedInPage: async ({ page, request }, use) => {
    // Setup: login via API
    const token = await loginViaAPI(request);
    await page.context().addCookies([...]);
    await use(page); // hand ready page to test
    // Teardown: automatic
  },
});

Interacting with the Page

page.goto() — navigation

Navigates the browser. Uses the baseURL from playwright.config.ts, so '/login' becomes http://localhost:3000/login.

await page.goto('/login');
page.getByRole() — finding elements

Playwright's recommended approach — it mirrors how a real user perceives the page. They see "a text field labeled email", not <input id="email-field">. The regex /email/i is a case-insensitive match on the accessible name.

// Find by accessible role — recommended approach
page.getByRole('textbox', { name: /email/i })
page.getByRole('button', { name: /sign in/i })

// Other locator methods
page.getByText('Welcome')
page.getByLabel('Password')
fill()
Clears the input and types the value. Simulates a user typing into a form field.
click()
Clicks an element. Playwright auto-waits for the element to be visible and clickable before acting.
// Complete interaction example
await page.getByRole('textbox', { name: /email/i }).fill('user@test.com');
await page.getByRole('textbox', { name: /password/i }).fill('secret123');
await page.getByRole('button', { name: /sign in/i }).click();

Assertions

Playwright assertions auto-wait

Unlike Jest's instant assertions, Playwright assertions poll repeatedly until the condition is met or the timeout expires (default: 5 seconds). No need for setTimeout or manual waitFor calls.

AssertionWhat it checks
expect(page).toHaveURL('/')Browser URL matches. Auto-waits for redirects.
expect(locator).toBeVisible()Element is visible on the page.
expect(locator).toHaveText('...')Element's text content matches.
expect(locator).not.toBeVisible()Negated — element should NOT be on screen.
// After login, assert the redirect happened
await expect(page).toHaveURL('/');

// Assert a welcome message is visible
await expect(page.getByText('Welcome, Admin')).toBeVisible();

// Assert an error is NOT shown
await expect(page.getByText('Invalid credentials')).not.toBeVisible();

Auto-Wait & Race Conditions

What is a race condition?

When the outcome depends on the timing of two things happening, and that timing isn't guaranteed. In E2E testing: your test clicks a button that triggers an API call, and the next line checks for a result that isn't there yet. Sometimes the API is fast enough and the test passes. Sometimes it isn't, and the test fails — not because your app is broken, but because the test and the app were "racing" each other.

Test clicks button
API call starts
Test checks result
API still loading...
💥 Test fails
Element not found yet
How Playwright solves this

Playwright auto-waits for actionability checks before every action and polls assertions until they pass. No sleep(2000) hacks needed.

  • fill() — waits for the input to be visible and editable
  • click() — waits for the button to be visible and not covered by another element
  • toHaveURL() — polls the URL until it matches or times out
  • toBeVisible() — polls until the element appears or times out
Important distinction

await is a JavaScript language requirement for Promises — you always need it. Auto-waiting is Playwright's internal smart behaviour that happens inside those awaited methods. Forgetting await causes exactly the race conditions Playwright is designed to prevent.

Testing with Preconditions

The problem

Testing "create a manual step" requires the user to be logged in, have picked a project, and have a manual already. Clicking through all those UI flows in every test is slow, fragile, and means a broken login page fails every test in your suite.

The principle

Only test the thing you're actually testing through the UI. Everything else (login, project creation, manual creation) should be set up via shortcuts — API calls or database seeding.

Login via API
Fast, no UI
Create project via API
Fast, no UI
Create manual via API
Fast, no UI
Test step creation
Through the UI ✓
test('create manual step', async ({ page, request }) => {
  // Preconditions via API — fast, no UI involved
  const token = await loginViaAPI(request);
  const project = await createProjectViaAPI(request, token);
  const manual = await createManualViaAPI(request, token, project.id);

  // NOW test only the step creation through the UI
  await page.goto(`/projects/${project.id}/manuals/${manual.id}`);
  await page.getByRole('button', { name: /add step/i }).click();
  await page.getByLabel('Step title').fill('Install dependencies');
  await page.getByRole('button', { name: /save/i }).click();

  await expect(page.getByText('Install dependencies')).toBeVisible();
});

Measuring Performance

Basic timing check

Playwright's main job is "does it work?", not "how fast is it?" — but you can do simple timing checks. For serious performance testing, use dedicated tools like Lighthouse, k6, or WebPageTest.

const start = Date.now();
await page.click('#load-data');
await expect(page.getByText('Data loaded')).toBeVisible();
const duration = Date.now() - start;

expect(duration).toBeLessThan(3000); // fail if > 3 seconds

The Golden Rule of E2E Testing

Only one test should go through the login UI (your login test). Only one test should create a project through the UI (your project creation test). Every other test that needs those as preconditions should set them up via API calls — keeping each test fast, focused, and independent.

🔄 Parallels you discovered