Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›Playwright›Writing maintainable and reusable test code

Playwright Best Practices and Ecosystem

Writing maintainable and reusable test code

This module explores strategies to build resilient Playwright test suites through abstraction and modularity. Maintainable code reduces the technical debt associated with UI changes, ensuring that your automated verification remains stable over time. You should reach for these techniques whenever you notice repetitive selectors or duplicated interaction logic across multiple test files.

Embracing Localized Selectors with Data Attributes

The foundation of maintainable tests lies in how you identify elements. Relying on CSS classes or deep DOM structures makes tests fragile, as these often change during refactoring or stylistic updates. Instead, use custom attributes like 'data-testid' that are dedicated solely to testing purposes. This decoupling ensures that your test logic remains agnostic to visual changes, focusing strictly on the functional identity of the element. By selecting elements through these stable identifiers, you ensure that the test suite does not break every time a designer updates a layout or changes a CSS framework. This strategy shifts the burden of maintenance from the test suite back to the component architecture, requiring developers to think about testing identifiers as a first-class citizen in the application. Always prioritize these stable attributes to provide a clear contract between your tests and your application structure, allowing for long-term consistency in your verification pipeline.

// Instead of '.login-button-primary', use a dedicated test ID
// This attribute survives CSS refactors and framework migrations
await page.getByTestId('login-submit-button').click();

Encapsulating Logic with the Page Object Model

The Page Object Model (POM) is the standard for managing complexity in larger suites. By grouping selectors and actions into dedicated classes, you centralize the knowledge of your application's UI structure. When a field name changes or a modal is updated, you only need to modify the code in one file rather than updating every test that touches that component. This abstraction layer also forces you to write more readable code, as your test scripts can invoke human-readable methods like 'loginPage.submitCredentials()' rather than manually typing out selectors. This approach treats your pages as programmable interfaces, enhancing reusability across different test scenarios. Think of the POM as an API for your application's UI, where the internal implementation details are hidden behind meaningful method signatures. This architecture dramatically simplifies the onboarding process for new developers and prevents the 'shotgun surgery' problem where a simple UI tweak forces massive changes across your entire test repository.

class LoginPage {
  constructor(page) {
    this.page = page;
    this.username = page.getByLabel('Username');
  }
  async login(user, pass) {
    await this.username.fill(user);
    await this.page.fill('input[type="password"]', pass);
    await this.page.click('button[type="submit"]');
  }
}

Leveraging Custom Fixtures for Environment Setup

Fixtures are the mechanism by which you provide pre-configured environments to your tests. Rather than duplicating setup code in 'beforeEach' blocks, you can define fixtures that inject authenticated pages, database states, or mocked network responses into your tests. A fixture runs lazily, meaning it only executes if the test actually requires it, which optimizes performance. Furthermore, fixtures can handle teardown logic automatically, ensuring that state is cleaned up without cluttering the main test body. This pattern allows for granular control over the test environment; you can easily swap a database-backed fixture for a mocked version during CI runs to increase speed and reduce flakiness. By moving complex setup logic into fixtures, you keep your test files focused strictly on the user journey. This separation of concerns allows you to build complex testing ecosystems where common infrastructure needs are defined once and inherited by any test that requires specific capabilities.

// Defining a custom fixture in playwright.config.ts or a separate file
export const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('#user', 'admin');
    await use(page);
    // Teardown logic happens after the test ends
  }
});

Standardizing Reusable Web Components

When your application uses complex, repeated patterns like data tables, date pickers, or navigation menus, it is beneficial to create specialized component objects. Similar to the Page Object Model, these component classes encapsulate the specific behavior of a repeatable UI segment. If your application has a table that appears on five different pages, you should not redefine the logic for clicking rows or sorting columns five times. Instead, define a TableComponent class that takes a locator as a constructor argument. This locator represents the root element of the table. By doing this, your test code can instantiate multiple table components in a single view, treating them as individual objects. This modular approach promotes the 'DRY' (Don't Repeat Yourself) principle, making your codebase much smaller and easier to debug. When the table's sorting mechanism changes, you update one class, and the entire suite automatically benefits from the corrected logic, significantly reducing maintenance overhead over time.

class TableComponent {
  constructor(locator) {
    this.container = locator;
  }
  async getRow(index) {
    return this.container.locator('tr').nth(index);
  }
}

Writing Atomic Tests to Isolate Failures

An atomic test is one that performs a single task and verifies a specific outcome. When tests perform too many actions, they become fragile and difficult to debug because a single failure might be caused by any of the previous steps. By keeping tests small and focused, you ensure that failure logs point directly to the problematic interaction. Furthermore, atomic tests are easier to parallelize because they do not rely on a specific sequential state created by a 'god-test' that runs for ten minutes. If a feature fails, you only need to look at the specific atomic test for that feature to identify the regression. While some overlap is necessary, you should aim to make each test independent. This improves the reliability of your continuous integration pipeline, as a single flake in a massive, multi-step chain does not invalidate the entire suite. Keep tests lean to make the system resilient and easy to understand for any developer who maintains it.

// An atomic test focuses on one specific feature
test('should display error on invalid login', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#user', 'bad-user');
  await page.click('#submit');
  await expect(page.locator('.error')).toBeVisible();
});

Key points

  • Always use stable data-testid attributes instead of relying on fragile CSS selectors.
  • The Page Object Model centralizes element identifiers to simplify UI-wide updates.
  • Custom fixtures allow for clean, efficient, and lazy-loading test environment configuration.
  • Component-based abstractions prevent code duplication for repeated UI elements like tables.
  • Atomic tests provide clear failure signals by isolating functionality into individual units.
  • Separating test setup from test logic improves readability and promotes code reuse.
  • Maintainability is achieved by treating your test code with the same engineering rigor as production code.
  • Decoupling the test script from the underlying DOM structure is the primary defense against test flakiness.

Common mistakes

  • Mistake: Hardcoding selectors like '.btn-primary' in tests. Why it's wrong: These are tied to visual styling which changes often, causing brittle tests. Fix: Use data-testid attributes to decouple tests from styling.
  • Mistake: Writing large, monolithic test files. Why it's wrong: They are difficult to read, maintain, and parallelize. Fix: Use the Page Object Model (POM) to encapsulate actions and structure code logically.
  • Mistake: Overusing page.waitForTimeout(). Why it's wrong: It introduces arbitrary delays, making tests slow and flaky. Fix: Rely on Playwright's built-in auto-waiting mechanism and web-first assertions.
  • Mistake: Failing to clean up state between tests. Why it's wrong: Tests become interdependent, where the success of one relies on the side effects of another. Fix: Use test.beforeEach() to reset state or isolate tests in separate browser contexts.
  • Mistake: Repetitive code for login or common navigation. Why it's wrong: It violates DRY principles and leads to maintenance nightmares when flows change. Fix: Create reusable helper methods or POM fixtures to handle common workflows.

Interview questions

Why is it important to use Page Object Models (POM) in Playwright, and what problem does it solve?

The Page Object Model is essential in Playwright because it separates the technical implementation of element locators from the test logic. Without POM, if an element's selector changes—like a login button ID—you would have to update every single test file. With POM, you maintain a single class file representing that page. This makes your test suite significantly more maintainable, readable, and less prone to breaking when the UI evolves, as your test scripts remain decoupled from the structural changes occurring in the application.

How do you leverage Playwright's fixture system to write more reusable test code?

Playwright fixtures allow you to define reusable setup and teardown logic that can be injected into any test. Instead of writing 'beforeEach' blocks in every file to log in or configure the environment, you define a custom fixture in a config file. For example, by extending the base test object, you can create an 'authenticatedPage' fixture. This ensures that every test starts in a consistent, known state without duplicating boilerplate code across your entire test repository, ultimately keeping your test files focused solely on the user journey.

What is the best way to handle shared locators in a large Playwright test project?

The best approach is to centralize locators within your Page Objects rather than hardcoding them inside the test methods. Use descriptive getter methods or properties in your classes, such as 'this.loginButton = page.locator('#login-btn')'. By doing this, you treat the locator as a single source of truth. If the development team changes the underlying HTML tag or attribute, you update the locator in exactly one location, ensuring that all dependent tests automatically inherit the correction, which drastically reduces technical debt and maintenance overhead.

Compare using CSS selectors versus Playwright's built-in locator engines like 'getByRole' and 'getByLabel'. Which is more maintainable and why?

Using built-in locators like 'getByRole', 'getByLabel', or 'getByText' is vastly superior to using CSS or XPath selectors for maintainability. CSS selectors are fragile because they often rely on implementation details like class names or nested divs that change frequently during design refreshes. Built-in locators reflect how a user interacts with the application—for instance, targeting a button by its accessible name. Because these locators prioritize accessibility and user intent, they are much less likely to break when the code structure changes, leading to significantly more robust and stable test suites.

How can you utilize Playwright's tagging and project configurations to organize and reuse tests effectively?

Playwright projects and test tagging allow you to partition your test suite based on environmental needs or functional areas. You can define projects in 'playwright.config.ts' to target different base URLs or browsers, while using tags like '@smoke' or '@regression' allows you to selectively run subsets of tests. This modularity means you don't have to rewrite tests for different environments; you simply configure the environment-specific variables at the project level, ensuring your core test code remains clean, environment-agnostic, and highly reusable across various deployment pipelines.

Explain the architectural considerations for creating a scalable Playwright utility library for complex UI components.

When creating a utility library for complex components like data grids or multi-step forms, you should implement the 'Component Object Model' pattern. Treat each complex UI element as a standalone class that accepts a 'Locator' or 'Page' object in its constructor. This allows you to encapsulate specific interaction logic—such as sorting a table or interacting with a date picker—into reusable modules. By keeping these components self-contained, you prevent test code bloat and allow developers to instantiate these complex objects in any test file, which promotes high code reuse and makes the entire suite easier to refactor as the UI complexity grows.

All Playwright interview questions →

Check yourself

1. When refactoring a test suite to use the Page Object Model, what is the primary goal?

  • A.To reduce the number of assertions in each test
  • B.To centralize element locators and actions to improve maintainability
  • C.To increase the speed of test execution by removing navigation steps
  • D.To bypass the browser's rendering engine for faster processing
Show answer

B. To centralize element locators and actions to improve maintainability
The POM improves maintainability by decoupling page structure from test logic. Option 0 is irrelevant to structure; Option 2 is incorrect as navigation is often necessary; Option 3 is technically impossible as Playwright must interact with the browser.

2. Why is it recommended to use 'data-testid' over CSS classes for selecting elements?

  • A.It executes faster than CSS class lookups
  • B.It is the only way to locate elements in shadow DOMs
  • C.It signals that the attribute is specifically for testing, making it resilient to CSS/JS refactors
  • D.It forces the developer to write more descriptive CSS
Show answer

C. It signals that the attribute is specifically for testing, making it resilient to CSS/JS refactors
Using dedicated testing attributes separates infrastructure from presentation, which is the cornerstone of robust tests. Options 0 and 1 are factually incorrect; Option 3 is a side effect, not a primary benefit.

3. What is the correct way to handle a dynamic element that takes time to appear in the DOM?

  • A.Use page.waitForTimeout(5000)
  • B.Use web-first assertions like expect(locator).toBeVisible()
  • C.Increase the default navigation timeout globally
  • D.Add an 'await' statement before every selector
Show answer

B. Use web-first assertions like expect(locator).toBeVisible()
Web-first assertions automatically poll for the element until it meets the criteria. Option 0 is an anti-pattern causing flakiness; Option 2 doesn't solve waiting; Option 3 is syntactically unnecessary for selectors.

4. If multiple tests require the same logged-in state, what is the most efficient approach?

  • A.Copy-paste the login logic into every test file
  • B.Use globalSetup to perform the login once
  • C.Use a custom fixture to inject a stored authentication state
  • D.Use a loop to repeat the login function
Show answer

C. Use a custom fixture to inject a stored authentication state
Custom fixtures allow for clean, scalable state injection without global side effects. Option 0 violates DRY; Option 1 is less flexible than fixtures; Option 3 is not a standard approach for state management.

5. Which of the following describes a 'flaky' test?

  • A.A test that fails intentionally when a bug is found
  • B.A test that fails to compile due to syntax errors
  • C.A test that exhibits inconsistent behavior, passing and failing without code changes
  • D.A test that takes longer than 10 seconds to execute
Show answer

C. A test that exhibits inconsistent behavior, passing and failing without code changes
Flakiness is defined by non-deterministic results, which erode trust in the test suite. Options 0, 1, and 3 describe expected failures, syntax issues, or performance constraints, not flakiness.

Take the full Playwright quiz →

← PreviousPerformance testing with PlaywrightNext →Page Object Model (POM) design pattern

Playwright

37 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app