Nathen Watters
testing16 min read

Organizing Reusable Flows in Playwright Without Making a Mess

When tests share multi-step setup, the naive approaches — copy-paste, beforeEach, and helper functions — all have failure modes. Here's how fixtures, test.step, and flow facades keep large suites maintainable.

·
testinge2eplaywrighttest-designautomation

You've written a 20-step user journey test that asserts on each transition. It's debuggable, it fails clearly, it works. Then you need to write another test that covers a different outcome of the same journey — and now you have the same 14 steps duplicated across two test files.

Then a third test. Then a fourth. You've traded one problem (undebuggable failures) for another (duplicated setup that drifts out of sync).

This is the flow reuse problem. The solutions seem obvious — beforeEach, a shared helper function, pull it into a Page Object — but each approach has a failure mode that only shows up at scale. Here's how to think through the options and when each one earns its place.

The Problem With Copy-Paste (And Why People Do It)

Copy-paste is the first instinct because it's the most explicit. Every test is self-contained. You can read it start to finish and know exactly what it does.

The cost arrives when step 3 of that 14-step setup changes. You find seven test files with slightly diverged versions of the same setup block, some already updated, some not, and no clear way to tell which ones are broken.

Explicit is good. Explicit-and-duplicated is not. The goal is to stay explicit about what you're testing while avoiding duplication of how you get there.

beforeEach: Right Tool, Know the Scope

beforeEach is the most natural place to extract shared setup. Declared inside a describe block, it runs before every test in that block. Declared at the top level of a file, it runs before every test in the file. For setup that's genuinely required by every test in a given scope, it's the right call.

test.describe('project deletion', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/projects')
    await page.getByRole('button', { name: 'New project' }).click()
    await page.getByLabel('Project name').fill('Test Project')
    await page.getByRole('button', { name: 'Create' }).click()
    await expect(page.getByRole('heading', { name: 'Test Project' })).toBeVisible()
  })
 
  test('deleted project is removed from the list', async ({ page }) => { ... })
  test('deletion requires confirmation', async ({ page }) => { ... })
  test('deleted project cannot be accessed by direct URL', async ({ page }) => { ... })
})

beforeEach is genuinely the right choice here. All three tests need the same project to exist, the setup is short, and it lives in the same file as the tests that depend on it. A reader can see the precondition without leaving the file.

It's also the right choice for navigation and authentication that's uniform across a describe block:

test.describe('settings page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/settings')
  })
 
  test('displays current notification preferences', async ({ page }) => { ... })
  test('can update email address', async ({ page }) => { ... })
  test('shows danger zone section', async ({ page }) => { ... })
})

Simple, obvious, no ceremony. Every test starts on the same page and the setup is right there.

Where it breaks down: beforeEach is scoped to a describe block, but the setup often gets reused across multiple files — so teams copy the beforeEach into each file. Now you're back to the duplication problem, just with extra structure around it.

The other failure mode is when beforeEach grows to cover setup that only some tests in the block actually need. Once you're adding conditionals or skipping teardown for specific tests, the shared setup is working against you:

test.describe('project dashboard', () => {
  test.beforeEach(async ({ page }) => {
    // This setup is only needed by two of the five tests in this block
    // Now all five pay the cost and the two that need it aren't obvious
    await createProjectAndNavigate(page)
  })
 
  test('empty state shows create button', async ({ page }) => { ... }) // doesn't need the project
  test('project card displays correct metadata', async ({ page }) => { ... }) // needs it
  test('sidebar shows active project', async ({ page }) => { ... }) // needs it
  test('header shows team name', async ({ page }) => { ... }) // doesn't need it
  test('notifications badge updates on new activity', async ({ page }) => { ... }) // doesn't need it
})

When this happens, split the describe block or move the targeted setup into the individual tests that need it.

Use beforeEach when every test in the block genuinely requires the same precondition, and that setup is specific enough to that block that it won't need to be shared elsewhere.

Fixtures: Shared Setup That Doesn't Lie

Playwright's fixture system is the right answer for setup that needs to be reused across test files. Fixtures are declared once and injected by name — similar to how dependency injection works in application code.

// fixtures/projects.ts
import { test as base } from '@playwright/test'
 
type Fixtures = {
  projectPage: { name: string; url: string }
}
 
export const test = base.extend<Fixtures>({
  projectPage: async ({ page }, use) => {
    // Setup — runs before the test
    await page.goto('/projects')
    await page.getByRole('button', { name: 'New project' }).click()
    await page.getByLabel('Project name').fill('Fixture Project')
    await page.getByRole('button', { name: 'Create' }).click()
    await expect(page.getByRole('heading', { name: 'Fixture Project' })).toBeVisible()
 
    await use({ name: 'Fixture Project', url: page.url() })
 
    // Teardown — runs after the test (optional)
    await page.request.delete('/api/projects/fixture-project')
  },
})
// specs/projects.spec.ts
import { test } from '../fixtures/projects'
import { expect } from '@playwright/test'
 
test('deleted project is removed from the list', async ({ page, projectPage }) => {
  await page.goto('/projects')
  await page.getByRole('row', { name: projectPage.name })
    .getByRole('button', { name: 'Delete' }).click()
  await page.getByRole('button', { name: 'Confirm' }).click()
 
  await expect(page.getByRole('row', { name: projectPage.name })).not.toBeVisible()
})

A few things worth noting:

  • The fixture is named and explicit — projectPage tells you exactly what precondition is in play
  • Setup and teardown live together, so cleanup doesn't get forgotten
  • Multiple test files can import and use the same fixture without duplication
  • Fixtures compose — you can build an authenticatedProjectPage fixture that extends both an auth fixture and a projectPage fixture
Tip

Playwright's built-in page, browser, and context are all fixtures under the hood. When you call base.extend(), you're using the same system Playwright uses internally.

Fixture lifecycle and the navigation gotcha

Fixtures have a scope — either 'test' (the default) or 'worker'. Understanding the difference matters as soon as your fixture touches navigation.

Test scope (default): the fixture runs fresh for every test. A new browser context and page are created, setup runs, the test executes, teardown runs, and the context is discarded. This is what the projectPage fixture above uses — each test gets a clean slate.

Worker scope: the fixture runs once per worker process and is shared across all tests assigned to that worker. Setup runs once, all the tests in that worker use the same instance, then teardown runs at the end. This is useful for expensive one-time setup like seeding a database or authenticating a session that doesn't change between tests.

The gotcha: if a worker-scoped fixture performs navigation, that navigation is part of the shared state. The fixture lands the browser on /projects/fixture-project — but by the time test 2 runs, test 1 may have navigated away. Test 2 starts wherever test 1 left off, not where the fixture put it.

type WorkerFixtures = {
  projectData: { name: string; url: string }
}
 
// Worker-scoped fixture — navigates once, shared across tests
export const test = base.extend<{}, WorkerFixtures>({
  projectData: [async ({ browser }, use) => {
    const context = await browser.newContext()
    const page = await context.newPage()
 
    await page.goto('/projects')
    await page.getByRole('button', { name: 'New project' }).click()
    await page.getByLabel('Project name').fill('Shared Project')
    await page.getByRole('button', { name: 'Create' }).click()
 
    // Only expose the data — not the page
    const url = page.url()
    await context.close()
 
    await use({ name: 'Shared Project', url })
  }, { scope: 'worker' }],
})
// Each test navigates explicitly — doesn't assume where the browser is
test('project settings are accessible', async ({ page, projectData }) => {
  await page.goto(projectData.url + '/settings') // explicit, not assumed
  await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
})

The pattern that avoids the problem: worker-scoped fixtures should expose data (IDs, URLs, names) rather than a live page reference. Let each test handle its own navigation using that data. Test-scoped fixtures can safely navigate because each test gets a fresh context anyway.

Warning

If your tests pass in isolation but fail when run together, a shared fixture navigating a shared page is the first thing to check. Run with --workers=1 to confirm — if the failures disappear, you have a lifecycle scoping issue.

test.step: Keeping Long Tests Readable

Even with fixtures handling setup, a long user journey test can become hard to scan. test.step doesn't change test behavior — it adds labeled checkpoints that show up in the HTML report and in error output.

test('user completes onboarding and sees dashboard', async ({ page }) => {
  await test.step('complete profile', async () => {
    await page.goto('/onboarding')
    await page.getByLabel('Full name').fill('Nathen Watters')
    await page.getByLabel('Job title').fill('SDET')
    await page.getByRole('button', { name: 'Continue' }).click()
    await expect(page.getByRole('heading', { name: 'Connect your tools' })).toBeVisible()
  })
 
  await test.step('connect GitHub integration', async () => {
    await page.getByRole('button', { name: 'Connect GitHub' }).click()
    await page.getByRole('button', { name: 'Authorize' }).click()
    await expect(page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
  })
 
  await test.step('skip team invites', async () => {
    await page.getByRole('button', { name: 'Skip for now' }).click()
    await expect(page.getByRole('heading', { name: "You're all set" })).toBeVisible()
  })
})

When this test fails, the report shows exactly which step failed — "connect GitHub integration" — rather than a line number you have to cross-reference with the source. The intermediate expect assertions still fail at the right moment; test.step just adds a label around them.

If the steps are reused across multiple tests, they can be extracted as functions that accept a page parameter:

async function completeProfileStep(page: Page, profile: { name: string; title: string }) {
  await page.goto('/onboarding')
  await page.getByLabel('Full name').fill(profile.name)
  await page.getByLabel('Job title').fill(profile.title)
  await page.getByRole('button', { name: 'Continue' }).click()
  await expect(page.getByRole('heading', { name: 'Connect your tools' })).toBeVisible()
}
 
async function connectGitHubStep(page: Page) {
  await page.getByRole('button', { name: 'Connect GitHub' }).click()
  await page.getByRole('button', { name: 'Authorize' }).click()
  await expect(page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
}
test('user with GitHub connected sees integration badge', async ({ page }) => {
  await test.step('complete profile', () => completeProfileStep(page, { name: 'Nathen', title: 'SDET' }))
  await test.step('connect GitHub', () => connectGitHubStep(page))
  // test-specific assertions follow
})

The test is readable. The step functions are importable. The test.step wrappers preserve the labeled output in reports.

API-backed steps

Not every step needs to go through the UI. Reaching a specific application state via the API — creating a resource, seeding a user role, triggering a background job — is faster, more reliable, and removes UI interactions that aren't the subject of the test. These fit naturally into the same step function pattern, just using Playwright's request context instead of page.

import { expect, type APIRequestContext } from '@playwright/test'
 
async function createProjectViaApi(
  request: APIRequestContext,
  project: { name: string; ownerId: string }
): Promise<{ id: string }> {
  const response = await request.post('/api/projects', {
    data: project,
  })
  await expect(response).toBeOK()
  return response.json()
}
 
async function assignMemberViaApi(
  request: APIRequestContext,
  projectId: string,
  userId: string
) {
  const response = await request.post(`/api/projects/${projectId}/members`, {
    data: { userId },
  })
  await expect(response).toBeOK()
}

Used inside a test with test.step, API steps slot in the same way as UI steps — but they don't touch the browser:

test('project member can view but not edit settings', async ({ page, request }) => {
  let projectId: string
 
  await test.step('seed project and member via API', async () => {
    const project = await createProjectViaApi(request, {
      name: 'Test Project',
      ownerId: 'user-owner',
    })
    projectId = project.id
    await assignMemberViaApi(request, projectId, 'user-member')
  })
 
  await test.step('sign in as member', async () => {
    await page.goto('/login')
    await page.getByLabel('Email').fill('member@example.com')
    await page.getByLabel('Password').fill('password')
    await page.getByRole('button', { name: 'Log in' }).click()
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
  })
 
  await test.step('navigate to project settings', async () => {
    await page.goto(`/projects/${projectId}/settings`)
    await expect(page.getByRole('heading', { name: 'Project settings' })).toBeVisible()
  })
 
  await expect(page.getByRole('button', { name: 'Save changes' })).toBeDisabled()
})

The first step is pure API — fast and silent. The test report still labels it, so if the seed request fails you see "seed project and member via API" in the output rather than a confusing failure on the login step. The UI steps that follow only cover what actually needs browser interaction.

This also keeps the step functions reusable across tests that need the same application state without caring how it got there.

Flow Facades for Multi-Stage Journeys

When a flow involves many steps that always go together — and you're composing those steps into tests that cover different outcomes — a facade class is a clean way to group them without forcing callers into one path.

A facade differs from the god-class Page Object in intent: it models a specific journey, not a page. It exposes named steps that callers invoke explicitly, rather than hiding the journey behind a single opaque method.

// flows/OnboardingFlow.ts
export class OnboardingFlow {
  constructor(private page: Page) {}
 
  async completeProfile(profile: { name: string; title: string }) {
    await this.page.goto('/onboarding')
    await this.page.getByLabel('Full name').fill(profile.name)
    await this.page.getByLabel('Job title').fill(profile.title)
    await this.page.getByRole('button', { name: 'Continue' }).click()
    await expect(this.page.getByRole('heading', { name: 'Connect your tools' })).toBeVisible()
  }
 
  async connectGitHub() {
    await this.page.getByRole('button', { name: 'Connect GitHub' }).click()
    await this.page.getByRole('button', { name: 'Authorize' }).click()
    await expect(this.page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
  }
 
  async skipIntegration() {
    await this.page.getByRole('button', { name: 'Skip integration' }).click()
    await expect(this.page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
  }
 
  async skipTeamInvites() {
    await this.page.getByRole('button', { name: 'Skip for now' }).click()
    await expect(this.page.getByRole('heading', { name: "You're all set" })).toBeVisible()
  }
}
// One test reaches step 3 via one path
test('user who skips GitHub still completes onboarding', async ({ page }) => {
  const onboarding = new OnboardingFlow(page)
  await test.step('complete profile', () => onboarding.completeProfile({ name: 'Nathen', title: 'SDET' }))
  await test.step('skip integration', () => onboarding.skipIntegration())
  await test.step('skip team invites', () => onboarding.skipTeamInvites())
 
  await expect(page.getByRole('heading', { name: "You're all set" })).toBeVisible()
})
 
// Another test takes a different path
test('user with GitHub connected sees integration badge on dashboard', async ({ page }) => {
  const onboarding = new OnboardingFlow(page)
  await test.step('complete profile', () => onboarding.completeProfile({ name: 'Nathen', title: 'SDET' }))
  await test.step('connect GitHub', () => onboarding.connectGitHub())
  await test.step('skip team invites', () => onboarding.skipTeamInvites())
 
  await page.goto('/dashboard')
  await expect(page.getByRole('status', { name: 'GitHub connected' })).toBeVisible()
})

Both tests share the same step implementations. Each test explicitly invokes the steps it needs. The assertions at the end are in the test, not the facade.

The facade is also the natural home for combining with test.step if you want labeling to be automatic:

// Inside OnboardingFlow
async completeProfile(profile: { name: string; title: string }) {
  return test.step('complete profile', async () => {
    // ...
  })
}

Now callers get labeled output without having to wrap every call themselves. But if your facade has many methods, wrapping each one in test.step manually is its own form of boilerplate. TypeScript decorators can eliminate it entirely.

The @step decorator

A @step decorator wraps a method in test.step automatically, using the method name as the label. A single implementation can support both @step (bare) and @step('custom label') by checking what it received as its first argument:

// utils/step.ts
import { test } from '@playwright/test'
 
type StepDecorator = (target: Function, context: ClassMethodDecoratorContext) => Function
 
export function step(labelOrTarget: string | Function, context?: ClassMethodDecoratorContext): StepDecorator | Function {
  // Called as @step('custom label') — return the actual decorator
  if (typeof labelOrTarget === 'string') {
    const label = labelOrTarget
    return function(target: Function, _context: ClassMethodDecoratorContext) {
      return function(this: unknown, ...args: unknown[]) {
        return test.step(label, () => target.apply(this, args))
      }
    }
  }
 
  // Called as @step (bare) — labelOrTarget is the method, context is provided
  const target = labelOrTarget
  const label = String(context!.name)
  return function(this: unknown, ...args: unknown[]) {
    return test.step(label, () => target.apply(this, args))
  }
}

Both forms work with the same import:

import { step } from '../utils/step'
 
export class OnboardingFlow {
  constructor(private page: Page) {}
 
  @step
  async completeProfile(profile: { name: string; title: string }) {
    await this.page.goto('/onboarding')
    await this.page.getByLabel('Full name').fill(profile.name)
    await this.page.getByLabel('Job title').fill(profile.title)
    await this.page.getByRole('button', { name: 'Continue' }).click()
    await expect(this.page.getByRole('heading', { name: 'Connect your tools' })).toBeVisible()
  }
 
  @step
  async connectGitHub() {
    await this.page.getByRole('button', { name: 'Connect GitHub' }).click()
    await this.page.getByRole('button', { name: 'Authorize' }).click()
    await expect(this.page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
  }
 
  @step('skip integration')
  async skipIntegration() {
    await this.page.getByRole('button', { name: 'Skip for now' }).click()
    await expect(this.page.getByRole('heading', { name: 'Invite your team' })).toBeVisible()
  }
 
  @step('skip team invites')
  async skipTeamInvites() {
    await this.page.getByRole('button', { name: 'Skip for now' }).click()
    await expect(this.page.getByRole('heading', { name: "You're all set" })).toBeVisible()
  }
}

Callers no longer need test.step wrappers — the decorator handles it, and the report shows the label from either the method name or the custom string:

test('user who skips GitHub still completes onboarding', async ({ page }) => {
  const onboarding = new OnboardingFlow(page)
  await onboarding.completeProfile({ name: 'Nathen', title: 'SDET' })
  await onboarding.skipIntegration()
  await onboarding.skipTeamInvites()
 
  await expect(page.getByRole('heading', { name: "You're all set" })).toBeVisible()
})
Warning

The implementation above uses the TC39 stage 3 decorator API (ClassMethodDecoratorContext), which is the default in TypeScript 5+ and requires no tsconfig flags. If your project has "experimentalDecorators": true in tsconfig.json, TypeScript uses the legacy decorator API instead — the two are incompatible and the implementation above will not work as written. Remove the flag to use stage 3 decorators, or rewrite the implementation using the legacy signature (target: any, propertyKey: string, descriptor: PropertyDescriptor).

Choosing the Right Tool

The approaches aren't mutually exclusive — most suites use all of them in different contexts:

SituationRecommended approach
Setup specific to all tests in one filebeforeEach in a describe block
Setup shared across multiple test filesPlaywright fixture
Long test that needs readable failure outputtest.step
Multi-step journey reused across tests with different outcomesFlow facade class
Simple shared interaction with no statePlain imported function

The common thread: make preconditions visible, make failures specific, and don't let shared code hide what a test actually depends on. A test that's easy to understand is usually a test that's easy to fix.