DEV Community

Cover image for Upgrade Playwright Tests: TypeScript Mixin Design Pattern Guide
idavidov13
idavidov13

Posted on • Originally published at idavidov.eu

Upgrade Playwright Tests: TypeScript Mixin Design Pattern Guide

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).

Monolith Design

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.

Reusable Components

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)
                );
            }
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

Notice two key parts here:

  1. export interface HomePage extends NavPage {}: This is crucial for TypeScript's type-checking. It tells the compiler, "Trust me, an instance of HomePage will also have all the methods of NavPage", giving us that sweet, sweet IntelliSense autocompletion.

  2. applyMixins(HomePage, [NavPage]): This is the runtime execution. It takes all the methods from NavPage and attaches them to HomePage. If you want to attach methods from more than one baseClass, 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' });
    });
});
Enter fullscreen mode Exit fullscreen mode

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.

composition over inheritance

Top comments (0)