Appearance
E2E Testing Guide
This document describes the Playwright E2E test architecture, directory structure, and conventions used in this project.
Overview
All E2E tests live under e2e/ and are run with Playwright. They mock all API calls via page.route() — no real backend is required.
Tests are organised into three layers:
| Layer | Location | Purpose |
|---|---|---|
| Spec files | e2e/Tests/ | Contain test() blocks, assertions, and test.step() calls |
| Page Fixtures & Selectors | e2e/WidgetPages/ | Page lifecycle, widget configuration, and locator factories |
| Utilities | e2e/Utilities/ | API interceptors, response generators, setup helpers, test data |
Directory Structure
e2e/
├── Tests/
│ ├── OfflineSetup.ts ← test.extend() with pageFixture option
│ ├── CheckoutFlows/
│ │ └── TicketBooking.spec.ts ← Full ticket booking checkout flows
│ ├── WidgetValidation/
│ │ ├── cssValidation.spec.ts ← Shadow DOM CSS property assertions
│ │ └── widgetColourScheme.spec.ts ← Remote-config colour scheme assertions
│ └── SmokeTests/
│ ├── Affiliate Link/
│ │ └── AffiliateLink.spec.ts ← Affiliate link localStorage / URL param tests
│ ├── BookNowWidget/
│ │ ├── BookNow.spec.ts ← Book-now widget smoke tests
│ │ └── BookNowAndFloatingWidgets.spec.ts ← Combined book-now + floating widget tests
│ ├── Configuration/
│ │ └── Configuration.spec.ts ← Remote config override tests
│ ├── Localisation/
│ │ └── Localisation.spec.ts ← Multi-language UI string tests
│ └── Tracking/
│ ├── GAnalytics.spec.ts ← Google Analytics data-layer tests
│ └── Posthog.spec.ts ← PostHog event tests
│
├── WidgetPages/ ← Page fixtures (one folder per page type)
│ ├── index.ts ← BasePageFixture, PageFixture & PageWidgetFixture interfaces
│ ├── Selectors/ ← Shared selector classes
│ │ ├── WidgetSelectors.ts ← Aggregates all selector groups
│ │ ├── ModalSelectors.ts ← Modal-related selectors
│ │ ├── DatePickerSelectors.ts ← Date picker selectors
│ │ ├── ExperienceCardSelectors.ts ← Experience card selectors
│ │ └── LanguageSelectors.ts ← Language switcher selectors
│ ├── PageWithASingleWidgetWithCustomTrigger/ ← Custom trigger page fixture
│ ├── PageWithThreeWidgets/ ← Default 3-widget page fixture (used by most tests)
│ ├── PageInDarkMode/ ← Dark mode page fixture
│ ├── PageWithRemoteConfig/ ← Remote config page fixture
│ ├── PageWithBookNowWidget/ ← Book-now widget page fixture
│ ├── PageWithGATracking/ ← Google Analytics page fixture
│ ├── PageWithPHTracking/ ← PostHog tracking page fixture
│ ├── PageWithDefaultLanguageItalian/ ← Italian default language page fixture
│ └── PageWithBookNowAndFloatingWidgets/ ← Combined book-now + floating widget fixture
│
├── Utilities/
│ ├── Interceptors/ ← All page.route() calls live here
│ │ ├── Inventory/index.ts ← Widget, experience, availability, calendar interceptors
│ │ └── Analytics/Posthog.ts ← PostHog request interceptor
│ ├── Fakers/ ← Typed mock response generators
│ │ ├── V4ResponseGenerator.ts ← Factory functions for all API response shapes
│ │ └── lib/
│ │ ├── experiences.ts ← Pre-built experience data constants (ScienceWorkshop, etc.)
│ │ └── widget.ts ← Widget data constants
│ ├── TestData/ ← Feature-scoped constants — never hardcode in spec files
│ │ ├── TicketBookingTestData.ts
│ │ ├── BookNowTestData.ts
│ │ ├── AffiliateLinkTestData.ts
│ │ ├── ConfigurationTestData.ts
│ │ ├── CSSValidationTestData.ts
│ │ ├── GAnalyticsTestData.ts
│ │ ├── LocalisationTestData.ts
│ │ ├── PosthogTestData.ts
│ │ └── ThemeValidationTestData.ts
│ ├── Availability/
│ │ └── setupAvailability.ts ← setupAvailability(), setupMultipleAvailabilities()
│ ├── Calendar/
│ │ └── setupCalendar.ts ← setupCalendar(), setupMultipleCalendarDays()
│ ├── DatePicker/
│ │ └── DatePicker.ts ← goToMonth(), dayElementsByDayText(), clickableDayElementsByDayText(), dayElementByAriaLabel()
│ ├── CSSValidation/
│ │ └── cssValidation.ts ← verifyShadowDOMStyles()
│ ├── Localisation/
│ │ └── Localisation.ts ← switchLanguage()
│ ├── RemoteConfig/
│ │ ├── configOverrides.ts ← makeUpdatedConfig(), makeThemeConfig(), ColorSet
│ │ ├── remoteConfig.ts ← buildUpdatedConfig(), expectClassNames()
│ │ └── remoteConfigData.ts ← RemoteConfig interface, remoteDefaultConfig
│ ├── ThemeValidation/
│ │ └── themeValidation.ts ← Theme validation helpers
│ ├── Tracking/
│ │ ├── tracking.ts ← getDataLayer(), assertPageView(), assertEvent()
│ │ └── Posthog/
│ │ └── PosthogEvents.ts ← Typed PostHog event interfaces & union
│ ├── AffiliateLinks/
│ │ └── AffiliateLinks.ts ← getBklinkFromLocalStorage(), setBklinkInLocalStorage()
│ └── BookNowWidget/
│ └── BookNowWidgetData.ts ← Book-now widget helpers
│
└── Reporter/
└── custom-reporter.ts ← Custom Playwright reporterTest Conventions
Importing test
- Tests that need
pageFixturemust import fromOfflineSetup:typescriptimport { offlineTest as test } from '../../OfflineSetup'; import { expect } from '@playwright/test'; - Smoke tests that manage their own fixture may import directly from
@playwright/test.
test.step() — Required for All New Tests
Every logical action in a test must be wrapped in a test.step() for tracing and reporting:
typescript
test('My test', async ({ page, pageFixture }) => {
await test.step('Navigate to page and open widget', async () => {
await pageFixture.visitPage();
await widget.openWidget();
});
await test.step('Set up availability mock', async () => {
await setupAvailability(page, experienceId, widgetId, startTime, 10, 20);
});
await test.step('Assert continue button is enabled', async () => {
const continueButton = pageFixture.selectors.modal.continueButton();
await expect(continueButton).not.toBeDisabled();
});
});Test Data — No Hardcoding
All expected values, strings, slot hours, CSS classes, event names, and other constants must come from e2e/Utilities/TestData/[Feature]TestData.ts:
typescript
// ✅ Correct
import { MESSAGES, SLOT_HOURS, CAPACITY } from '../../Utilities/TestData/TicketBookingTestData';
// ❌ Wrong
const expectedMessage = 'This experience has no availability in the near future.';Selectors — Locator Factory Methods Only
Selector classes in e2e/WidgetPages/Selectors/ return Locator objects only. No actions, no assertions inside selector classes:
typescript
// ✅ Correct — in test file
const card = pageFixture.selectors.experienceCard.card(experienceId);
await expect(card).toBeVisible();
// ❌ Wrong — actions/assertions do not belong in selector classesPrefer getByTestId() (attribute: data-bk-fw-test-id) over CSS selectors or XPath.
Interceptors — Never in Spec Files
All page.route() calls must live in e2e/Utilities/Interceptors/. Never add route handlers inline in spec files:
typescript
// ✅ Correct
await setupAvailability(page, experienceId, widgetId, startTime, available, totalCapacity);
// ❌ Wrong
await page.route('**/availabilities**', route => route.fulfill({ ... }));Web-First Assertions
Always use web-first assertions so Playwright auto-retries:
typescript
// ✅ Correct — auto-retrying
await expect(locator).toBeVisible();
await expect(locator).toHaveText(/Book now/);
// ❌ Wrong — not auto-retrying
expect(await locator.textContent()).toBe('Book now');waitForResponse — Register Before Navigation
Always register waitForResponse promises before the navigation or click that triggers the request:
typescript
// ✅ Correct
const responsePromise = page.waitForResponse(res => res.url().includes('/availabilities'));
await buyTicketButton.click();
await responsePromise;
// ❌ Wrong — race condition
await buyTicketButton.click();
const response = await page.waitForResponse(...); // may already have firedPage Fixtures
Page fixtures own the page lifecycle — URL, widget configuration, navigation, and widget loading.
Every page fixture:
- Extends
BasePageFixturefrome2e/WidgetPages/index.ts - Implements
PageFixture - Defines a
urlproperty - Instantiates
PageWidgetFixtureobjects in its constructor
typescript
// e2e/WidgetPages/PageInDarkMode/index.ts
import { BasePageFixture, PageFixture, PageWidgetFixture } from '../index';
export class DarkModePageFixture extends BasePageFixture implements PageFixture {
public widgets: PageWidgetFixture[];
public url = 'http://localhost:3511/PageInDarkMode/index.html';
constructor(public readonly page: Page) {
super(page);
this.widgets = [new DarkModePageWidget(page)];
}
}Widget Fixtures
Each widget on a page implements PageWidgetFixture — with its own configuration, interceptors, load check, and open method:
typescript
export class DarkModePageWidget implements PageWidgetFixture {
public configuration = {
id: 'abc-dark',
experiences: [
{ data: ScienceWorkshop, tickets: true, vouchers: true },
{ data: ScienceWorkshopKids, tickets: true, vouchers: false },
],
};
constructor(public readonly page: Page) {}
async interceptGetWidgetRequest() { ... }
async interceptExperiencesRequest() { ... }
async widgetToLoad() { await expect(this.page.locator('.bk-button')).toBeVisible(); }
async openWidget() { await this.widgetToLoad(); await this.page.locator('.bk-button').click(); }
}Adding a New Feature to the Test Suite
- Add
data-bk-fw-test-idattributes to the Vue template where needed. - Create a Page Fixture in
e2e/WidgetPages/[PageName]/index.tsextendingBasePageFixture. - Implement
PageWidgetFixturefor each widget on the page. - Add a Selector Class in
e2e/WidgetPages/Selectors/if the feature introduces a new UI area, and compose it intoWidgetSelectors. - Add interceptors in
e2e/Utilities/Interceptors/and response generators ine2e/Utilities/Fakers/if new API endpoints are needed. - Create a
[Feature]TestData.tsfile ine2e/Utilities/TestData/for all expected strings, constants, and configuration values — never hardcode in spec files. - Wire the page fixture — add to
OfflineSetup.tsas an option, or create aplaywright.config.tsproject, or instantiate withnewinbeforeEach. - Write spec files importing
offlineTest as testfromOfflineSetup. Wrap every logical action intest.step().
Running Tests
bash
# Run all E2E tests
npx playwright test
# Run a specific spec file
npx playwright test e2e/Tests/CheckoutFlows/TicketBooking.spec.ts
# Run in UI mode (recommended for local development)
npx playwright test --ui
# Run with headed browser
npx playwright test --headed
# Show the last HTML report
npx playwright show-reportQuick Reference — Right vs Wrong
| ❌ Wrong | ✅ Correct |
|---|---|
import { test } from '@playwright/test' (with pageFixture) | import { offlineTest as test } from '../OfflineSetup' |
expect(await locator.textContent()).toBe('Book now') | await expect(locator).toHaveText(/Book now/) |
await page.waitForTimeout(2000) | await expect(locator).toBeVisible() |
page.route(...) in a spec file | Interceptor function in Utilities/Interceptors/ |
waitForResponse(...) registered after click() | Register promise before the click |
| Inline mock response object | Generator from Utilities/Fakers/V4ResponseGenerator.ts |
Hardcoded string 'Ticket Shop' in test | Constant from Utilities/TestData/[Feature]TestData.ts |
| Duplicated selector logic in spec file | Selector method from WidgetPages/Selectors/ |
No test.step() wrapping | Every logical action wrapped in test.step('...', async () => { ... }) |