This episode covers custom test fixtures and helpers, extending Playwright with plugins and custom reporters, as well as sharing utilities across projects.

As your suite grows, your needs will exceed Playwright's built-in capabilities. This episode 18 covers custom tooling: building a layer on top of Playwright — custom fixtures, helper methods, custom reporters, and plugins — so the suite reflects your project's specific needs.
These abilities mark the transition from being a user to being a builder of test infrastructure. Instead of repeating boilerplate in every test, you design a foundation that can be reused, tested itself, and shared across projects.
Fixtures don't just supply objects — they also manage the lifecycle. Here's a fixture that creates a test user and then cleans it up:
import { test as base, expect } from '@playwright/test';
import { UserApi } from './api/UserApi';
export const test = base.extend({
user: async ({ request }, use) => {
const userApi = new UserApi(request);
const user = await userApi.createTestUser();
await use(user);
await userApi.deleteUser(user.id);
},
});
export { expect };base.extend({ user: ... }) creates a user fixture that creates data, hands it to the test via use(user), then cleans it up after the test finishes — even if the test fails. This removes setup/cleanup boilerplate from every test.
Helpers are pure functions that wrap repetitive logic. Separate them into their own module so they can be used without the test runner:
export function formatIDR(nominal: number) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
}).format(nominal);
}
export async function waitForToast(page) {
await page.getByRole('status').waitFor();
}formatIDR(nominal) formats a number as Indonesian Rupiah, and waitForToast(page) waits for a notification. Centralized helpers make tests more concise and consistent.
A reporter determines how test results are presented. You can create a custom reporter that sends results to an internal system:
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
class SlackReporter implements Reporter {
onTestEnd(test: TestCase, result: TestResult) {
if (result.status !== 'passed') {
console.log(`[ALERT] ${test.title} ${result.status}`);
}
}
}
export default SlackReporter;class SlackReporter implements Reporter uses the Reporter interface from @playwright/test/reporter. By using a custom reporter, suite results can be routed to Slack, a dashboard, or a database — whatever the team needs.
export default defineConfig({
reporter: [
['html', { outputFolder: 'playwright-report' }],
['./custom-reporter.ts'],
],
});reporter accepts a list of reporters that run in sequence. Combine built-in reporters (html, list) with a custom reporter for a complete output mix.
For work before all tests — for example, preparing a database or tokens — use global setup:
export default defineConfig({
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
});globalSetup runs code once before the whole suite. This is the right place to prepare a shared environment — seed data, build the app, or start a service.
Playwright isn't only for tests — it can be used as a library in any script. This integration opens the door for internal tooling:
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'preview.png' });
await browser.close();
})();The script above uses playwright (not @playwright/test) as a pure library — ideal for scheduled jobs, preview generators, or internal pipelines.
npx playwright codegen isn't only for creating tests — it also produces an interaction sketch you can refine into a script. Record a complex flow, then extract the important logic into an automation script or a test helper.
Because it runs as plain Node.js, the Playwright library can be called from scripts run by cron, Lambda, or data pipelines. The same chromium.launch() pattern works anywhere Node.js runs.
When several repositories use the same helpers, package them into an internal NPM module:
playwright-utils/
├── fixtures/
│ └── index.ts
├── helpers/
│ └── index.ts
└── index.tsThis module is published to an internal registry or used as a git dependency. All test projects then import helpers from a single source — changes only need to be made once.
import { test, expect } from '@acme/playwright-utils';
test('using a shared fixture', async ({ user, page }) => {
await page.goto('/profile');
await expect(page.getByText(user.email)).toBeVisible();
});import { test, expect } from '@acme/playwright-utils' replaces the import from @playwright/test. All shared fixtures and helpers are available directly — consistency is guaranteed across every repository.
A shared module is only useful if it's well-documented and versioned. Write documentation for every fixture and helper, release with semantic versioning, and make sure exports stay compatible via type-checking (npx tsc --noEmit) before publishing. Consumers follow changes through the changelog, not by guessing.
Episode 18 lifted you from being a user to being a tooling builder: custom fixtures manage the data lifecycle, centralized helpers reduce duplication, custom reporters and global setup extend the runner's behavior, and internal module packaging enables sharing utilities across projects in a structured way.
Key takeaways:
use manage setup and cleanup automatically.playwright as a library opens automation scripts outside the test runner.In the next episode we'll discuss operational readiness and runbooks — runbooks for flaky tests, environment drift, and browser failures, managing suite health and team ownership, recovery strategies when baselines change, and maintenance and cleanup schedules.