Test Design & Page Objects

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

An E2E suite fails slowly: not on the day it is written, but a year later, when a harmless markup change breaks a hundred tests or a shared test order makes one fail on Tuesdays. Most of that cost is decided by design choices made in the first week. This page collects those choices and shows them in Playwright 1.64, Cypress 16.2 and Selenium 4.51 (Java with JUnit 5 and Python with pytest), checked on 25 October 2026. Every example was run against the Bookshelf application. The three tools' own documentation agrees on far more than it disagrees; where they differ, the difference is called out.

What to cover with E2E

The test pyramid in the section home page puts few, slow tests at the top. Choose them by risk and journey, not by code coverage:

  • Critical user journeys that make or lose money or trust: sign in, search, add a review, check out.

  • Integration seams that no lower level test touches: the browser, the real HTTP stack, the cookie, the database.

  • Things only a browser can answer: layout at a breakpoint, a keyboard path, a download, a redirect after login.

Leave out what a cheaper test already proves. Whether a validation rule rejects nine characters is a unit or API test; the E2E test checks one representative failure to prove the message is shown. The same applies to every combination of sort order and filter: test the logic below the browser and keep one journey in the browser.

Test user-visible behaviour

Playwright’s best-practices page puts it first: automated tests should verify that the application works for end users and avoid depending on implementation details such as function names, whether something is an array or the CSS class of an element. A user sees a "Submit review" button, a heading and a message, so a test asserts on those. When a test needs to look inside, it is usually a sign that the behaviour is not observable and should be made so (an accessible name, a status message, a data-state attribute).

Isolation and independence

Each test must pass or fail on its own, in any order, in parallel and alone. All three tools say so:

  • Playwright: each test is completely isolated with its own local storage, session storage, data and cookies. The fixture page is a new page in a new browser context per test, created in milliseconds.

  • Cypress: clears cookies, local storage and sessions before each test (test isolation is on by default), and the best practices page asks for specs that are isolated and organised around features and flows.

  • Selenium: the guide lists "test independency", "avoid sharing state" and "fresh browser per test" under encouraged behaviours and "test dependency" under discouraged ones.

State outside the browser is the usual leak. Bookshelf keeps its data in memory, and its /api/test/reset endpoint restores it and clears sessions. During the preparation of this page, the Selenium smoke test that searches for "quiet" failed with 0 books found after another test had seeded a single book: the smoke test did not reset the data and ran after a test that left different state behind. It passed again once the data was reset. The fix is not to order tests carefully; it is to let every test arrange its own state.

Generate state through the API, not the UI

Clicking through sign-in and four forms to get to the page under test makes every test slow and every test dependent on the sign-in page. Arrange by calling the application’s API or a test-only endpoint, then use the browser only for what the test is about. Selenium’s encouraged list calls this "generating application state", and Cypress’s best practices say to "programmatically log into your application, and take control of your application’s state".

// Playwright: seed one book without reviews through the request fixture
test('a seeded book with no reviews shows the empty state', async ({ request, page }) => {
  await request.post('/api/test/seed', {
    data: { books: [{ title: 'Only Book', author: 'Test Author' }], reviews: [] },
  });
  await page.goto('/books/1');
  await expect(page.getByTestId('no-reviews')).toHaveText('No reviews yet.');
});
// Cypress: the same arrangement with cy.request
it('a seeded book with no reviews shows the empty state', () => {
  cy.request('POST', '/api/test/seed', {
    books: [{ title: 'Only Book', author: 'Test Author' }],
    reviews: [],
  });
  cy.visit('/books/1');
  cy.get('[data-testid=no-reviews]').should('have.text', 'No reviews yet.');
});
// Selenium Java (JUnit 5): reset through plain HTTP before each test, new browser per test
@BeforeEach
void start() throws Exception {
  HttpClient.newHttpClient().send(
      HttpRequest.newBuilder(URI.create(BASE_URL + "/api/test/reset"))
          .POST(HttpRequest.BodyPublishers.noBody()).build(),
      HttpResponse.BodyHandlers.discarding());
  ChromeOptions options = new ChromeOptions();
  options.addArguments("--headless=new");
  driver = new ChromeDriver(options);
}

@AfterEach
void stop() {
  driver.quit();
}
# Selenium Python (pytest): an autouse fixture resets data, another provides a fresh browser
@pytest.fixture(autouse=True)
def reset_data():
    urllib.request.urlopen(urllib.request.Request(f"{BASE_URL}/api/test/reset", method="POST"))


@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    d = webdriver.Chrome(options=options)
    yield d
    d.quit()

The Playwright, Cypress and both Selenium examples ran against Bookshelf and passed (Playwright in Chromium and WebKit). Starting a new browser per test costs a second or two with Selenium and almost nothing with Playwright’s contexts; Selenium’s guide still recommends it because shared browser state is the commonest source of order-dependent failures. Where speed matters, reuse the browser process and give each test a new context, as Playwright does, or a fresh profile.

Sign in once, not in every test

Signing in through the form in every test is the slowest part of many suites. Each tool has a way to do it once and reuse the result:

  • Playwright saves browser state (cookies and storage) to a file in a setup project and loads it per test; see Authentication & Isolation.

  • Cypress caches a session with cy.session(); see Sessions, Authentication & Cross-Origin.

  • Selenium has no built-in cache. Sign in through the API and add the session cookie to the driver, or accept the slower form login for a handful of tests.

A cached session can become stale when the server forgets it. Bookshelf’s reset clears every session, so the cached Cypress session was invalid in the second test of a spec and the test failed to find the form. The fix, shown in the Cypress custom command below, is the validate option of cy.session(), which runs after the session is restored and recreates it when it no longer holds.

No sleeps

A fixed sleep is either too short, which makes the test flaky, or too long, which makes every run slow. Instead:

  • Playwright: rely on auto-waiting actions and web-first assertions, which retry until the condition holds or the timeout ends. await expect(locator).toHaveText(...) waits; expect(await locator.textContent()) does not.

  • Cypress: queries and assertions retry for you; cy.wait(1000) for time is an anti-pattern, while cy.wait('@alias') waits for a network request.

  • Selenium: use explicit waits with ExpectedConditions (WebDriverWait), not Thread.sleep() or a long implicit wait. See Waits & Expected Conditions.

The optimistic review in Bookshelf shows the point: the new row appears at once with data-state="pending" and becomes saved after the request. A test that asserts on the saved state with a retrying assertion needs no sleep and works with any server delay.

Locator strategy

Choose the locator that a user or assistive technology would use, then fall back:

  1. Role and accessible name: getByRole('button', { name: 'Submit review' }) in Playwright, cy.getByRole() in Cypress 16.2. Selenium has no role locator, so use By.id, By.name or a CSS selector on a label or attribute. A role locator also fails when the accessible name is broken, which is a free accessibility check.

  2. Label, placeholder, alt text and visible text for form fields and content.

  3. A dedicated test id, such as data-testid, for elements with no stable role or text. Cypress’s best practices recommend data-* attributes to isolate selectors from CSS and JavaScript changes; Playwright recommends test ids as an explicit contract. testIdAttribute in the Playwright configuration lets a project use data-cy or its own name.

  4. CSS or XPath last, because they are tied to the markup. Playwright’s guide marks button.buttonIcon.episode-actions-later as the bad example; Selenium’s locator guidance prefers stable ids and attributes over long XPath chains.

Scope locators by chaining and filtering instead of writing longer selectors: Playwright page.getByTestId('review').filter({ hasText: 'lovely' }), Cypress cy.contains('[data-testid=review]', text). Write the locator once, in a page object, so a markup change is a one-line fix.

Page objects

A page object models a page, or a significant part of one, as a class: its constructor captures the locators in one place and its methods are the things a user can do or observe. Tests read like the user’s story, and a change in the markup touches one file. Selenium’s documentation lists page object models first among its encouraged behaviours; Playwright publishes a page-object guide; Cypress is more guarded (below).

Rules that keep page objects useful:

  • Methods describe intent (addReview, signIn), not clicks.

  • A page object exposes locators or small queries; the assertions belong in the test, or in clearly named expect... helpers.

  • Do not put a sleep or the test’s data inside a page object.

  • Keep them small. When a page has a repeated or complex widget, extract a page component object (a header, a pagination bar, a review list) used by several page objects.

Playwright: page objects with fixtures

Playwright’s test.extend turns page objects into fixtures: a test asks for bookDetailsPage by name and receives a ready instance, and set-up and tear-down live next to the fixture. This example puts a data reset in an automatic beforeEach of the extended test, keeping the specs focused.

// tests/pages/book-details-page.ts
import { expect, type Locator, type Page } from '@playwright/test';

export class BookDetailsPage {
  readonly title: Locator;
  readonly reviews: Locator;
  readonly rating: Locator;
  readonly reviewText: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(private readonly page: Page) {
    this.title = page.getByTestId('book-title');
    this.reviews = page.getByTestId('review');
    this.rating = page.getByLabel('Rating');
    this.reviewText = page.getByLabel('Your review');
    this.submit = page.getByRole('button', { name: 'Submit review' });
    this.error = page.getByTestId('review-error');
  }

  async goto(id: number) {
    await this.page.goto(`/books/${id}`);
  }

  async addReview(rating: number, text: string) {
    await this.rating.selectOption(String(rating));
    await this.reviewText.fill(text);
    await this.submit.click();
  }

  async expectSavedReview(text: string) {
    await expect(this.reviews.filter({ hasText: text })).toHaveAttribute('data-state', 'saved');
  }
}
// tests/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { BookDetailsPage } from './pages/book-details-page';
import { SignInPage } from './pages/sign-in-page';

type Fixtures = {
  signInPage: SignInPage;
  bookDetailsPage: BookDetailsPage;
};

export const test = base.extend<Fixtures>({
  signInPage: async ({ page }, use) => {
    await use(new SignInPage(page));
  },
  bookDetailsPage: async ({ page }, use) => {
    await use(new BookDetailsPage(page));
  },
});

// Start every test from the same seeded data.
test.beforeEach(async ({ request }) => {
  await request.post('/api/test/reset');
});

export { expect };
// tests/review-page-object.spec.ts
import { test, expect } from './fixtures';

const readerPassword = process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only';

test('reader adds a review through page objects', async ({ signInPage, bookDetailsPage }) => {
  await signInPage.signIn('reader', readerPassword);
  await bookDetailsPage.goto(3);
  await bookDetailsPage.addReview(4, 'A lovely, detailed field guide.');
  await bookDetailsPage.expectSavedReview('lovely');
});

test('a too-short review shows a validation message', async ({ signInPage, bookDetailsPage }) => {
  await signInPage.signIn('reader', readerPassword);
  await bookDetailsPage.goto(3);
  await bookDetailsPage.addReview(4, 'Short');
  await expect(bookDetailsPage.error).toHaveText('Write at least 10 characters');
});

This per-test reset has a side effect to plan for: Bookshelf’s reset clears every server-side session, so it also signs out any reader or admin that another test, another worker or a cached storageState file has already signed in. It is harmless here because each test signs in through its own page object. If you combine the reset with authentication that is reused across tests, either call /api/test/seed instead (it restores the data without touching sessions), run a single worker, or sign in again inside the fixture. The same applies to the Cypress beforeEach hook that posts to /api/test/reset.

This example (with the SignInPage class that fills the sign-in form) ran against Bookshelf and passed in Chromium and WebKit. Firefox was not run locally for this page, because the Firefox build used by Playwright does not launch on the macOS version of this host; the same engine runs in the Linux container image of the section.

Cypress: custom commands and page objects

Cypress’s best-practices page lists "sharing page objects" among its anti-patterns, and the community debate behind it is useful. Cypress commands are queued, not returned, so a page object cannot return a value the way a Selenium one does; a class of methods that call cy commands and return this for chaining works but gains less than in other tools. The Cypress answer is custom commands for cross-cutting steps (signing in, API set-up) and plain functions or small classes for locators. The sample uses both, and shows the stale session problem:

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      /** Sign in through the API once per user and reuse the cached session. */
      signIn(username: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('signIn', (username: string) => {
  cy.env(['READER_PASSWORD']).then(({ READER_PASSWORD }) => {
    cy.session(
      username,
      () => {
        cy.request('POST', '/api/session', { username, password: READER_PASSWORD });
      },
      {
        // /api/test/reset clears the server-side sessions, so re-check the cached one.
        validate: () => {
          cy.request('/api/session').its('body.authenticated').should('eq', true);
        },
      },
    );
  });
});

export {};
// cypress/support/pages/book-details-page.ts
export class BookDetailsPage {
  visit(id: number) {
    cy.visit(`/books/${id}`);
    return this;
  }

  addReview(rating: number, text: string) {
    cy.get('#rating').select(String(rating));
    cy.get('#text').type(text);
    cy.contains('button', 'Submit review').click();
    return this;
  }

  review(text: string) {
    return cy.contains('[data-testid=review]', text);
  }

  get error() {
    return cy.get('[data-testid=review-error]');
  }
}
// cypress/e2e/review-page-object.cy.ts
import { BookDetailsPage } from '../support/pages/book-details-page';

describe('Reviews with a page object and a custom command', () => {
  const book = new BookDetailsPage();

  beforeEach(() => {
    cy.request('POST', '/api/test/reset');
    cy.signIn('reader');
  });

  it('reader adds a review', () => {
    book.visit(3).addReview(4, 'A lovely, detailed field guide.');
    book.review('lovely').should('have.attr', 'data-state', 'saved');
  });

  it('a too-short review shows a validation message', () => {
    book.visit(3).addReview(4, 'Short');
    book.error.should('have.text', 'Write at least 10 characters');
  });
});

The three files were run with cypress run and passed (3 tests including the seeding test). Without validate, the second test failed with "Expected to find element: #rating, but never found it": the cached cookie no longer matched a session on the server.

Selenium: page objects in Java and Python

Selenium’s documentation recommends page objects and, since it has no waiting built in, puts the waits inside them so that a page method returns when the page is ready. The Java page object below also shows Select for the rating list and a custom wait condition that returns the review row once it reaches the saved state:

// src/test/java/demo/pages/BookDetailsPage.java
public class BookDetailsPage {
  private final WebDriver driver;
  private final String baseUrl;
  private final WebDriverWait wait;

  private final By rating = By.id("rating");
  private final By reviewText = By.id("text");
  private final By submit = By.cssSelector("[data-testid=review-submit]");
  private final By reviews = By.cssSelector("[data-testid=review]");
  private final By error = By.cssSelector("[data-testid=review-error]");

  public BookDetailsPage(WebDriver driver, String baseUrl) {
    this.driver = driver;
    this.baseUrl = baseUrl;
    this.wait = new WebDriverWait(driver, Duration.ofSeconds(5));
  }

  public BookDetailsPage open(int id) {
    driver.get(baseUrl + "/books/" + id);
    wait.until(ExpectedConditions.visibilityOfElementLocated(submit));
    return this;
  }

  public BookDetailsPage addReview(int stars, String text) {
    new Select(driver.findElement(rating)).selectByValue(String.valueOf(stars));
    driver.findElement(reviewText).sendKeys(text);
    driver.findElement(submit).click();
    return this;
  }

  public WebElement savedReviewContaining(String text) {
    return wait.until(d -> d.findElements(reviews).stream()
        .filter(li -> li.getText().contains(text) && "saved".equals(li.getDomAttribute("data-state")))
        .findFirst()
        .orElse(null));
  }

  public String errorMessage() {
    return wait.until(ExpectedConditions.visibilityOfElementLocated(error)).getText();
  }
}
// src/test/java/demo/ReviewPageObjectTest.java (excerpt)
@Test
void readerAddsAReview() {
  new SignInPage(driver, BASE_URL).signIn("reader", READER_PASSWORD);
  BookDetailsPage book = new BookDetailsPage(driver, BASE_URL).open(3);
  book.addReview(4, "A lovely, detailed field guide.");
  assertNotNull(book.savedReviewContaining("lovely"));
}

@Test
void tooShortReviewShowsValidationMessage() {
  new SignInPage(driver, BASE_URL).signIn("reader", READER_PASSWORD);
  BookDetailsPage book = new BookDetailsPage(driver, BASE_URL).open(3);
  book.addReview(4, "Short");
  assertEquals("Write at least 10 characters", book.errorMessage());
}
# tests/pages/book_details_page.py
class BookDetailsPage:
    RATING = (By.ID, "rating")
    REVIEW_TEXT = (By.ID, "text")
    SUBMIT = (By.CSS_SELECTOR, "[data-testid=review-submit]")
    REVIEWS = (By.CSS_SELECTOR, "[data-testid=review]")
    ERROR = (By.CSS_SELECTOR, "[data-testid=review-error]")

    def __init__(self, driver, base_url):
        self.driver = driver
        self.base_url = base_url
        self.wait = WebDriverWait(driver, 5)

    def open(self, book_id):
        self.driver.get(f"{self.base_url}/books/{book_id}")
        self.wait.until(EC.visibility_of_element_located(self.SUBMIT))
        return self

    def add_review(self, stars, text):
        Select(self.driver.find_element(*self.RATING)).select_by_value(str(stars))
        self.driver.find_element(*self.REVIEW_TEXT).send_keys(text)
        self.driver.find_element(*self.SUBMIT).click()
        return self

    def saved_review_containing(self, text):
        def saved(driver):
            for li in driver.find_elements(*self.REVIEWS):
                if text in li.text and li.get_dom_attribute("data-state") == "saved":
                    return li
            return False

        return self.wait.until(saved)

    def error_message(self):
        return self.wait.until(EC.visibility_of_element_located(self.ERROR)).text
# tests/test_review_page_object.py
def test_reader_adds_a_review(driver, base_url):
    SignInPage(driver, base_url).sign_in("reader", READER_PASSWORD)
    book = BookDetailsPage(driver, base_url).open(3)
    book.add_review(4, "A lovely, detailed field guide.")
    assert book.saved_review_containing("lovely")


def test_too_short_review_shows_validation_message(driver, base_url):
    SignInPage(driver, base_url).sign_in("reader", READER_PASSWORD)
    book = BookDetailsPage(driver, base_url).open(3)
    book.add_review(4, "Short")
    assert book.error_message() == "Write at least 10 characters"

Both Selenium suites ran against Bookshelf with headless Chrome 155 and chromedriver 155 and passed (2 tests each). The page object imports are the ones used by the smoke projects: org.openqa.selenium.support.ui.Select, ExpectedConditions and WebDriverWait in Java; selenium.webdriver.support.ui.Select and WebDriverWait in Python.

Selenium design strategies

Selenium’s "Design patterns and development strategies" page names four approaches that apply to any tool:

  • Domain-driven design and DSLs. Express tests in the vocabulary of the app (readerReviews(book)), keeping Selenium calls out of test bodies. The "domain specific language" page in the encouraged list shows the idea.

  • Page objects. The abstraction above.

  • LoadableComponent. A base class (shipped as org.openqa.selenium.support.ui.LoadableComponent in the Java binding) that makes loading and "is the page ready" explicit: get() calls isLoaded(), calls load() if the check fails, and checks again, which gives a clear failure when a page does not load.

  • Bot-style tests. A command-based style: a "bot" class with actions (bot.signIn(...), bot.review(...)) that hide the page structure and let a test change several pages without a page object per page.

The first three combine well; the bot style trades discoverability for brevity. A fluent API, where each method returns the object and tests chain calls (new BookDetailsPage(...).open(3).addReview(4, "..."), as the examples above do), is on the encouraged list too.

The LoadableComponent version of the same page ran against Bookshelf:

public class LoadableBookDetailsPage extends LoadableComponent<LoadableBookDetailsPage> {
  private final WebDriver driver;
  private final String url;

  public LoadableBookDetailsPage(WebDriver driver, String baseUrl, int id) {
    this.driver = driver;
    this.url = baseUrl + "/books/" + id;
  }

  @Override
  protected void load() {
    driver.get(url);
    new WebDriverWait(driver, Duration.ofSeconds(5))
        .until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("[data-testid=book-title]")));
  }

  @Override
  protected void isLoaded() throws Error {
    assertTrue(driver.getCurrentUrl().equals(url), "Not on " + url);
  }

  public String title() {
    return driver.findElement(By.cssSelector("[data-testid=book-title]")).getText();
  }
}

// in a test
LoadableBookDetailsPage page = new LoadableBookDetailsPage(driver, "http://localhost:3000", 1).get();
assertEquals("The Quiet Compiler", page.title());

The DSL and bot-style snippets of the Selenium page were not reproduced or run here; they follow the same shape as the page objects above.

Encouraged and discouraged with Selenium

Selenium’s guide gives two lists. They describe a browser-automation stance that is useful beyond Selenium.

Encouraged Why it helps

Page object models, DSL, fluent API

Readability and one place to change when the UI changes.

Generating application state

Faster, less flaky tests than clicking through set-up.

Mock external services

Your suite does not fail because a third party is slow or down.

Improved reporting

Failures arrive with context (screenshots, logs).

Avoid sharing state, test independency, fresh browser per test

Tests can run in any order and in parallel.

Tips on working with locators

Stable ids, attributes and data attributes over layout-bound selectors.

Discouraged What to do instead

CAPTCHAs

Disable them in the test environment or use a test key; automating them defeats their purpose.

File downloads

Do not drive the browser’s download dialog; request the file with an HTTP client, or use a tool’s download API.

HTTP response codes

WebDriver does not expose status codes; check them at the API level or with BiDi network events.

Gmail, email and Facebook (third-party) logins

Avoid logging into services you do not own; use an API token or a test account in your own identity provider.

Test dependency

Make every test independent.

Performance testing

Use a load testing tool; see Choosing a Tool.

Link spidering

Use a crawler or a link checker; a browser is the wrong tool.

Two factor authentication

Use test accounts with 2FA disabled or a test identity provider, or bypass 2FA with a test-only token.

Playwright and Cypress agree with most of these and offer more built-in answers: Playwright has a download event and network response assertions, and Cypress’s trade-offs page says it is not the optimal tool for spidering links, performance testing and scripting third-party sites. The mocking tools are covered in Network Mocking, Clock & API Testing and Network, Stubs, Spies & Clocks.

Best practices of each tool

Tool Do Avoid

Playwright

Test user-visible behaviour; isolate tests; control the data; prefer getByRole and test ids; chain and filter locators; use web-first assertions; mock third-party responses with the Network API; use codegen to find locators.

Testing third-party sites; CSS class selectors; expect(await locator.isVisible()).toBe(true) instead of the retrying await expect(locator).toBeVisible(); fixed timeouts; one test depending on another.

Cypress

Select with data-* attributes; log in programmatically; control state with cy.request() and cy.task(); keep specs independent and organised by feature; use cy.env() for secrets and Cypress.expose() only for public values; use cy.session() to cache a login.

Selectors tied to ids, classes or tags; hard-coded secrets; cy.wait(time); testing external sites; starting from a UI login in every test; sharing page objects widely.

Selenium

Page objects; fluent API; explicit waits; independent tests; fresh browser per test; mock external services; generate state through APIs; reports with screenshots on failure.

Thread.sleep() and long implicit waits; mixing implicit and explicit waits; sharing one driver across tests; third-party logins; CAPTCHAs.

What changed recently

  • Cypress 16 removed Cypress.env(): read sensitive values with cy.env() and public configuration with Cypress.expose(), the pattern used in the command above. Cypress 16.2 added cy.getByRole().

  • Playwright 1.63 added test locks for sharing a scarce resource between tests, and 1.64 added page.getByRef() for elements named in an AI-generated accessibility snapshot.

  • Selenium 4.51 removed Guava from its client bindings, and ExpectedCondition no longer implements Guava’s Function, so custom wait conditions that relied on that type need a check after upgrading. Selenium’s documentation now includes a page on using AI agents.