Every page in your application shares some common behaviors. Every page has a URL. Every page has a title. Every page might have a header and footer. Instead of duplicating these in every page object, create a BasePage that all others extend.
import { type Locator, type Page, expect } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
readonly heading: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { level: 1 });
}
// Navigate to a specific path
async goto(path: string) {
await this.page.goto(path);
}
// Wait for the page to fully load
async waitForLoad() {
await this.page.waitForLoadState('domcontentloaded');
}
// Get the page title from the browser tab
async getTitle(): Promise<string> {
return this.page.title();
}
// Get the current URL
getCurrentUrl(): string {
return this.page.url();
}
// Verify the page heading matches expected text
async expectHeading(text: string | RegExp) {
await expect(this.heading).toHaveText(text);
}
// Take a screenshot for debugging
async takeScreenshot(name: string) {
await this.page.screenshot({ path: `screenshots/${name}.png` });
}
// Wait for a specific URL pattern
async waitForUrl(pattern: string | RegExp) {
await this.page.waitForURL(pattern);
}
}import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './base.page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page); // Call BasePage constructor
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByTestId('error-message');
}
// Override goto with the specific login URL
async goto() {
await super.goto('/banking/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}| BasePage (shared) | Specific Page Object |
|---|---|
| goto(path) | goto() with specific URL |
| waitForLoad() | expectLoaded() with page-specific checks |
| getTitle() | getBalance(), getProductCount() |
| takeScreenshot() | login(), addToCart(), submitForm() |
| waitForUrl() | Page-specific locators and actions |
| expectHeading() | Page-specific error handling |
Do not put page-specific locators in BasePage. If only DashboardPage has a balance display, that locator belongs in DashboardPage, not BasePage. BasePage should only contain things that EVERY page shares. Keep it thin.
Notice the abstract keyword on BasePage. This means you cannot create a new BasePage(page) directly. You must extend it. This is intentional. BasePage is a template, not a concrete page.
Some teams skip BasePage and put common methods in a utility file instead. Both approaches work. Use BasePage if your pages share common locators (like a header). Use utility functions if pages only share behaviors (like screenshot or wait methods).
Q: What is BasePage and why do you use it?
A: BasePage is an abstract class that contains methods shared by all pages -- navigation, waiting for load, getting the title, taking screenshots. Every page object extends BasePage and inherits these common methods. This avoids duplicating goto() and waitForLoad() in every page class. I keep BasePage thin -- only truly universal methods go there. Page-specific locators and actions stay in the concrete page classes. It follows the DRY principle and makes the codebase consistent.
Key Point: BasePage holds common methods like goto(), waitForLoad(), getTitle(). Mark it abstract. Keep it thin. Every page object extends it with super(page).