From Selenium Wrapper Classes to Playwright's Built-in Locators
🔧 The Wrapper Class Era
With Selenium, we used to create wrapper classes for every UI element. Buttons, inputs, dropdowns, checkboxes — each got its own class with custom methods, error handling, and retry logic.
Button button = new Button("Submit");button.click();
Input email = new Input("Email");email.type("test@example.com");Every element got its own class. We called it “clean architecture.” 😅
These wrappers weren’t just vanity abstractions. They handled real problems: flaky waits, stale element references, and a WebDriver API that didn’t speak the language of the UI. So we built layers on top of layers to make our tests readable.
But we were solving problems that the framework should have solved for us.
🎭 Enter Playwright
With Playwright, the same actions look like this:
page.getByRole("button", { name: "Submit" }).click();page.getByLabel("Email").fill("test@example.com");🚫 No wrapper classes. No inheritance chains. No Button, no Input, no BaseComponent.
🎯 Playwright’s locator API already speaks the language of the UI — roles, labels, text, placeholders. 💡 The abstraction we spent weeks building? Playwright ships it out of the box.
Side-by-Side Comparison
Here’s what the migration looks like in practice:
🖱️ Click a Button
// Selenium + wrapperButton("Submit").click()// PlaywrightgetByRole("button", { name: "Submit" }).click()✏️ Fill an Input
// Selenium + wrapperInput("Email").type("test@example.com")// PlaywrightgetByLabel("Email").fill("test@example.com")📋 Select a Dropdown
// Selenium + wrapperDropdown("Country").select("US")// PlaywrightgetByLabel("Country").selectOption("US")☑️ Check a Checkbox
// Selenium + wrapperCheckbox("Agree").check()// PlaywrightgetByRole("checkbox", { name: "Agree" }).check()Every one of these Selenium wrappers existed because the raw Selenium API was too low-level. driver.findElement(By.id("...")).click() tells you nothing about what you’re clicking. Our wrappers added semantics. Playwright bakes those semantics in from the start.
⚠️ When Wrappers Still Make Sense
Wrapper classes aren’t dead. Complex components like a DataTable — with thead, tbody, rows, and columns — still deserve their own class. Playwright won’t magically parse a table for you.
class DataTable { constructor(private page: Page, private selector: string) {}
async getRowCount(): Promise<number> { return this.page.locator(`${this.selector} tbody tr`).count(); }
async getCellValue(row: number, col: number): Promise<string> { return this.page .locator(`${this.selector} tbody tr`) .nth(row) .locator("td") .nth(col) .innerText(); }
async getHeaderNames(): Promise<string[]> { return this.page.locator(`${this.selector} thead th`).allInnerTexts(); }}The rule of thumb: if you’re wrapping a single native element (button, input, checkbox), Playwright already has you covered. If you’re wrapping a composite component with its own internal structure and query patterns, a wrapper class still earns its place.
✨ What We Learned from the Migration
When we moved to Playwright, we built our new framework from scratch. 90% of the wrapper classes we used to write? Gone. The remaining 10%? Still worth it for complex components.
The biggest takeaway wasn’t about Playwright specifically — it was about recognizing when your “architecture” is compensating for a framework’s limitations versus adding genuine value. Half the patterns we treated as best practices were just workarounds.
Pick the right tool, and the architecture simplifies itself.