Automation with Playwright in 2023. Typescript, Page Object, and Fixtures

Eugene Gronski
6 min readJan 29, 2023

Intro

Today, I want to introduce you to a Node.js framework that is becoming increasingly popular for automation. By the end of 2022, Playwright will have more than 1 million weekly downloads according to npm. It may still be losing to tools like Cypress or Selenium Webdriver in terms of weekly downloads (those have 4 and 2 million downloads respectively by the end of 2022). But is this framework worth the attention of Automation Engineers? Let’s find out!

Long story short

Playwright is developed by Microsoft and the core team consists of developers who were formerly Google Engineers who worked on Puppeteer. I think this gives us enough authority to trust this tool, since Puppeteer dominated the market for over a decade alongside Selenium driver. Many Microsoft projects are awesome, like VS Code, which is widely considered the best free code editor on the market. But let’s get back to our topic of automation.

Why should I use Playwright?

You shouldn’t. But let me convince you to try it. Results from stateofjs.com in 2022 show a satisfaction level of 95%. Let’s look into the advantages that Playwright gives us.

  • Speed: Playwright is designed to be fast and efficient, which can make it faster than other browser automation tools like Selenium. This can help you run your tests more quickly, especially if you have a large test suite.
  • Auto-wait. Playwright waits for elements to be actionable prior to performing actions. It also has a rich set of introspection events. The combination of the two eliminates the need for artificial timeouts — the primary cause of flaky tests.
  • Tracing and solid default report. Configure test retry strategy, capture execution trace, videos, and screenshots to eliminate flakes.
  • Concurrent execution Playwright allows you to run multiple browser contexts at the same time in a single process, which can make your tests run even faster.
  • Codegen and Playwright Inspector help you to generate tests by recording your actions. You can save them in any language.
  • Cross-browser testing: Playwright allows you to run your tests on multiple browsers, including Chrome, Firefox, and Safari. While it may not be necessary to run tests on multiple browsers for E2E testing, it’s nice to have the option available.

Here you can find a repository with the already configured and structured project. You can use it according to your needs. This automation dev starter solves the most common problems that we as QA Engineers face in automation.

Structure of the automation.

.
├── config
│ ├── global-setup.ts
│ └── playwright.config.ts
├── package-lock.json
├── package.json
└── src
├── data
│ └── data.json
├── fixtures
│ ├── AxeFixture.ts
│ └── TodoFixture.ts
├── pages
│ └── TodoPage.ts
└── tests
├── a11y.spec.ts
└── demo-pom-todo-app.spec.ts

Config folder

  • contain the playwright.config.ts file. You can check what this config file looks like in the repository and check documentation since there is a pretty large number of options available.
  • the global-setup.ts function runs once before all tests. Its main purpose is to log into the web app and save the entire state of the browser. This state is then stored in the storageState.json file, which is used to inject the state into the browser before each test. This helps us avoid having to log in to the app each time before running tests. Of course, there are different types of authorizations, but mostly we deal with JWT, which is stored in local storage or cookies. Look at the documentation for more information.

Page Object Model

The approach that I believe works best for automation is using the POM pattern. Here is a simple example of how to implement the Page Object pattern. All elements on the page that we plan to interact with are defined inside the class as fields. These fields are initialized within the constructor.

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

export class TodoDemoPage {
readonly page: Page;
readonly newTodoInput: Locator;
readonly todoTitle: Locator;
readonly todoCount: Locator

constructor(page: Page) {
this.page = page;
this.newTodoInput = page.getByPlaceholder('What needs to be done?');
this.todoTitle = page.getByTestId('todo-title');
this.todoCount = page.getByTestId('todo-count');
}

async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc');
}

async addTodo(data: string) {
await this.newTodoInput.fill(data)
await this.newTodoInput.press("Enter")
}

async addDefaultTodos(todosItems: string[]) {
for (const todo of todosItems) {
await this.addTodo(todo)
}
}
...
}

The methods inside the class are actions that can be performed on the actual page. As you can see, we have an addTodo method which accepts a string as a parameter. This method is quite straightforward. When implementing the Page Object pattern, remember to follow good practices.

  • naming — make sure that fields and methods have appropriate names.
  • the method does only one thing according to the name and does it well.
  • perform assertion of particular results on the page.
  • dry — don’t repeat yourself.

Fixtures

Before we go to the actual test and how to use the POM, I want to introduce fixtures. It’s a more advanced feature that, in my opinion, is more flexible than just using usual lifecycle methods such as before/after. Essentially, they are used to establish the environment for each test, giving the test all it needs.

But enough words, let’s look at the code.

import { test as base } from '@playwright/test';
import { TodoDemoPage } from '../pages/TodoPage';

type MyFixtures = {
todoDemoPage: TodoDemoPage;
noneExistingPage: any
};

export const todoDemoPage = async({page}, use) => {
const todoDemoPage = new TodoDemoPage(page);
// Set up the fixture.
await todoDemoPage.goto();
// Use the fixture value in the test.
await use(todoDemoPage);
}

// we can create as many fixtures as we want, but I prefer to store them in separate files
export const noneExistingPage = async({page}, use) => {
// Let's imagine we have another fixter-page set up here
}

export const test = base.extend<MyFixtures>({todoDemoPage, noneExistingPage});

In this case, it’s simple functions with two parameters, which can be used for extending the test object from Playwright. Thus we will heave direct access to any of defined fixtures.

I can’t describe the advantages better than the documentation, but there are a few that I’d like to quote here. I hope you will appreciate fixtures as the number of your pages and test cases grow.

Fixtures encapsulate setup and teardown in the same place so it is easier to write.

Fixtures are reusable between test files — you can define them once and use in all your tests. That’s how Playwright’s built-in page fixture works.

Fixtures are on-demand — you can define as many fixtures as you’d like, and Playwright Test will setup only the ones needed by your test and nothing else.

Fixtures are composable — they can depend on each other to provide complex behaviors.

source: https://playwright.dev/docs/test-fixtures

Tests

So, the hardest part is actually behind us and we can now enjoy writing tests using our fixtures, which encapsulate the whole set-up logic, allowing us to focus on building our automation tests.

import { test } from '../fixtures/TodoFixture'

const TODO_ITEMS = [
'buy some cheese',
'buy bottle of wine, or two',
'celebrate'
];

// our test is imported from fixtures folder
// so we can have access to tododDempPage and noneExistingPage objects
// in callback function trhough destructuring and we can use it for our needs
test.describe('New Todo', () => {
test('should allow me to add todo items', async ({ todoDemoPage }) => {
await todoDemoPage.addTodo(TODO_ITEMS[0])
await todoDemoPage.checkInputIsEmpty();
await todoDemoPage.addTodo(TODO_ITEMS[1])
await todoDemoPage.checkAddedTodos([TODO_ITEMS[0], TODO_ITEMS[1]])
await todoDemoPage.checkNumberOfTodosInLocalStorage(2);
});

test('should clear text input field when an item is added', async ({ todoDemoPage }) => {
await todoDemoPage.addTodo(TODO_ITEMS[0])
await todoDemoPage.checkInputIsEmpty();
await todoDemoPage.checkNumberOfTodosInLocalStorage(1);
});


test('should append new items to the bottom of the list', async ({ todoDemoPage }) => {
await todoDemoPage.addDefaultTodos(TODO_ITEMS);
await todoDemoPage.checkDefaultAddedTodods(TODO_ITEMS);
await todoDemoPage.checkAddedTodos(TODO_ITEMS)
await todoDemoPage.checkNumberOfTodosInLocalStorage(3);
});
});

Those three tests are pretty simple, but look how clean and readable they are. If we hadn’t used POM and fixtures, the code would have looked more chaotic and less readable, which doesn’t help when your tests are getting flaky or failing and you need to fix them, changing something in multiple places.

CI/CD

Once our tests have been developed and we have a proof of concept for our p, it is important to configure our continuous integration and continuous deployment (CI/CD) pipeline. The specific approach to do this will vary depending on the specific project. It is recommended to read the documentation to properly set up the CI/CD pipeline.

Summary

I hope that you found the information helpful in your journey towards automation. Enjoy the coding and hope you will have bug-free projects!

Github with Playwright starter: https://github.com/eugeniuszG/playwright-starter

--

--