TutorialsJun 23, 20258 min read

Understanding Playwright test hooks in the CI context (JavaScript) – A complete tutorial

Waweru Mwaura

Software Engineer

All applications need some form of testing, whether frontend, backend, stress testing, or any other. Playwright can help.

Playwright is an end-to-end testing framework for web applications, supporting cross-browser testing (Chromium, Firefox, WebKit) from a single API. Its built-in test runner (Playwright Test) provides hook functions to manage set-up and tear-down logic around your tests.

Prerequisites

To get the most from this tutorial for Playwright hooks, you will need:

Getting started

To get started quickly, and for a seamless experience, clone the project repository from github from here. Use this command:

git clone https://github.com/CIRCLECI-GWP/playwright-test-hooks.git

Or, start by creating a test project. To create a Playwright test project and install the Playwright browsers, run:

 npm install -D @playwright/test && npx playwright install

What are Playwright hooks?

The four primary hooks for Playwright are:

  1. test.beforeAll(async () => { ... }) runs once per worker and is executed before all tests in a file or a test.describe Playwright block.
  2. test.beforeEach(async () => { ... }) runs before every individual test. It is commonly used to reset the state of a test or navigate to a base URL before each test execution in a block of tests.
  3. test.afterEach(async () => { ... }) runs after every test execution. It is used to clean up or reset side effects of the individual tests (e.g., clearing cookies) after each test finishes to allow for the next test to run seamlessly.
  4. test.afterAll(async () => { ... }) runs once per worker and after all tests in the file or block of tests are done. It is the final level of teardown in a block of tests.

For visibility and usability, Playwright hooks can be declared at the top level outside the test setup script or inside a test.describe block.

This snippet can be found in the cloned repository in the tests directory under the file name authentication-demo-flow.spec.js.

// tests/authentication-demo-flow.spec.js
import { test } from '@playwright/test';

test.describe('User  Authentication Flow', () => {
  test.beforeAll(async () => {
    // e.g. start a mock login server
  });

  test.beforeEach(async ({ page }) => {
    await page.goto('https://example.com/login');
  });

  test('has correct login title', async ({ page }) => {
    await expect(page).toHaveTitle(/Login/);
  });

  test.afterEach(async ({ page }) => {
    await page.context().clearCookies();
  });

  test.afterAll(async () => {
    // e.g. stop the mock login server
  });
});

Here is a diagram of the Playwright hooks lifecycle.

Playwright hooks lifecycle

Using Playwright test hooks

The test hooks in the previous example test script appear in this sequence:

  • Setup: Run all the beforeAll hooks that are described in the test file.
  • Test: For every test that is present in the file, run the beforeEach hook, then the test, then afterEach test hook.
  • Teardown: Run all afterAll hooks after all the tests have completed and after all the afterEach hooks have completed executing.

To show this, you can use a test that goes to the example.com website and runs two tests with beforeAll, beforeEach, afterEach and afterAll hooks. The snippet is part of the cloned repository: tests/multiple-test-hooks.spec.js.

Here is the sequence of execution:

// multiple-test-hooks.spec.js
import { test, expect } from '@playwright/test';

test.beforeAll(async () => {
  console.log('>>> beforeAll');
});

test.beforeEach(async ({ page }) => {
  console.log('>>> beforeEach');
  await page.goto('https://example.com');
});

test('test 1', async ({ page }) => {
  console.log('>>> running test 1');
  await expect(page).toHaveTitle('Example Domain');
});

test('test 2', async ({ page }) => {
  console.log('>>> running test 2');
  const header = await page.locator('h1');
  await expect(header).toHaveText('Example Domain');
});

test.afterEach(async () => {
  console.log('>>> afterEach');
});

test.afterAll(async () => {
  console.log('>>> afterAll');
});

Execute the test using this command:

 npx playwright test tests/multiple-test-hooks.spec.js

The browser name will depend on the browser configured during playwright installation.

This is an example of the console output:

Running 2 tests using 2 workers
[chromium] › tests/multiple-test-hooks.spec.js:13:7 › Multiple hooks › test 1
>>> beforeAll
[chromium] › tests/multiple-test-hooks.spec.js:18:7 › Multiple hooks › test 2
>>> beforeAll
[chromium] › tests/multiple-test-hooks.spec.js:13:7 › Multiple hooks › test 1
>>> beforeEach
[chromium] › tests/multiple-test-hooks.spec.js:18:7 › Multiple hooks › test 2
>>> beforeEach
[chromium] › tests/multiple-test-hooks.spec.js:13:7 › Multiple hooks › test 1
>>> running test 1
[chromium] › tests/multiple-test-hooks.spec.js:18:7 › Multiple hooks › test 2
>>> running test 2
[chromium] › tests/multiple-test-hooks.spec.js:13:7 › Multiple hooks › test 1
>>> afterEach
[chromium] › tests/multiple-test-hooks.spec.js:18:7 › Multiple hooks › test 2
>>> afterEach
[chromium] › tests/multiple-test-hooks.spec.js:13:7 › Multiple hooks › test 1
>>> afterAll
[chromium] › tests/multiple-test-hooks.spec.js:18:7 › Multiple hooks › test 2
>>> afterAll
  2 passed (5.9s)

To open last HTML report run:

  npx playwright show-report

Here is a diagram showing the multiple hooks execution flow.

Playwright hooks lifecycle

While the test script above seems like a basic use case, hooks can achieve very complex use cases. An example which could include something like multiple users in a system that log in via different URLs, require different authentication methods, and have access to different permissions. With Playwright hooks, we can segregate the various users using a beforeEach to log in the specific users for the test, a before all to clear any king of existing state for any user, and an after each to log out users and also delete any kind of data not required for the next user.

Hooks not only provide for a way to achieve different results with the same set of steps but also a way to modify the configuration of Playwright, as we will see below.

Hooks vs other Playwright lifecycle methods

Playwright hooks are not only limited in the scope of being used in test files but can also be used in a global scope, such as when we would like to set up resources before our entire test suite starts to execute and after our test suite completes execution. This can involve actions such as globally logging in to ensure that we do not login when executin every single test.

Global setup/teardown vs hooks

The Global Setup and teardown are not Playwright hooks but tie into the Playwright lifecycle configuration and can be used with test hooks to further create more dynamic tests.

Here is a snippet of how we would use globalSetup and globalTeardown in the config file:

// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
  globalSetup: require.resolve('./global-setup.js'),
  globalTeardown: require.resolve('./global-teardown.js'),
});

This configuration in playwright.config.ts tells Playwright to expect a global setup configuration file in the ./global-setup.js file and a teardown file in the ./global-teardown.js. Usually the global-setup sits above the beforeAll hook and will always execute before any other hooks execute and the global teardown will always execute after all the afterAll hooks to teardown any resources that had been created by the test run.

Note: In global-setup.js, you can always export async functions to prepare your test environments and set environment variables that will be used in the execution of your tests.

Here is an example of a global setup snippet to set up the user and authenticate them before executing any test:

//global-setup.js example

import {test as globalSetup} from '@playwright/test';
import config from '../../../config';
import { authenticate } from '@utils/test-utils/authUtils';

globalSetup('UI Global Setup', async ({request}) => {
  await authenticate(request, config.get("APP_USERNAME"), config.get("APP_PASSWORD"));
})

An example of a global-teardown snippet to log out the user after test execution completion.

//global-teardown.js example

import {test as globalTeardown} from '@playwright/test';
import config from '../../../config';
import { logout } from '@utils/test-utils/authUtils';

globalTeardown('UI Global Setup', async ({request}) => {
  await logout(request, config.get("APP_USERNAME"));
})

With the Global setup at play, the previous execution sequence for the Playwright hooks would look something like this.

>>>> Global setup actions
>>> beforeAll
>>> beforeEach
>>> running test 1
>>> afterEach
>>> beforeEach
>>> running test 2
>>> afterEach
>>> afterAll
>>>> Global teardown actions

Custom fixtures vs hooks

Just to be very clear before anyone throws any stones, custom fixtures in Playwright are not hooks but they do serve a similar purpose like what hooks do in most circumstances. Fixtures allow us to create new functionality and reuse it across different tests or entirely change how we use the default functionality in Playwright such as a click and modify that globally by extending a fixture and therefore the behaviour of that functionality.

Custom fixtures allow for the encapsulation of logic with fixtures. A good example would be to have a function that encapsulates a repeated activity, such as navigation to the Todo page and clearing any todos before performing any other action on that page. In this case, the todoPage fixture, once called in the test, will first navigate to the todo page and clear any todos in that page before performing any other action in our test.

Similar to our hooks, we can use this custom fixture to perform a login for various users based on the defined hook. We can have a hook defined for logging in Admins, another for Managers, and another for just ordinary users, and the result would be the same.

import { test as base } from '@playwright/test';
import { TodoPage } from './todo-page';

const test = base.extend({
  todoPage: async ({ page }, use) => {
    const todoPage = new TodoPage(page);
    await todoPage.goto();
    await use(todoPage);
    await todoPage.clearAll();
  },
});

test('can add and remove todos', async ({ todoPage }) => {
    // The todoPage encapsulates the `page` fixture and also 
    // navigation to the todoPage and also clearing of the todo items
  await todoPage.addToDo('Buy milk');
});

Now that you understand how to use multiple Playwright hooks, you can integrate CI/CD using CircleCI.

Writing the CI pipeline configuration

Note: This section is applicable to you only if you are creating the CI/CD configuration yourself. If you are using the cloned repository this has already been implemented and there is no need for further action.

In this section, you will automate the test scripts written above by adding the pipeline configuration for CircleCI. Start by creating a folder named .circleci in the root directory. Then create a config.yml file where your CircleCI configuration will live. Now add these configuration details:

version: 2.1
orbs:
  circleci-cli: circleci/circleci-cli@0.1.9
jobs:
  build:
    docker:
      - image: mcr.microsoft.com/playwright:v1.40.0-jammy
    working_directory: ~/repo
    steps:
      - checkout
      # Download and cache dependencies
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-

      - run: npm install
      - run: npm run test

      - store_artifacts:
          path: ./playwright-report
          destination: playwright-report-first
      - store_artifacts:
          path: /tmp/artifacts

In this configuration, CircleCI uses a Node.js Docker image provided by Microsoft (Playwright container) and pulled from the Docker registry. It then updates the npm package manager. Next, if the cache exists, it is restored. Application dependencies are updated only when a change has been detected with restore-cache.

Setting up a project on CircleCI

If you cloned the sample project repository, your project is already integrated with CircleCI. If you created this yourself, you will need to set up CircleCI. Initialize a GitHub repository in your project by running:

git init

Next, create a .gitignore file in the root directory. Inside the file, add node_modules to ignore npm-generated modules from being added to your remote repository. Add a commit and then push your project to GitHub.

Log into CircleCI and go to Projects. All the repositories associated with your GitHub username or your organization are listed, including the one you want to set up in CircleCI. For this tutorial, it is playwright-test-hooks.

Select project

Click the Set Up Project button. You will be prompted about whether you have already defined the configuration file for CircleCI within your project. Enter the branch name (for this tutorial, you are using main). Click Set Up Project to complete the process.

This will run successfully.

Select project

Conclusion

In this tutorial, you learned how to use Playwright test hooks to set up and tear down resources in Playwright. You learned to use the beforeAll/afterAll logic in Playwright tests for setup that executes just once before and after execution of all tests; and beforeEach and afterEach for repeated logic across the various tests. You also learned how hooks differ from custom fixtures and global set up/tear down, and how they can be both used to manage resources required for tests. You learned how to use CircleCI to ensure that tests can run locally and on a CI/CD platform. If you enjoyed this tutorial, kindly share it with your community. Until the next one, keep testing!