Skip to content

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:

LayerLocationPurpose
Spec filese2e/Tests/Contain test() blocks, assertions, and test.step() calls
Page Fixtures & Selectorse2e/WidgetPages/Page lifecycle, widget configuration, and locator factories
Utilitiese2e/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 reporter

Test Conventions

Importing test

  • Tests that need pageFixture must import from OfflineSetup:
    typescript
    import { 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 classes

Prefer 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 fired

Page Fixtures

Page fixtures own the page lifecycle — URL, widget configuration, navigation, and widget loading.

Every page fixture:

  1. Extends BasePageFixture from e2e/WidgetPages/index.ts
  2. Implements PageFixture
  3. Defines a url property
  4. Instantiates PageWidgetFixture objects 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

  1. Add data-bk-fw-test-id attributes to the Vue template where needed.
  2. Create a Page Fixture in e2e/WidgetPages/[PageName]/index.ts extending BasePageFixture.
  3. Implement PageWidgetFixture for each widget on the page.
  4. Add a Selector Class in e2e/WidgetPages/Selectors/ if the feature introduces a new UI area, and compose it into WidgetSelectors.
  5. Add interceptors in e2e/Utilities/Interceptors/ and response generators in e2e/Utilities/Fakers/ if new API endpoints are needed.
  6. Create a [Feature]TestData.ts file in e2e/Utilities/TestData/ for all expected strings, constants, and configuration values — never hardcode in spec files.
  7. Wire the page fixture — add to OfflineSetup.ts as an option, or create a playwright.config.ts project, or instantiate with new in beforeEach.
  8. Write spec files importing offlineTest as test from OfflineSetup. Wrap every logical action in test.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-report

Quick 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 fileInterceptor function in Utilities/Interceptors/
waitForResponse(...) registered after click()Register promise before the click
Inline mock response objectGenerator from Utilities/Fakers/V4ResponseGenerator.ts
Hardcoded string 'Ticket Shop' in testConstant from Utilities/TestData/[Feature]TestData.ts
Duplicated selector logic in spec fileSelector method from WidgetPages/Selectors/
No test.step() wrappingEvery logical action wrapped in test.step('...', async () => { ... })