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
pageis 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()
Source: Playwright: Best practices, Playwright: API testing, Cypress: Best practices, Selenium: Generating application state, Selenium: Fresh browser per test
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, whilecy.wait('@alias')waits for a network request. -
Selenium: use explicit waits with
ExpectedConditions(WebDriverWait), notThread.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:
-
Role and accessible name:
getByRole('button', { name: 'Submit review' })in Playwright,cy.getByRole()in Cypress 16.2. Selenium has no role locator, so useBy.id,By.nameor 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. -
Label, placeholder, alt text and visible text for form fields and content.
-
A dedicated test id, such as
data-testid, for elements with no stable role or text. Cypress’s best practices recommenddata-*attributes to isolate selectors from CSS and JavaScript changes; Playwright recommends test ids as an explicit contract.testIdAttributein the Playwright configuration lets a project usedata-cyor its own name. -
CSS or XPath last, because they are tied to the markup. Playwright’s guide marks
button.buttonIcon.episode-actions-lateras 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
sleepor 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"
Source: Selenium: Page object models
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.LoadableComponentin the Java binding) that makes loading and "is the page ready" explicit:get()callsisLoaded(), callsload()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 |
Testing third-party sites; CSS class selectors; |
Cypress |
Select with |
Selectors tied to ids, classes or tags; hard-coded secrets; |
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. |
|
What changed recently
-
Cypress 16 removed
Cypress.env(): read sensitive values withcy.env()and public configuration withCypress.expose(), the pattern used in the command above. Cypress 16.2 addedcy.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
ExpectedConditionno longer implements Guava’sFunction, 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.