Throughout my career, I have worked with various test automation frameworks, including Selenium WebDriver (Java, Python), WebdriverIO (JavaScript), Puppeteer (JavaScript), Cypress, and Playwright (TypeScript). These frameworks share common design patterns known as the Page Object Model (POM) and Behavior-Driven Development (BDD) approach and Screenplay Pattern. Although I am a certified BDD developer with Cucumber, BDD is not my preferred pattern, so I won’t cover it in this tutorial. Instead, we’ll focus on the Page Object Model and Module Design Pattern. I have not seen many posts on Module Design Pattern, to be honest, I haven’t seen a single post where it is used in the context of test automation.

For this tutorial, I’m going to use a website that I built to practice test automation called Instaverse. We going to look at the basic login component and assert that we get redirected to a main page.

I’ll use Playwright for the Page Object Model, as it is their recommended pattern, and Cypress for the Module Design Pattern. Before we dive into implementing these patterns, let’s look at the code without them.

Playwright

//playwright

import { test, expect } from '@playwright/test';

test.describe('login spec', () => {
  
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000/authform')
  })
  
  test('login to the Instaverse application', async ({page}) => {
    await page.locator('input[type="email"]')
            .fill('admin@gmail.com');
    await page.locator('input[type="password"]')
            .fill('123');
    await page.locator('.ant-form-item-control-input-content > .ant-btn-default > span')
            .click();
    await expect(page.getByRole('button', { name: 'Search' }))
            .toBeVisible()
  })
})

Cypress

// Cypress

describe('login test', () => {

    beforeEach(() => {
        cy.visit('http://localhost:3000/authform')
    })

    it('should be able to login', () => {
        cy.get('input[type="email"]')
            .type('admin@gmail.com');
        cy.get('input[type="password"]')
            .type('123');
        cy.get('.ant-form-item-control-input-content > .ant-btn-default > span')
            .contains('Log In').click();
        cy.get('button')
            .contains('Search')
            .should('be.visible')
    })
})

Page Objects Model

Now that we have our initial tests set up for both Playwright and Cypress, let’s start with the Page Object Model recommended by Playwright.

  1. Login Page Objects
/po/login.page.ts

import { type Page } from '@playwright/test'

export class LoginPage {

    protected readonly page: Page;

    constructor(page: Page) {
        this.page = page;
    }

    async navigate() {
        await this.page.goto('http://localhost:3000/authform')
    }

    async login(email: string, password: string) {
        await this.page.locator('input[type="email"]').fill(email);
        await this.page.locator('input[type="password"]').fill(password);
        await this.page.locator('.ant-form-item-control-input-content > .ant-btn-default > span').click();
    }
}

2. Main Page Objects

/po/main.page.ts

import { type Page, expect } from '@playwright/test'

export class MainPage {

    protected readonly page: Page;

    constructor(page: Page) {
        this.page = page;
    }

    async navigate() {
        await this.page.goto('http://localhost:3000')
    }

    async assertSearchButton() {
        await expect(this.page.getByRole('button', { name: 'Search' })).toBeVisible()
    }
}

3. And Finally let’s use it in our test

import { test, expect } from '@playwright/test';
import { LoginPage } from './po/login.page';
import { MainPage } from './po/main.page';

test.describe('login spec Page Objects', () => {
  
  test.beforeEach(async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
  })
  
  test('login to the Instaverse application', async ({page}) => {
    const loginPage = new LoginPage(page);
    const mainPage = new MainPage(page);
    
    await loginPage.login('admin@gmail.com', '123');
    await mainPage.assertSearchButton();
  })
})

Now we can see how messy it gets—we have to import all the page objects in each spec file and then instantiate objects for each page, sometimes even twice since we have to pass the page fixture to the object. So what can we do?

Let’s consider the approach Andrew Knight explains in his demo. He suggests using fixtures, which, according to the Playwright documentation, are a fundamental concept of the framework.

First, let’s add our fixture file.

/fixtures/instaverse-test.ts

import { test as base } from '@playwright/test';
import { LoginPage } from '../po/login.page';
import { MainPage } from '../po/main.page';


type InstaverseFixtures = {
  loginPage: LoginPage;
  mainPage: MainPage;
};

export const test = base.extend<InstaverseFixtures>({

  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  mainPage: async ({ page }, use) => {
    await use(new MainPage(page));
  },
});

export { expect } from '@playwright/test';

Now we can use it in our test. Notice that we no longer need to import page fixture; instead, we use the one we defined.

import { test, expect } from './fixtures/instaverse-test';

test.describe('login spec Page Objects with Fixtures', () => {
  
  test.beforeEach(async ({ loginPage }) => {
    await loginPage.navigate();
  })
  
  test('login to the Instaverse application', async ({loginPage, mainPage}) => {
    await loginPage.login('admin@gmail.com', '123');
    await mainPage.assertSearchButton();
  })
})

This looks much better now. Is there anything else we can do? Yes, we can incorporate the concept of a page manager, which is well explained by Artem Bondar in his Udemy course. First, let’s add a manager page:

/po/manager.ts

import { type Page } from '@playwright/test'
import { LoginPage } from './login.page';
import { MainPage } from './main.page';

export class Manager {

    private readonly page: Page;
    private readonly pageMain: MainPage;
    private readonly pageLogin: LoginPage;

    constructor(page: Page) {
        this.page = page;
        this.pageMain = new MainPage(this.page);
        this.pageLogin = new LoginPage(this.page);
    }

    loginPage() {
        return this.pageLogin;
    }

    mainPage() {
        return this.pageMain;
    }
}

Now we need to update our fixture, or better yet we can create a new one

/fixtures/instverse-manager-test.ts

import { test as base } from '@playwright/test';
import { Manager } from '../po/manager';


type InstaverseFixtures = {
  manager: Manager;
};

export const test = base.extend<InstaverseFixtures>({

  manager: async ({ page }, use) => {
    await use(new Manager(page));
  },
});

export { expect } from '@playwright/test';

Let’s take a look at how it will look in our test

import { test, expect } from './fixtures/instaverse-manager-test';

test.describe('login spec Page Objects Manager with Fixtures', () => {
  
  test.beforeEach(async ({ manager }) => {
    await manager.loginPage().navigate();
  })
  
  test('login to the Instaverse application', async ({manager}) => {
    await manager.loginPage().login('admin@gmail.com', '123');
    await manager.mainPage().assertSearchButton();
  })
})

We’ve reached the end of our discussion on the Page Objects Model with Playwright. While there is more to explore regarding data setup management, it is beyond the scope of this blog post.

Module Design Pattern

Now, let’s switch to Cypress and the module design pattern, which I find to be more developer-friendly.

Let’s convert our sample test to a module-based test.

/modules/login/login.ts

export function navigate(){
    return cy.visit('http://localhost:3000/authform')
}

export function login(email: string, password: string){
    cy.get('input[type="email"]')
            .type(email);
        cy.get('input[type="password"]')
            .type(password);
        cy.get('.ant-form-item-control-input-content > .ant-btn-default > span')
            .contains('Log In').click();
}
/modules/main/main.ts

export function navigate() {
    return cy.visit('http://localhost:3000')
}

export function assertSearchButton() {
    cy.get('button')
    .contains('Search')
    .should('be.visible')
}

Now we can import our modules to a test and start using it

import * as loginPage from './modules/login/login';
import * as mainPage from './modules/main/main';

describe('login test', () => {

    beforeEach(() => {
        loginPage.navigate();
    })

    it('should be able to login', () => {
       loginPage.login('admin@gmail.com', '123');
       mainPage.assertSearchButton();
    })
})

Can we make it even cleaner? Absolutely. We need to create a main index file for each module and an index.ts file to collect all the modules.

Here’s what our structure will look like:

The code would be as follows

/modules/login/index.ts

export * from './login';
/modules/main/index.ts

export * from './main';
/modules/index.ts

export * as loginPage from './login';
export * as mainPage from './main';

And then our test would be

import * as modules from './modules';

describe('login test', () => {

    beforeEach(() => {
        modules.loginPage.navigate();
    })

    it('should be able to login', () => {
       modules.loginPage.login('admin@gmail.com', '123');
       modules.mainPage.assertSearchButton();
    })
})

In my opinion, module design is much cleaner and easier to read compared to page objects. Ultimately, you should use whichever approach you and your team find most comfortable and effective.