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

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:
- Node.js and Package Manager - Use npm and Node.js 18
- Playwright Test Runner/Installation
- Basic Playwright knowledge: Basic knowledge of writing Playwright tests
- Version control & CI: A code versioning repository (e.g, GitHub, GitLab, etc.)
- A CircleCI account
- Git installed
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:
test.beforeAll(async () => { ... })
runs once per worker and is executed before all tests in a file or atest.describe
Playwright block.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.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.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.
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, thenafterEach
test hook. - Teardown: Run all
afterAll
hooks after all the tests have completed and after all theafterEach
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.
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
.
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.
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!