Page Object Model
12 challenges covering POM design, TypeScript patterns, fixture injection, BasePage, and when to apply the pattern.
Challenge 1BeginnerFundamentals
Solved
What is the primary purpose of the Page Object Model?
A team writes the same locators in 20 different test files. When the UI changes, all 20 files break. What problem does POM solve?
Answer: B — centralize locators and interactions
POM creates a single source of truth for each page's selectors and interactions. When a button's text changes, you update the locator in ONE page class — all tests that use it continue working. Without POM, changing a selector requires grep-and-replace across dozens of test files. This maintainability benefit is the core reason to adopt POM, especially in large test suites with frequent UI changes.
POM creates a single source of truth for each page's selectors and interactions. When a button's text changes, you update the locator in ONE page class — all tests that use it continue working. Without POM, changing a selector requires grep-and-replace across dozens of test files. This maintainability benefit is the core reason to adopt POM, especially in large test suites with frequent UI changes.
Challenge 2BeginnerTypeScript
Solved
What is the correct POM constructor pattern in TypeScript?
You are creating a
LoginPage class. Which constructor correctly receives and stores the Playwright Page object?Answer: B — TypeScript parameter property shorthand
constructor(private page: Page) {} is the idiomatic TypeScript pattern. The private keyword before the parameter name automatically declares this.page as a class field AND assigns the argument — no body code needed. Import Page from @playwright/test. The full class: import { Page } from '@playwright/test'; export class LoginPage { constructor(private page: Page) {} }.Challenge 3BeginnerDesign
Solved
Where should locators be defined in a POM class?
Tests should NEVER contain locators directly. In a POM class, what is the recommended pattern for storing locators?
export class LoginPage {
constructor(private page: Page) {}
// Locators go here as ???
??? emailInput = this.page.getByLabel('Email');
??? submitBtn = this.page.getByRole('button', { name: 'Login' });
}Answer: B — private readonly
private readonly is the correct modifier: private prevents tests from using locators directly (they must use methods), and readonly ensures the locator reference is never reassigned after initialization. Example: private readonly emailInput = this.page.getByLabel('Email');. Tests call methods like loginPage.fillEmail('user@test.com') — they never know which locator is used internally.Challenge 4BeginnerNavigation
Solved
What should a goto() method do?
Your
LoginPage has a goto() method. Beyond navigating, what should it also do?async goto(): Promise<void> {
await this.page.goto('/login');
// Should also do ???
}Answer: B — assert the page loaded correctly
The action + verification principle: every action should be followed by a check that it succeeded. A robust
The action + verification principle: every action should be followed by a check that it succeeded. A robust
goto(): async goto() { await this.page.goto('/login'); await expect(this.page).toHaveURL('/login'); await expect(this.loginHeading).toBeVisible(); }. This way, if navigation fails silently (redirects, auth errors), the test fails immediately at goto() with a clear message rather than failing mysteriously later.Challenge 5IntermediateFixtures
Solved
How do you inject a page object into tests using fixtures?
What is the recommended way to make
loginPage available in tests without instantiating it in every test file?// fixtures.ts
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
}
});Answer: B — test.extend() fixture
Custom fixtures via
Custom fixtures via
test.extend() are the idiomatic Playwright way to inject page objects. Tests simply declare { loginPage } and get a fully initialized instance — no new call needed in the test. Tests import from the fixtures file instead of @playwright/test: import { test } from './fixtures'. This is composable (stack multiple fixtures), automatically handles teardown, and avoids repetitive beforeEach setup.Challenge 6IntermediateInheritance
Solved
What is the purpose of a BasePage class?
Multiple page classes need methods like
waitForPageLoad(), getTitle(), and scrollTo(). Where should these live?Answer: B — BasePage with protected page: Page
A
A
BasePage class holds shared methods: class BasePage { constructor(protected page: Page) {} async waitForLoad() { await this.page.waitForLoadState('networkidle'); } }. Specific pages extend it: class LoginPage extends BasePage { constructor(page: Page) { super(page); } }. Use protected (not private) so subclasses can access this.page. Only add methods to BasePage that are genuinely shared — avoid making it a dumping ground.Challenge 7BeginnerNaming
Solved
What is the recommended naming convention for POM methods?
You are naming methods on a
CheckoutPage. Which set of method names follows POM best practices?Answer: B — verb-first camelCase action names
POM methods should describe actions, not UI elements. Use action verbs:
POM methods should describe actions, not UI elements. Use action verbs:
fill, click, select, check, get. Examples: fillEmail(email: string), clickSubmit(), selectCountry(country: string), getErrorMessage(): Promise<string>. This makes tests read like prose: await loginPage.fillEmail('user@test.com'); await loginPage.clickSubmit(); — the intent is instantly clear.Challenge 8IntermediateComponents
Solved
What are Component Objects in POM?
Your app has a
NavBar that appears on 15 different pages, and a Modal used across 8 pages. How should you model these?Answer: B — separate component classes, composed into pages
Component Objects model reusable UI pieces.
Component Objects model reusable UI pieces.
class NavBar { constructor(private page: Page) {} async clickLogo() {...} }. Then compose: class DashboardPage { nav = new NavBar(this.page); modal = new Modal(this.page); }. Tests use: await dashboardPage.nav.clickLogo(). This avoids BasePage bloat — BasePage would have ALL nav+modal methods mixed with page-specific methods. Composition is cleaner for shared UI components.Challenge 9IntermediateTypeScript
Solved
Why use readonly for locator fields in a page class?
What is the benefit of declaring locators as
private readonly class fields in a POM class?export class LoginPage {
constructor(private page: Page) {}
private readonly emailInput = this.page.getByLabel('Email');
private readonly password = this.page.getByLabel('Password');
}Answer: B — prevents reassignment; locators are still lazy
Playwright locators are lazy — they don't query the DOM until you interact with them (click, fill, etc.).
Playwright locators are lazy — they don't query the DOM until you interact with them (click, fill, etc.).
readonly on the field only prevents someone from accidentally doing this.emailInput = somethingElse later in the class. The locator definition line is evaluated once at construction time but no DOM query runs. This TypeScript discipline catches bugs like this.emailInput = this.page.getByLabel('Wrong') at compile time.Challenge 10IntermediatePragmatics
Solved
When should you NOT use the Page Object Model?
A QA engineer has 3 end-to-end tests that each test a unique one-off workflow. These pages are not tested anywhere else. Should they use POM?
Answer: B — POM is overkill for one-off tests
POM is an investment that pays off when the same page is tested multiple times across many test files. For truly one-off tests (unique workflow, used once), creating a full page class adds complexity with no benefit. The pragmatic approach: write the test directly, and only extract a page object when you find yourself repeating the same interactions in more than 2-3 places. Don't over-engineer for hypothetical future reuse.
POM is an investment that pays off when the same page is tested multiple times across many test files. For truly one-off tests (unique workflow, used once), creating a full page class adds complexity with no benefit. The pragmatic approach: write the test directly, and only extract a page object when you find yourself repeating the same interactions in more than 2-3 places. Don't over-engineer for hypothetical future reuse.
Challenge 11IntermediateSetup
Solved
Fixtures vs beforeEach for page object setup — what's the difference?
Both
beforeEach and custom fixtures can instantiate a page object before each test. Which approach is preferred for page objects and why?Answer: B — fixtures are preferred
Fixtures win because: (1) Composable — one fixture can depend on another; (2) File-scoped reuse — define once in fixtures.ts, use in any test file by importing; (3) No describe block needed — beforeEach requires being inside a describe or test file; (4) Automatic teardown — code after
Fixtures win because: (1) Composable — one fixture can depend on another; (2) File-scoped reuse — define once in fixtures.ts, use in any test file by importing; (3) No describe block needed — beforeEach requires being inside a describe or test file; (4) Automatic teardown — code after
await use() runs as cleanup. beforeEach in a test file only helps that file. Fixtures scale; beforeEach doesn't.Challenge 12BeginnerSetup
Solved
When using POM with custom fixtures, where should tests import 'test' from?
Your project has
fixtures.ts that extends test with page object fixtures. What import should test files use?Answer: B — import from the fixtures file
When you create custom fixtures with
When you create custom fixtures with
base.extend(), the result is a new test object that includes your page object fixtures. Tests MUST import this extended test — importing from @playwright/test directly will not have the custom fixtures defined. Also export expect from your fixtures file: export { expect } from '@playwright/test' so tests only need one import: import { test, expect } from '../fixtures'.