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
- ALL selectors must be in Page Objects - Never put selectors in test files
- Page Objects encapsulate page structure and interactions
- Reuse existing Page Objects when possible
- 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 instancedatabase: Database name for this testport: 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. configandlabsparticipate in the per-file environment identity. If either changes, the shared environment is recycled.stripeEnabledalways 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 afterbaseURL,page,pageWithAuthenticatedUser, orghostAccountOwnerhas 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 inbeforeEachbefore 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
nameorplaceholderattributes - 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:
- Fresh Ghost instance with unique database
- Unique port to avoid conflicts
- Pre-authenticated session
- 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