DEV Community

Roman Orlov
Roman Orlov

Posted on

Fluent API pattern implementation with Playwright and Javascript/Typescript

Welcome!

In today’s post, I’ll talk about one of my favorite patterns for UI testing. I won’t go into detail about what it is and why you should use it. My goal today is to demonstrate the implementation of this pattern when working with Playwright and Javascript/Typescript. If after reading and analyzing the implementation examples you still have questions, I recommend reading more about this pattern here.

If you liked the post, support it with a like and a comment, and also join the Telegram channel, where I post a little more about my tasks and thoughts on the topic of software development and testing.

So, let’s go 🙂

Java first

I really like it when I don’t have to keep in mind what step of the scenario I am at and which page is open at the moment while I’m writing automated test. It seems like a small thing, but actually it complicates development due to so-called information noise. First and foremost, when I am busy with writing a test, I want to focus on the test logic test, not keeping a heap of service information in my head.

This problem can be easily solved in typed programming languages (such as Java, C#). I will show you an example and you will immediately understand that we are talking about the Fluent API pattern:

    @Test
    void testExample() {
        HomePage page = Pages.login.open()
            .fillUsernameInput(Config.correctUsername)
            .fillUsernamePassword(Confid.correctPass)
            .clickLoginButton();

            assertTrue(page.isInventoryListVisible());
    }
Enter fullscreen mode Exit fullscreen mode

Aside from the code looking super readable, there’s another side effect: I don’t have to think about where I am in the application after a certain action, all the transition logic (essentially the graph of our application) is described within the page objects. Each Page Object method returns the page type that is expected after performing the action.

There is a problem though…

Unlike the synchronous code in the above-mentioned languages, Javascript or Typescript are not synchronous by the nature, which means such code cannot be written because all methods of interacting with the page will return Promise<T>. Let me show you this with the example of the open() method of some page (it will be useful to those who are not very familiar with Promise or those who are just beginning to understand the basics of Javascript):

    export class LoginPage {
        // locators, constructors etc.

        /*
        Here we need to return this page as a result as we know we're on current
        page after it's open
        */
        public async open(): Promise<LoginPage> {
            await this.page.goto(this.url);
            return this;
        }
    }
Enter fullscreen mode Exit fullscreen mode

And now I cannot utilize Fluent API patter as of Javascript asynchronousness:

What implementation options do we have?

The simpliest one

The most obvious way is to wait until the action is done and next Page Object is returned step by step:

    let loginPage: LoginPage;

    test.beforeEach(({page}) => {
        loginPage = new LoginPage(page);
    });

    test('User can login', async () => {
        let login = await loginPage.open();
        login = await loginPage.setUsername('standard_user');
        login = await loginPage.setPassword('secret_sauce');
        let home = await loginPage.clickLoginButton();

        expect(await home.isInventoryListVisible()).toBeTruthy();
    });
Enter fullscreen mode Exit fullscreen mode

To be honest, it doesn’t look so good 😕. Need to mention that such a code doesn’t solve the context problem as well. I still have to remember which page I’m on at any given moment.

Next step — using then()

The Promise class allows us to use the then() method and the return value to build the call chains. Let's tweak our code a bit and see what we've got:

    let loginPage: LoginPage;

    test.beforeEach(({page}) => {
        loginPage = new LoginPage(page);
    });

    test('User can login', async () => {
        await loginPage.open()
            .then(async (page) => await loginPage.setUsername('standard_user'))
            .then(async (page) => await loginPage.setPassword('secret_sauce'))
            .then(async (page) => await loginPage.clickLoginButton())
            .then(async (page) => expect(await page.isInventoryListVisible()).toBeTruthy());
    });
Enter fullscreen mode Exit fullscreen mode

It’s better now, no need to remember the current page. Basically, you can use it, but it still doesn’t look nice.

The graceful way

First of all, I want to say thank you to Anthony Gore — it was he who introduced me to the wonderful library that helps solve the problem of calling a chain of methods.

So, the first thing to do is to add a new dependency to the project:

    npm i proxymise
Enter fullscreen mode Exit fullscreen mode

If you are working with a Javascript project, everything is okay, you can use it.

If the project works with Typescript, additional actions will be required:

  1. Check that the command tsc -v works. If not, you need to install an additional dependency: npm i tsc

  2. Then we execute tsc --init and look at the result. It will also be written there how to fix errors if there is any. If the command was executed successfully, a tsconfig.json file should appear in your project.

Now it is necessary to connect proxymise in the page object files and wrap the class so that it can be used from tests in the Fluent API format. The full project code looks like this:

    // pages/login-page.ts
    import {Locator, Page} from "@playwright/test";
    import {HomePage} from "./home-page";
    import proxymise from "proxymise";

    export class LoginPage {
        private page: Page;
        // Make this field static to use in static method
        private static url = 'https://www.saucedemo.com/';

        private usernameField: Locator;
        private passwordField: Locator;
        private loginButton: Locator;

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

            this.usernameField = this.page.locator('#user-name');
            this.passwordField = this.page.locator('#password');
            this.loginButton = this.page.locator('#login-button');
        }

        // This method is static now. Necessary for proxymise correct work
        public static async open(page: Page): Promise<LoginPage> {
            await page.goto(LoginPage.url);
            return new LoginPage(page);
        }

        public async setUsername(name: string): Promise<LoginPage> {
            await this.usernameField.fill(name);
            return this;
        }

        public async setPassword(pass: string): Promise<LoginPage> {
            await this.passwordField.fill(pass);
            return this;
        }

        public async clickLoginButton(): Promise<HomePage> {
            await this.loginButton.click();
            return new HomePage(this.page);
        }
    }

    // Wrap this class with proxymise to avoid it in tests code
    export default proxymise(LoginPage);

    // pages/home-page.ts
    import {Locator, Page} from "@playwright/test";
    import proxymise from "proxymise";

    export class HomePage {
        private page: Page;
        private static url = 'https://www.saucedemo.com/inventory.html';

        private inventoryList: Locator;

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

            this.inventoryList = page.locator('div.inventory_list');
        }

        public static async open(page: Page): Promise<HomePage> {
            await page.goto(HomePage.url);
            return new HomePage(page);
        }

        public async isInventoryListVisible(): Promise<boolean> {
            return await this.inventoryList.isVisible();
        }
    }

    export default proxymise(HomePage);

    // tests/saucedemo.spec.ts
    import {test, expect} from '@playwright/test';
    import LoginPage from "../pages/login-page";

    test('User can login', async ({page}) => {
        const isInventoryShown = await LoginPage.open(page)
            .setUsername('standard_user')
            .setPassword('secret_sauce')
            .clickLoginButton()
            .isInventoryListVisible();

        expect(isInventoryShown).toBeTruthy();
    });
Enter fullscreen mode Exit fullscreen mode

Pay attention to how concise our test has become. This solution is no different from the standard Fluent API in Java or C#.

Conclusions

This example is basic for understanding how to achieve Fluent API in tests written in Playwright and Typescript. But it is not final, it can be further improved by adding new features and concepts to facilitate developer and code maintainance. In any case, doing such an exercise would be useful for the development of coding skills 🙂

Good luck with your projects and don’t stop to automate!

Top comments (0)