Halmurat T.

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 + wrapper
Button("Submit").click()
// Playwright
getByRole("button", { name: "Submit" }).click()

✏️ Fill an Input

// Selenium + wrapper
Input("Email").type("test@example.com")
// Playwright
getByLabel("Email").fill("test@example.com")

📋 Select a Dropdown

// Selenium + wrapper
Dropdown("Country").select("US")
// Playwright
getByLabel("Country").selectOption("US")

☑️ Check a Checkbox

// Selenium + wrapper
Checkbox("Agree").check()
// Playwright
getByRole("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.