Smoketest

Playwright Fixtures vs Page Object Model in 2026

Your Playwright suite needs shared authentication, test data, and selectors, but choosing between fixtures and page objects can create more structure than clarity. In 2026, start with fixtures for lifecycle and dependency injection, add thin page helpers where repetition is costly, and reserve a full POM for large suites with dedicated owners.

9 min read

Your Playwright suite has reached the point where every test needs an authenticated user, seeded data, and the same locators. You are deciding whether to build a fixture layer, a page object model, or both, before another hundred tests make the choice harder to reverse.

As of Playwright 1.61, use fixtures first for dependencies, setup, teardown, and isolation. Add thin page helpers for repeated UI vocabulary. A full Playwright POM is worth its cost mainly in very large suites with dedicated owners. Fixtures and page objects are complementary, but most suites need much less page abstraction than older testing advice suggests.

The choice is a false one. Playwright's own fixtures guide uses a TodoPage object inside a fixture. The useful question is which responsibilities deserve abstraction, and who will keep them current.

The short decision guide

Fixtures and page objects solve different problems. A fixture controls when a resource is created, who receives it, and when it is cleaned up. A page object gives UI interactions names that match your application.

NeedFixturesPage object modelFixtures with thin helpers
Authentication and test data lifecycleBest fitPoor fitBest fit
Per-test or per-worker scopeBuilt inMust be designed separatelyBuilt in
Centralized selectorsPossible, but not the main purposeBest fitGood fit for repeated areas
Test readabilityDirect dependencies in the signatureCan read well, but adds indirectionUsually the best balance
Generated test compatibilityEnvironment arrives through fixturesDeep methods can hide test intentFixtures plus visible locator calls
Ownership costModerateHigh as the app and suite growModerate

What Playwright fixtures actually are

Playwright fixtures are dependency injection with lifecycle management. A test names what it needs in its function arguments, and the runner supplies those values. The fixture can perform setup before use, then teardown after it. The official definition says fixtures establish each test's environment while giving it only what it needs (Playwright fixtures documentation).

The generic parameters make the two lifetimes visible:

playwright/fixtures.ts

TypeScript
import { test as base } from '@playwright/test';

type TestFixtures = {
  projectName: string;
};

type WorkerFixtures = {
  accountId: string;
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
  projectName: async ({}, use) => {
    await use(`project-${Date.now()}`);
  },
  accountId: [
    async ({}, use, workerInfo) => {
      await use(`account-${workerInfo.workerIndex}`);
    },
    { scope: 'worker' },
  ],
});

export { expect } from '@playwright/test';

The first type argument contains test-scoped fixtures. They set up and tear down for each test. The second contains worker-scoped fixtures. They live until that worker shuts down. Dependencies determine ordering: if fixture A needs fixture B, B starts first and tears down last. Fixtures without auto: true run only when a test or another fixture asks for them (fixture execution order).

The options answer most fixture design questions:

OptionUse it for
scope: 'test'Isolated state that should reset for every test. This is the default.
scope: 'worker'Expensive state that tests in one worker can safely share.
auto: trueSetup that must run even when a test does not name the fixture.
option: trueValues projects or test.use() should override.
timeoutA separate allowance for slow fixture setup or teardown.
box: trueHiding fixture detail from reports.
titleA clearer fixture name in reports and errors.

Playwright documents fixtures as reusable, on-demand, composable, and flexible. They keep setup beside teardown and remove the need to wrap tests in describe blocks just to establish an environment (fixture advantages). Fixtures make dependencies explicit and give them a scope.

Built-in fixtures follow the same model. page, context, and request are per-test. browser is shared by a worker. browserName identifies Chromium, Firefox, or WebKit (built-in fixtures).

What a Playwright POM gives you

A Playwright page object model wraps a page or part of a page in a class. The official POM guide gives it two jobs: expose an application-specific API and keep selectors in one reusable place. Its example stores a Page, initializes Locator fields in the constructor, and provides named methods such as goto() and getStarted().

That can make a test read at the right level. checkout.enterAddress() communicates more intent than six locator calls. When a selector changes across 80 tests, one edit is attractive. The class shape is also familiar to teams coming from Selenium or older end-to-end frameworks.

The cost is indirection. To understand a failed checkout.submitOrder(), you have to leave the test and find the locator or assertion inside the class. Large page objects also collect unrelated responsibilities, and methods can outlive the tests they represent.

If half the suite uses accountPage.save() and the other half calls getByRole() directly, reviewers must decide which level is correct. Moving every interaction behind a method hides useful details. Playwright's best-practices guide emphasizes isolation, user-facing locators, and web-first assertions. It allows some duplication when that keeps tests easier to understand.

The 2026 default is fixtures first

For most suites, use fixtures as the structural layer. Put accounts, authentication state, API clients, seeded records, feature configuration, and cleanup there. These resources have lifetimes and dependencies, which is exactly what the fixture runner manages.

Keep ordinary test actions in the test until repetition becomes costly. Prefer user-facing locators such as getByRole() over CSS or XPath, and use web-first assertions such as expect(locator).toBeVisible(), which retry until their condition is met (Playwright best practices). A test with four visible interactions is often easier to debug than a test with four high-level methods whose bodies are elsewhere.

When repetition does become noisy, add a thin helper for one cohesive area:

playwright/helpers/project-list.ts

TypeScript
import type { Page } from '@playwright/test';

export class ProjectList {
  constructor(readonly page: Page) {}

  projectLink(name: string) {
    return this.page.getByRole('link', { name });
  }
}

playwright/fixtures.ts

TypeScript
import { test as base } from '@playwright/test';
import { ProjectList } from './helpers/project-list';

type Fixtures = {
  projectList: ProjectList;
};

export const test = base.extend<Fixtures>({
  projectList: async ({ page }, use) => {
    await use(new ProjectList(page));
  },
});

export { expect } from '@playwright/test';

Playwright's fixture documentation shows the same composition: a page object created from the built-in page fixture (page object fixture example). The fixture owns lifecycle. The helper owns UI vocabulary.

A real auth and seeding fixture

Authentication and data setup show why fixture scope matters more than a large POM. The following pattern gives each parallel worker its own stored login state, then gives every test an isolated project created through the built-in API request fixture.

playwright/fixtures.ts

TypeScript
import fs from 'fs';
import path from 'path';
import { test as baseTest, expect } from '@playwright/test';
import { acquireAccount, signIn } from './auth-helpers';

type Project = {
  id: string;
  name: string;
};

type TestFixtures = {
  seededProject: Project;
};

type WorkerFixtures = {
  workerStorageState: string;
};

export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [
    async ({ browser }, use) => {
      const id = test.info().parallelIndex;
      const fileName = path.resolve(
        test.info().project.outputDir,
        `.auth/${id}.json`,
      );

      if (fs.existsSync(fileName)) {
        await use(fileName);
        return;
      }

      const page = await browser.newPage({ storageState: undefined });
      const account = await acquireAccount(id);
      await signIn(page, account);
      await page.context().storageState({ path: fileName });
      await page.close();
      await use(fileName);
    },
    { scope: 'worker' },
  ],

  seededProject: async ({ request }, use) => {
    const response = await request.post('/api/projects', {
      data: { name: `project-${Date.now()}` },
    });
    expect(response.ok()).toBeTruthy();

    const project = (await response.json()) as Project;
    await use(project);
    await request.delete(`/api/projects/${project.id}`);
  },
});

export { expect } from '@playwright/test';

This adapts Playwright's documented per-worker authentication pattern. The worker state is keyed by test.info().parallelIndex, not workerIndex, and it overrides the built-in storageState fixture. Creating the login page with storageState: undefined prevents inherited authentication. Playwright recommends keeping stored state under playwright/.auth and excluding it from Git because it can contain sensitive cookies and headers.

The seeded project remains test-scoped. It is created with request.post(), checked through response.ok(), passed to the test, and deleted after the test releases it. API-based setup follows Playwright's documented API testing pattern without making the browser repeat setup that the API can perform directly.

Login is expensive and safe to share within one worker account. The project is mutable and should not leak between tests. A page object cannot enforce that distinction.

When a full POM still earns its keep

A full POM can fit a very large suite when many tests share mature UI areas, the team wants stable domain vocabulary, and someone owns the page layer as production code.

Without that ownership, size alone is not enough. A smaller suite can benefit from a focused helper used across 40 checkout cases. Count shared behavior, not test files.

If you choose a full POM, keep assertions close to the test when they describe the test's purpose. Keep page methods small enough that a failure still points to one meaningful interaction. Split objects by cohesive page region or user task, not because every URL must have a class.

How AI-generated tests change the calculus

Playwright added planner, generator, and healer agents in version 1.56. In the documented workflow, the planner explores the application and writes a Markdown plan. The generator turns that plan into executable tests while checking selectors and assertions against the live app. The healer runs failures, patches locators, and reruns the test (Playwright test agents).

The integration point is a seed test. It imports test and expect from a local fixtures module, allowing the planner to execute the necessary fixtures and hooks. The generated examples use semantic locator calls inline. Custom environment logic therefore transfers through fixtures, while a deep page-object hierarchy is less visible to the documented workflow.

Generated code is easiest to inspect when important actions and assertions remain in the test. A reviewer can see the role, accessible name, and expected result without opening several classes. Fixtures still provide authentication and seeded data without duplicating setup.

This is not an argument to ban helpers. Give generated tests a small, stable fixture surface and use thin helpers for interactions your team genuinely wants standardized. Avoid teaching the generator a large internal framework unless that framework has owners who will review its output and keep its contracts current. After Playwright upgrades, the agent documentation says to regenerate the agent definitions with init-agents.

The rule to carry forward

Use fixtures for resources and lifecycle. Use direct locators for test-specific behavior. Introduce a thin page helper after repeated interactions obscure the test's intent. Move to a full Playwright POM only when the suite has shared domain language and dedicated ownership.

Fixtures make isolation and cleanup enforceable. Helpers remove costly repetition. Tests retain enough detail that a person or generation tool can understand a failure without touring the framework.

FAQ

Are Playwright fixtures a replacement for POM?

No. Playwright fixtures manage dependencies, setup, teardown, and scope. A page object model encapsulates selectors and UI operations. They can work together, and Playwright's fixtures guide demonstrates that combination. Most suites should begin with fixtures, then add thin page helpers only where repeated UI interactions make tests harder to read or maintain.

What scope should my Playwright auth fixture use?

Use worker scope when each parallel worker can safely reuse one authenticated account and storage state. Use test scope when authentication state changes during a test or accounts cannot be shared. Playwright's documented per-worker pattern keys state files by test.info().parallelIndex and gives each worker a separate account.

Is Playwright POM still useful in 2026?

Yes, especially in large suites where many tests share stable UI areas and a team owns the abstraction. It is less useful when page methods merely hide short, readable locator calls. Start with direct semantic locators, extract cohesive repeated interactions, and let observed maintenance cost justify a broader POM instead of adopting one by default.

Do Playwright agents use page objects?

The documented Playwright agent workflow imports custom fixtures through a seed test and generates tests with semantic locator calls inline. The docs do not say agents can never produce page objects. In practice, fixtures are the documented bridge for environment setup, while deep page-object layers are less visible to generation and healing.

Can I combine multiple Playwright fixture modules?

Yes. Playwright provides mergeTests() for combining fixture definitions, introduced in version 1.39. Import the test objects, call mergeTests(dbTest, a11yTest), and export the result. This is useful when database, accessibility, and application fixtures have separate owners, though one clear fixture import per test file remains easier to follow.

Share this post

The QA column is no longer where the sprint goes to die.

Move one ticket. Watch it come back tested. Then decide.

Keep reading

All posts →