Why Serenity/JS
Your test suite should be more than a collection of scripts that pass or fail. It should be shared evidence of what your system does, how reliably it does it, and whether it's ready to ship — evidence that developers, testers, and business stakeholders can all read and act on.
That's what Serenity/JS helps you build.
Tests and reports that explain your system
Most test reports list what's broken. Serenity/JS reports explain what your system does — and how confidently you can ship it:
- For developers: activity trees show the exact step that failed, with screenshots, HTTP exchanges, and timing — reducing diagnosis from minutes to seconds
- For QA engineers: consistency tracking reveals which tests are genuinely broken vs. flaky vs. recovering — so you invest effort where it matters
- For product owners and managers: the dashboard answers "can we ship?" with a confidence score, pass rate, and trend chart — no technical translation needed
- For the whole team: the Capabilities view maps test results to your directory structure, turning your spec files into navigable living documentation of what the system does
The Serenity/JS HTML Reporter produces these reports automatically from your test results. But a report is only as meaningful as the tests behind it — which is where the Serenity/JS framework comes in.
→ See a live report | HTML Reporter guide
Tests expressed in domain language
For reports to serve the whole team, tests need to describe what the actors in your system are doing and why — how a customer completes the checkout flow, how a third-party integrates with your APIs, how an administrator audits the system — not just how browsers click buttons or REST clients send requests.
This requires tests to be written using the vocabulary of the business workflows they represent, with implementation details encapsulated so they don't pollute the test. As described in BDD in Action, Second Edition, this separation naturally organises into three layers — and Serenity/JS gives you the building blocks to implement them:
| Layer | Concern | Serenity/JS concept |
|---|---|---|
| Specification | What the system should do | Test scenarios (describe, it) |
| Domain | How actors accomplish goals — composable workflows in business language | Actors + Tasks |
| Integration | Where the system is interacted with — web UIs, APIs, mobile apps | Abilities + Interactions + Questions |
Each layer depends only on the layer below it, so when something changes, the impact stays contained:
- UI redesign — update selectors in the Integration layer; your business workflows and test scenarios don't change
- New business rule — add scenarios and Tasks; the integration code stays the same
- Switch Selenium → Playwright — swap the configuration; your Tasks and specs are untouched
- Change integration APIs — update the Integration layer; nothing above it notices
→ Learn more: Screenplay Pattern | Serenity/JS Architecture | BDD in Action, Second Edition
The Screenplay Pattern makes this practical
The Screenplay Pattern is the architectural mechanism that makes domain-language tests possible without ceremony or heavyweight abstractions:
- Actors represent people and external systems interacting with the system under test
- Abilities are thin wrappers around integration libraries needed to interact with your system's interfaces
- Tasks model sequences of activities as meaningful steps of a business workflow
- Interactions represent the low-level activities an actor can perform using a given interface
- Questions retrieve information from the system under test and the test execution environment
The same Tasks and Questions work across Playwright, WebdriverIO, Cucumber, and other supported test runners — because they depend on abstractions, not on any specific integration library.
The examples below use Playwright Test, but Screenplay Pattern code is identical regardless of what test runner or browser driver you use — that's the point of the architecture.
Adopt at your own pace
Serenity/JS draws on over two decades of the author's experience helping teams of all sizes — from startups shipping MVP webapps to multinationals building online banking systems and sophisticated trading platforms. We know that no team can stop delivering to rearchitect their test suite. That's why Serenity/JS supports gradual adoption: start with just reporting, introduce Screenplay interactions where they add value, and let new and legacy tests run side by side — no migration deadline required.
To show you how this works in practice, here's the same test at four levels of adoption, from vanilla Playwright through to the full Screenplay Pattern.
Scenario: Existing Swag Labs customer successfully orders an item
- Customer logs in
- Adds a backpack to their cart
- Successfully completes checkout
Swag Labs is a demo e-commerce website created by Sauce Labs specifically for practicing test automation. It's a fictive online store where you can browse products, add items to a cart, and complete checkout flows.
Level 0: Where most teams start
No Serenity/JS — all logic in flat test scripts. This is where most teams start — and where most test suites stay until the pain of duplication and opaque failures forces a change.
import { test, expect } from '@playwright/test';
test('should complete checkout successfully', async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.locator('[data-test="username"]').fill('standard_user');
await page.locator('[data-test="password"]').fill('secret_sauce');
await page.locator('[data-test="login-button"]').click();
await page.locator('[data-test="add-to-cart-sauce-labs-backpack"]').click();
await page.locator('[data-test="shopping-cart-link"]').click();
await page.locator('[data-test="checkout"]').click();
await page.locator('[data-test="firstName"]').fill('Alice');
await page.locator('[data-test="lastName"]').fill('Smith');
await page.locator('[data-test="postalCode"]').fill('90210');
await page.locator('[data-test="continue"]').click();
await page.locator('[data-test="finish"]').click();
await expect(page.locator('[data-test="complete-header"]'))
.toHaveText('Thank you for your order!');
});
What you get: A working test. Quick to write when you have a handful of scenarios.
What breaks down at scale:
- Selector duplication — when
[data-test="login-button"]becomes[data-test="sign-in"], you grep the codebase and update every file that references it. With 200 tests and 30 that need login, that's 30 edits for one rename. - Opaque failures — reports show "failed at locator
[data-test="login-button"]". To understand what the test was verifying, you have to mentally parse the whole script. - No reuse — login, "add to cart", checkout — each sequence is copy-pasted into every test that needs it.
- AI amplifies the problem — coding agents trained on your codebase reproduce the same copy-paste patterns, generating more tests with the same duplication and fragility, faster than you can review their changes.
The password secret_sauce is hard-coded in these examples to keep them simple and self-contained. In real-world tests, load credentials from environment variables or a password vault to avoid committing secrets to your repository.
Level 1: Add Serenity/JS reporting
The smallest possible improvement: add the Serenity/JS HTML Reporter to your test runner configuration. Your test code stays completely unchanged, but the reports now show trend history across CI runs, flaky test detection, error clustering, and an interactive dashboard — all in a self-contained report you can open directly from your filesystem, or serve from GitHub Pages or GitLab Pages.
For example, with Playwright Test:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
- reporter: 'html',
+ reporter: [
+ [ 'html' ], // Keep the default Playwright HTML reporter for low-level debugging
+ [ '@serenity-js/playwright-test', {
+ crew: [
+ '@serenity-js/console-reporter',
+ [ '@serenity-js/html-reporter', {
+ specDirectory: './tests'
+ } ],
+ ]
+ }]
+ ],
// ... rest of your config stays the same
});
That's it — no separate report generation step, no additional scripts. The report is produced automatically at the end of the test run.
→ Learn more: HTML Reporter configuration | Configuring the test runner
What you gain:
- Trend history — see how pass rate and duration change across CI runs; spot regressions immediately
- Flaky test detection — tests automatically classified as flaky, degraded, inconsistent, or recovered
- Error clustering — failures grouped by root cause, not listed individually
- Interactive dashboard — confidence score, pass rate, completeness, and consistency at a glance
- Zero changes to your test code — only the config is updated
What still breaks down at scale:
- Selectors still duplicated — a UI refactor still means a multi-file find-and-replace
- No code reuse — login logic is still copy-pasted into every test that needs an authenticated user
- Diagnosis gap — reports tell you which test failed and cluster errors, but the test code itself still can't show you the step-by-step execution at a glance
Level 2: Screenplay + Playwright hybrid
Now that you have reporting, the next step is making your tests produce better reports. Swap the import to @serenity-js/playwright-test to access the actor fixture, then introduce Screenplay interactions where they add clarity. Each interaction appears as a named, timed step in the report — and your existing tests continue to work unchanged alongside the new ones.
import { test, expect } from '@serenity-js/playwright-test'; // ← swap the import
import { Navigate } from '@serenity-js/web';
test('should complete checkout successfully', async ({ actor, page }) => {
// Screenplay interaction — shows as a named step in reports
await actor.attemptsTo(
Navigate.to('https://www.saucedemo.com/'),
);
// Vanilla Playwright — still works, just won't appear as named steps
await page.locator('[data-test="username"]').fill('standard_user');
await page.locator('[data-test="password"]').fill('secret_sauce');
await page.locator('[data-test="login-button"]').click();
await page.locator('[data-test="add-to-cart-sauce-labs-backpack"]').click();
await page.locator('[data-test="shopping-cart-link"]').click();
await page.locator('[data-test="checkout"]').click();
await page.locator('[data-test="firstName"]').fill('Alice');
await page.locator('[data-test="lastName"]').fill('Smith');
await page.locator('[data-test="postalCode"]').fill('90210');
await page.locator('[data-test="continue"]').click();
await page.locator('[data-test="finish"]').click();
await expect(page.locator('[data-test="complete-header"]'))
.toHaveText('Thank you for your order!');
});
What you gain over Level 1:
- Screenplay interactions appear as named steps in reports — with timing and screenshots
- Access to
actor,actorCalled, and other Serenity/JS fixtures - A library of ready-made interactions —
Navigate,Click,Enter, and many more - Incremental adoption — convert interactions one at a time without rewriting entire tests
What still breaks down at scale:
- Sequences still inlined — two tests that both need "add item to cart" each spell out the same clicks and selectors
- No single source of truth — selectors live in test files rather than in a shared location, so a rename still fans out
- Limited composability — named steps improve reports, but cross-test reuse requires the next step: factoring common workflows into composable Tasks
Level 3: Full Screenplay Pattern
This is where the architecture pays off in full. Tests describe what the actors do in business language — and the report reflects that directly. Selectors, page interactions, and API calls live in separate, reusable classes that any test can compose. Compare the spec file below with the flat script from Level 0:
- swag-labs.spec.ts
- Authenticate.ts
- Inventory.ts
- Checkout.ts
import { describe, it } from '@serenity-js/playwright-test';
import { Ensure, equals } from '@serenity-js/assertions';
import { Navigate } from '@serenity-js/web';
import { Authenticate } from '../screenplay/Authenticate';
import { Inventory } from '../screenplay/Inventory';
import { Checkout } from '../screenplay/Checkout';
describe('Swag Labs', () => {
it('should let a standard user complete checkout', async ({ actor }) => {
await actor.attemptsTo(
Navigate.to('https://www.saucedemo.com/'),
Authenticate.withCredentials('standard_user', 'secret_sauce'),
Inventory.productCalled('Sauce Labs Backpack').addToCart(),
Checkout.completeWith({
firstName: 'Alice',
lastName: 'Smith',
postalCode: '90210',
}),
Ensure.that(Checkout.confirmationHeading(), equals('Thank you for your order!')),
);
});
});
import { Masked, Task } from '@serenity-js/core';
import { Click, Enter, PageElement, By } from '@serenity-js/web';
export class Authenticate {
private static usernameField =
PageElement.located(By.css('[data-test="username"]')).describedAs('username field');
private static passwordField =
PageElement.located(By.css('[data-test="password"]')).describedAs('password field');
private static loginButton =
PageElement.located(By.css('[data-test="login-button"]')).describedAs('login button');
static withCredentials = (username: string, password: string) =>
Task.where(`#actor logs in as ${ username }`,
Enter.theValue(username).into(Authenticate.usernameField),
Enter.theValue(Masked.valueOf(password)).into(Authenticate.passwordField),
Click.on(Authenticate.loginButton),
);
}
import { Task } from '@serenity-js/core';
import { Click, Text, PageElement, By } from '@serenity-js/web';
export class ProductCard {
constructor(private name: string) {}
private card = PageElement.located(By.css(
`[data-test="inventory-item"]:has([data-test="inventory-item-name"]:text("${ this.name }"))`
)).describedAs(`product card for "${ this.name }"`);
private inventoryItemPrice =
PageElement.located(By.css('[data-test="inventory-item-price"]'))
.of(this.card).describedAs(`price of "${ this.name }"`);
private addToCartButton =
PageElement.located(By.css('[data-test^="add-to-cart"]'))
.of(this.card).describedAs(`"Add to cart" button for "${ this.name }"`);
price = () =>
Text.of(this.inventoryItemPrice);
addToCart = () =>
Task.where(`#actor adds "${ this.name }" to the cart`,
Click.on(this.addToCartButton),
);
}
export class Inventory {
static productCalled = (name: string) => new ProductCard(name);
}
import { Task } from '@serenity-js/core';
import { Click, Enter, Text, PageElement, By } from '@serenity-js/web';
export class Cart {
private static cartLink =
PageElement.located(By.css('[data-test="shopping-cart-link"]')).describedAs('shopping cart link');
private static checkoutButton =
PageElement.located(By.css('[data-test="checkout"]')).describedAs('checkout button');
static open = () =>
Task.where('#actor opens the shopping cart',
Click.on(Cart.cartLink),
);
static checkout = () =>
Task.where('#actor proceeds to checkout',
Cart.open(),
Click.on(Cart.checkoutButton),
);
}
export class Checkout {
private static firstNameField =
PageElement.located(By.css('[data-test="firstName"]')).describedAs('first name');
private static lastNameField =
PageElement.located(By.css('[data-test="lastName"]')).describedAs('last name');
private static postalCodeField =
PageElement.located(By.css('[data-test="postalCode"]')).describedAs('postal code');
private static continueButton =
PageElement.located(By.css('[data-test="continue"]')).describedAs('continue button');
private static finishButton =
PageElement.located(By.css('[data-test="finish"]')).describedAs('finish button');
private static confirmationHeader =
PageElement.located(By.css('[data-test="complete-header"]')).describedAs('confirmation heading');
static completeWith = (info: { firstName: string; lastName: string; postalCode: string }) =>
Task.where(`#actor completes checkout`,
Cart.checkout(),
Enter.theValue(info.firstName).into(Checkout.firstNameField),
Enter.theValue(info.lastName).into(Checkout.lastNameField),
Enter.theValue(info.postalCode).into(Checkout.postalCodeField),
Click.on(Checkout.continueButton),
Click.on(Checkout.finishButton),
);
static confirmationHeading = () =>
Text.of(Checkout.confirmationHeader);
}
What you gain over Levels 1–2:
- Readable tests — the scenario reads like a user story, not a browser script
- Reusable building blocks —
Authenticate.withCredentials()works in every test; change a selector once, fix every test that needs it - Rich activity breakdown — reports decompose high-level Tasks into their constituent steps, each with timing and evidence, so you can trace exactly where a workflow succeeded or failed
- Multi-actor support — buyer + seller, admin + user scenarios become trivial to implement
- Blended testing — use APIs for fast test data setup, UI only where it matters
- Tool independence — swap Playwright for WebdriverIO, or use both in the same project, without touching test logic
The Task code imports from @serenity-js/web and @serenity-js/core — not from Playwright or WebdriverIO. Switch tools (or use both) without rewriting your Domain layer. Only the Ability configuration changes.
→ Try the Screenplay Pattern | Full Playwright tutorial | WebdriverIO tutorial
Ready to start?
Pick the guide for your test runner — each one takes you from zero to a working report in under 5 minutes:
- For Playwright users — add Serenity/JS to your project in 5 minutes
- For WebdriverIO users — integrate with your existing setup
- For Cucumber users — add reporting and Screenplay to your Gherkin scenarios
- 15-minute tutorial — build your first Screenplay test step by step, from scratch
- Project Templates — pre-configured starter projects
What others are saying
- Open Letter to Jan Molak — "Its value is that it encourages teams to think about behaviour, intent, and outcomes," by Jan Graefe
- Code was never the hard part — on why design thinking matters more than code generation