In the world of test automation, especially with a tool as powerful as Playwright, we often face a classic architectural challenge - the BasePage
object. It starts small, but soon it grows into a monolithic "God object" containing methods for every shared component across the application (navigation, footers, modals).
This leads to deep and rigid inheritance chains. An ArticlePage
might inherit from NavPage
, which inherits from BasePage
. This is not only complex but also violates the single responsibility principle.
What if we could build our Page Objects with components just like we build modern web apps? This is where the Mixin Design Pattern comes in, favoring flexible composition over rigid inheritance.
What's the Problem with Inheritance?
Traditional inheritance (class A extends B
) is powerful, but it has limits. A class can only extend one other class.
So, what happens when your ArticlePage
needs navigation and table-handling logic, and your HomePage
needs navigation and table logic? You can't extend NavPage, TablePage
. This forces you to either cram everything into a single BasePage
or create convoluted inheritance chains.
The Mixin Solution: Composition
The Mixin Design Pattern lets you create reusable "feature packs". They are small classes focused on a single piece of functionality (like navigation or table handling). You can then "mix" these feature packs into any Page Object that needs them.
This approach allows you to:
Share functionality across unrelated classes.
Avoid deep inheritance chains, keeping your code flat and manageable.
Keep Page Objects clean and focused on their primary purpose.
In modern front-end development, which is heavily component-based, our testing frameworks should evolve too. By using the Mixin Design Pattern, we can build a more modular, reusable, and maintainable test suite.
๐ Implementing Mixins in Playwright with TypeScript
Let's walk through a practical example of adding shared navigation functionality to a HomePage
object without using inheritance. All examples are made for tha app that I am always using - Conduit (huge shoutouts to mr. Artem Bondar) and there is a complete Repository.
Step 1: The applyMixins
Helper Function
TypeScript doesn't have a native mixin
keyword. So, first, we need a small utility function that does the heavy lifting. This function copies the properties and methods from our "feature packs" to our target class.
Think of this as the engine of our pattern.
// helpers/mixin.ts
/**
* Mixin function to combine multiple base classes into one.
* This function copies all properties and methods from source classes to the target class.
*
* @param derivedCtor - The target class that will receive the mixed-in functionality.
* @param constructors - Array of source classes to mix in.
*/
export function applyMixins(derivedCtor: any, constructors: any[]): void {
constructors.forEach((baseCtor) => {
// Get all property names from the source class prototype
Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
// Skip constructor to avoid conflicts
if (name !== 'constructor') {
// Copy the property descriptor (including getters, setters, methods)
Object.defineProperty(
derivedCtor.prototype,
name,
Object.getOwnPropertyDescriptor(baseCtor.prototype, name) ||
Object.create(null)
);
}
});
});
}
Step 2: Create the "Feature Pack" - NavPage
Next, we create our mixin. In this case, it's a NavPage
class that encapsulates all locators and methods for interacting with the website's main navigation bar, a component present on almost every page.
This class is our reusable piece of code.
// pages/clientSite/baseClasses/navPage.ts
import { Page, Locator, expect } from '@playwright/test';
/**
* This is the page object for the Navigation functionality.
*/
export class NavPage {
constructor(protected page: Page) {}
// ... Locators for navBar, homePageLink, settingsButton, etc.
get signInNavigationLink(): Locator {
return this.page.getByRole('link', { name: 'Sign in' });
}
get emailInput(): Locator {
return this.page.getByRole('textbox', { name: 'Email' });
}
get passwordInput(): Locator {
return this.page.getByRole('textbox', { name: 'Password' });
}
get signInButton(): Locator {
return this.page.getByRole('button', { name: 'Sign in' });
}
/**
* Logs in the user.
*/
async logIn(email: string, password: string): Promise<void> {
await this.navigateToSignInPage();
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
await expect(
this.page.getByRole('navigation').getByText(process.env.USER_NAME!)
).toBeVisible();
}
// ... other methods like navigateToSignInPage(), logOut(), etc.
}
Step 3: Compose the HomePage
with the NavPage
Mixin
Now for the magic. We create our HomePage
object, which is responsible only for elements unique to the home page. Then, we use our applyMixins
helper to blend in the NavPage
functionality.
// pages/clientSite/homePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { applyMixins } from '../../helpers/mixin';
import { NavPage } from './baseClasses/navPage';
/**
* This is the page object for the Home Page.
*/
export class HomePage {
constructor(protected page: Page) {}
get homeBanner(): Locator {
return this.page.getByRole('heading', { name: 'conduit' });
}
get yourFeedBtn(): Locator {
return this.page.getByText('Your Feed');
}
/**
* Navigates to the home page as Guest.
*/
async navigateToHomePageGuest(): Promise<void> {
await this.page.goto(process.env.URL as string);
await expect(this.homeBanner).toBeVisible();
}
}
/**
* Interface declaration that tells TypeScript that HomePage
* also has all the methods and properties from NavPage.
* This enables IntelliSense and type checking.
*/
export interface HomePage extends NavPage {}
/**
* Apply the mixin at runtime.
* This actually copies all NavPage methods to HomePage's prototype.
*/
applyMixins(HomePage, [NavPage]);
Notice two key parts here:
export interface HomePage extends NavPage {}
: This is crucial for TypeScript's type-checking. It tells the compiler, "Trust me, an instance ofHomePage
will also have all the methods ofNavPage
", giving us that sweet, sweet IntelliSense autocompletion.applyMixins(HomePage, [NavPage])
: This is the runtime execution. It takes all the methods fromNavPage
and attaches them toHomePage
. If you want to attach methods from more than onebaseClass
, you can list them in the array.
Step 4: Use the Composed Page Object in a Test
Finally, let's see it in action. In our test file, we can instantiate HomePage
and call methods from NavPage
directly on it, like logIn()
.
// tests/auth.setup.ts
import { test as setup } from '../fixtures/pom/test-options';
setup('auth user', async ({ homePage, page }) => {
await setup.step('create logged in user session', async () => {
await homePage.navigateToHomePageGuest();
// Calling a method from NavPage on a HomePage instance!
await homePage.logIn(process.env.EMAIL!, process.env.PASSWORD!);
await page.context().storageState({ path: '.auth/userSession.json' });
});
});
It just works! Our HomePage
is clean and focused, yet it has all the power of the navigation functionality when needed.
A Note on Potential Downsides
While powerful, this pattern has trade-offs. The main one is the potential for method name collisions. If you mix in two classes that both define a method called clickSubmit()
, the last one applied will overwrite the previous ones. Additionally, it can sometimes be less obvious where a method originates compared to a clear extends
chain.
Summary
By embracing a composition over inheritance mindset with the Mixin pattern, you can build a far more flexible, modular, and maintainable Playwright test automation framework.
Your Page Objects become cleaner, your code becomes more reusable, and you avoid the architectural headache of the monolithic BasePage
. This approach better mirrors modern, component-based web development and leads to a more robust and scalable test suite.
Top comments (0)