Files
mygit/e2e/.claude/E2E_TEST_WRITING_GUIDE.md
DuckQ1u 93d1b7c3d3
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
first commit
2026-04-22 19:51:20 +07:00

15 KiB

E2E Test Writing Guide

Overview

This guide provides instructions for writing E2E tests in the /e2e/ directory using TypeScript and Playwright. Tests follow the Page Object Model pattern and utilize data factories for test data management.

Environment Setup

Running Tests

# From the e2e directory
cd e2e

# Run all tests
pnpm test

# Run specific test file
pnpm test tests/admin/feature.test.ts

# Run with visible browser (debugging)
pnpm test --debug

# Run with specific timeout
pnpm test --timeout=60000

# Keep environment running after test (useful for Playwright MCP exploration)
PRESERVE_ENV=true pnpm test

# Enable debug logging
DEBUG=@tryghost/e2e:* pnpm test

Test Organization

Directory Structure

e2e/
├── tests/
│   ├── admin/           # Admin panel tests
│   ├── public/          # Public site tests
│   └── [area]/          # Other test areas
├── helpers/
│   ├── pages/           # Page Objects
│   │   ├── admin/       # Admin page objects
│   │   └── public/      # Public page objects
│   └── playwright/      # Test fixtures and setup
└── data-factory/        # Test data generators

Test File Naming

  • Test files: [PageName].test.ts - Named after the page being tested (e.g., PostEditor.test.ts, MembersList.test.ts)
  • Page objects: [Feature]Page.ts (PascalCase)
  • Use descriptive names that clearly indicate what's being tested

Page Object Pattern

Core Principles

  1. ALL selectors must be in Page Objects - Never put selectors in test files
  2. Page Objects encapsulate page structure and interactions
  3. Reuse existing Page Objects when possible
  4. Create focused, single-responsibility Page Objects

Creating a Page Object

// e2e/helpers/pages/admin/FeaturePage.ts
import {Page, Locator} from '@playwright/test';
import {AdminPage} from './AdminPage';

export class FeaturePage extends AdminPage {
    // Define locators as readonly properties
    readonly elementName: Locator;
    readonly buttonName: Locator;
    readonly modalDialog: Locator;

    constructor(page: Page) {
        super(page);
        this.pageUrl = '/ghost/#/[path]';

        // Selector priority (use in this order):
        // 1. data-testid
        this.elementName = page.getByTestId('element-id');

        // 2. ARIA roles with accessible names
        this.buttonName = page.getByRole('button', {name: 'Button Text'});

        // 3. Labels for form elements
        this.elementName = page.getByLabel('Field Label');

        // 4. Text content (for unique text)
        this.elementName = page.getByText('Unique text');

        // 5. Avoid CSS/XPath selectors unless absolutely necessary
    }

    // Action methods
    async performAction(): Promise<void> {
        await this.buttonName.click();
    }

    async fillForm(data: {field1: string; field2: string}): Promise<void> {
        await this.field1Input.fill(data.field1);
        await this.field2Input.fill(data.field2);
    }

    // State verification methods
    async isElementVisible(): Promise<boolean> {
        return await this.elementName.isVisible();
    }

    async getElementText(): Promise<string> {
        return await this.elementName.textContent() || '';
    }

    // Common utility methods (add to AdminPage or BasePage for reuse)
    async pressEscape(): Promise<void> {
        await this.page.keyboard.press('Escape');
    }

    async waitForAutoSave(): Promise<void> {
        await this.page.waitForFunction(() => {
            const status = document.querySelector('[data-test="status"]');
            return status?.textContent?.includes('Saved');
        });
    }
}

Modal/Dialog Pattern

export class FeatureModal {
    private readonly page: Page;
    readonly modal: Locator;
    readonly closeButton: Locator;
    readonly saveButton: Locator;

    constructor(page: Page) {
        this.page = page;
        this.modal = page.getByRole('dialog');
        this.closeButton = this.modal.getByRole('button', {name: 'Close'});
        this.saveButton = this.modal.getByRole('button', {name: 'Save'});
    }

    async waitForVisible(): Promise<void> {
        await this.modal.waitFor({state: 'visible'});
    }

    async waitForHidden(): Promise<void> {
        await this.modal.waitFor({state: 'hidden'});
    }

    async close(): Promise<void> {
        await this.closeButton.click();
        await this.waitForHidden();
    }

    async isVisible(): Promise<boolean> {
        return await this.modal.isVisible();
    }
}

Extending Base Pages

// Admin pages extend AdminPage
export class PostEditorPage extends AdminPage {
    // Implementation
}

// Public pages extend BasePage
export class PublicHomePage extends BasePage {
    // Implementation
}

Writing Tests

Test Structure (AAA Pattern)

Important: Write self-documenting tests without comments. Test names and method names should clearly express intent. If complex logic is needed, extract it to a well-named method in the Page Object.

Tests should follow the Arrange-Act-Assert (AAA) pattern:

  • Arrange: Set up test data and page objects
  • Act: Perform the actions being tested
  • Assert: Verify the expected outcomes

The structure should be visually clear through spacing, not comments:

import {test, expect} from '../../helpers/playwright';
import {FeaturePage} from '../../helpers/pages/admin/FeaturePage';
import {createPostFactory} from '../../data-factory';

test.describe('Feature Name', () => {
    test('should perform expected behavior', async ({page, ghostInstance}) => {
        // Arrange
        const featurePage = new FeaturePage(page);
        const postFactory = createPostFactory(page.request);
        const post = await postFactory.create({title: 'Test Post'});

        // Act
        await featurePage.goto();
        await featurePage.performAction();

        // Assert
        expect(await featurePage.isElementVisible()).toBe(true);
        expect(await featurePage.getResultText()).toContain('Expected text');
    });
});

Test Fixtures

The page fixture provides:

  • Pre-authenticated browser session (logged into Ghost admin)
  • Automatic cleanup after test

The ghostInstance fixture provides:

  • baseUrl: The URL of the Ghost instance
  • database: Database name for this test
  • port: Port number the instance is running on

Additional standalone fixtures exported from helpers/playwright/fixture.ts and re-exported by @/helpers/playwright:

  • resolvedIsolation: 'per-file' | 'per-test'
  • resetEnvironment(): force a full environment recycle in per-file mode before stateful fixtures are resolved
test.beforeEach(async ({resetEnvironment, resolvedIsolation}) => {
    if (resolvedIsolation === 'per-file') {
        await resetEnvironment();
    }
});

Isolation rules:

  • Default is per-file isolation, so the underlying Ghost environment can be reused across tests in the same file.
  • Call usePerTestIsolation() at the root of a file to switch to per-test isolation and force a fresh Ghost environment for each test.
  • Import it from @/helpers/playwright/isolation.
  • config and labs participate in the per-file environment identity. If either changes, the shared environment is recycled.
  • stripeEnabled always forces per-test isolation because Ghost must boot against a per-test fake Stripe server.
  • resetEnvironment() is a hook-only escape hatch. Do not call it after baseURL, page, pageWithAuthenticatedUser, or ghostAccountOwner has already been resolved.
  • Do not treat resetEnvironment() as an in-test cleanup step. If you recycle the environment, you must re-establish any stateful fixtures, and the supported pattern is to call it in beforeEach before those fixtures are created.
  • ESLint catches direct misuse, but the runtime guard in the fixture is the final enforcement.

When to use each option:

  • config: for boot-time Ghost config such as billing URLs or force-upgrade flags.
  • labs: for tests that need specific labs flags on or off.
  • stripeEnabled: for tests that need the fake Stripe server and Stripe-backed Ghost boot config.
  • usePerTestIsolation(): for whole files that mutate shared state heavily and should never reuse a Ghost environment across tests.

Data Factories

Using Data Factories

Data factories provide a clean way to create test data. Import the factory you need and use it to generate data with specific attributes.

import {createPostFactory, createMemberFactory} from '../../data-factory';

test('test with data', async ({page}) => {
    const postFactory = createPostFactory(page.request);
    const memberFactory = createMemberFactory(page);

    const post = await postFactory.create({
        title: 'Test Post',
        content: 'Test content',
        status: 'published'
    });

    const member = await memberFactory.create({
        name: 'Test Member',
        email: 'test@example.com'
    });

    const postEditorPage = new PostEditorPage(page);
    await postEditorPage.gotoExistingPost(post.id);
});

Factory Pattern

Factories are available for various Ghost entities. Check the data-factory directory for available factories. Common examples include:

  • Creating posts with different statuses and content
  • Creating members with subscriptions
  • Creating staff users with specific roles
  • Creating tags, offers, and other entities

New factories are added as needed. When you need test data that doesn't have a factory yet, consider creating one rather than manually constructing the data.

Best Practices

DO's

Use Page Objects for all selectors Write self-documenting tests with clear method and test names Check existing Page Objects before creating new ones Use proper waits (waitForLoadState, waitFor, etc.) Keep tests isolated - Each test gets its own Ghost instance Use descriptive test names that explain what's being tested Extract complex logic to well-named methods in Page Objects Use data factories for complex test data Add meaningful assertions beyond just visibility checks

DON'Ts

Never put selectors in test files Don't write comments - make code self-documenting instead Don't use hardcoded waits (page.waitForTimeout) Don't use networkidle in waits (page.waitForLoadState('networkidle')) - rely on web assertions to assess readiness instead Don't depend on test execution order Don't manually log in - use the pre-authenticated fixture Avoid CSS/XPath selectors - use semantic selectors Don't create test data manually if a factory exists

Common Patterns

Waiting for Elements

// Good - explicit waits
await element.waitFor({state: 'visible'});
await page.waitForSelector('[data-test="element"]');

// Bad - arbitrary timeouts
await page.waitForTimeout(5000); // Avoid this!

Handling Async Operations

// Wait for save to complete
await page.waitForFunction(() => {
    const status = document.querySelector('[data-test="status"]');
    return status?.textContent?.includes('Saved');
});

Working with iframes

// Access iframe content
const iframe = page.locator('iframe[title="preview"]');
const frameContent = iframe.contentFrame();
await frameContent.click('button');

Keyboard Shortcuts

// Press keyboard keys
await page.keyboard.press('Escape');
await page.keyboard.press('Control+S');
await page.keyboard.type('Hello World');

Ghost-Specific Patterns

Common Selectors

  • Navigation: data-test-nav="[section]"
  • Buttons: data-test-button="[action]"
  • Lists: data-test-list="[name]"
  • Modals: [role="dialog"] or .gh-modal
  • Loading states: .gh-loading-spinner

Admin URLs

  • Editor: /ghost/#/editor/post/[id]
  • Posts list: /ghost/#/posts
  • Settings: /ghost/#/settings
  • Members: /ghost/#/members

Common UI Elements

  • Buttons: .gh-btn-[color] (e.g., .gh-btn-primary)
  • Inputs: Often use name or placeholder attributes
  • Status indicators: [data-test="status"]

Using Playwright MCP for Page Object Discovery

When creating new Page Objects or discovering selectors for unfamiliar UI:

1. Start Ghost with Preserved Environment

# Start Ghost and keep it running
PRESERVE_ENV=true pnpm test

# The test will output the Ghost instance URL (usually http://localhost:2369)

2. Use Playwright MCP to Explore

// Navigate to the Ghost instance
mcp__playwright__browser_navigate({url: "http://localhost:2369/ghost"})

// Capture the current DOM structure
mcp__playwright__browser_snapshot()

// Interact with elements to discover selectors
mcp__playwright__browser_click({element: "Button description", ref: "selector-from-snapshot"})

// Take screenshots for reference
mcp__playwright__browser_take_screenshot({filename: "feature-state.png"})

3. Extract Selectors for Page Objects

Based on your exploration, create the Page Object with discovered selectors:

  • Note the element references from snapshots
  • Identify the best selector strategy (testId, role, label, text)
  • Test interactions before finalizing the Page Object

Debugging

Debug Mode

# See browser while test runs
pnpm test --debug

# UI mode for interactive debugging
pnpm test --ui

Debug Logging

# Enable all e2e debug logs
DEBUG=@tryghost/e2e:* pnpm test

# Specific debug namespace
DEBUG=@tryghost/e2e:ghost-fixture pnpm test

Preserve Environment

# Keep containers running after test
PRESERVE_ENV=true pnpm test

Test Artifacts

  • Screenshots on failure: test-results/
  • Playwright traces: test-results/

Test Isolation

Each test automatically gets:

  1. Fresh Ghost instance with unique database
  2. Unique port to avoid conflicts
  3. Pre-authenticated session
  4. Automatic cleanup after test completion

You don't need to worry about:

  • Database cleanup
  • Port conflicts
  • Login/logout
  • Test data pollution

Validation Checklist

Before submitting a test:

  • All selectors are in Page Objects
  • Test follows AAA pattern
  • Test is deterministic (not flaky)
  • Uses proper waits (no arbitrary timeouts)
  • Has meaningful assertions
  • Follows naming conventions
  • Reuses existing Page Objects where possible
  • Test passes locally
  • Test fails for the right reason (if demonstrating a bug)

Quick Reference

Essential Imports

import {test, expect} from '../../helpers/playwright';
import {PageName} from '../../helpers/pages/admin/PageName';
import {createPostFactory} from '../../data-factory';

Test Template

test.describe('Feature', () => {
    test('specific behavior', async ({page, ghostInstance}) => {
        // Arrange
        const pageObject = new PageObject(page);

        // Act
        await pageObject.goto();
        await pageObject.action();

        // Assert
        expect(await pageObject.getState()).toBe(expected);
    });
});

Run Commands

pnpm test                           # All tests
pnpm test path/to/test.ts          # Specific test
pnpm test --debug                   # With browser
pnpm test --grep "pattern"         # Pattern matching
PRESERVE_ENV=true pnpm test         # Keep environment
DEBUG=@tryghost/e2e:* pnpm test     # Debug logs