docs/handbook/engineering/playbooks/e2e-tests.mdx
Our full-stack browser E2E suite uses Playwright to ensure critical user workflows function correctly across the application. The tests are organized using the Page Object Model pattern to maintain clean, reusable, and maintainable test code. This playbook outlines the structure, conventions, and best practices for writing e2e tests.
packages/tests-e2e/
├── scenarios/ # Test files (*.spec.ts)
├── pages/ # Page Object Models
│ ├── base.ts # Base page class
│ ├── index.ts # Page exports
│ ├── authentication.page.ts
│ ├── builder.page.ts
│ ├── flows.page.ts
│ └── agent.page.ts
├── helper/ # Utilities and configuration
│ └── config.ts # Environment configuration
├── playwright.config.ts # Playwright configuration
└── project.json # Nx project configuration
This playbook provides a comprehensive guide for writing e2e tests following the established patterns in your codebase. It covers the Page Object Model structure, test organization, configuration management, and best practices for maintaining reliable e2e tests.
All page objects extend the BasePage class and follow a consistent structure:
export class YourPage extends BasePage {
url = `${configUtils.getConfig().instanceUrl}/your-path`;
getters = {
// Locator functions that return page elements
elementName: (page: Page) => page.getByRole('button', { name: 'Button Text' }),
};
actions = {
// Action functions that perform user interactions
performAction: async (page: Page, params: { param1: string }) => {
// Implementation
},
};
}
// Direct element selection in test files
test('should create flow', async ({ page }) => {
await page.getByRole('button', { name: 'Create Flow' }).click();
await page.getByText('From scratch').click();
// Test logic mixed with element selection
});
// flows.page.ts
export class FlowsPage extends BasePage {
getters = {
createFlowButton: (page: Page) => page.getByRole('button', { name: 'Create Flow' }),
fromScratchButton: (page: Page) => page.getByText('From scratch'),
};
actions = {
newFlowFromScratch: async (page: Page) => {
await this.getters.createFlowButton(page).click();
await this.getters.fromScratchButton(page).click();
},
};
}
// integration.spec.ts
test('should create flow', async ({ page }) => {
await flowsPage.actions.newFlowFromScratch(page);
// Clean test logic focused on behavior
});
Test files should be organized by feature or workflow:
import { test, expect } from '@playwright/test';
import {
AuthenticationPage,
FlowsPage,
BuilderPage
} from '../pages';
import { configUtils } from '../helper/config';
test.describe('Feature Name', () => {
let authenticationPage: AuthenticationPage;
let flowsPage: FlowsPage;
let builderPage: BuilderPage;
test.beforeEach(async () => {
// Initialize page objects
authenticationPage = new AuthenticationPage();
flowsPage = new FlowsPage();
builderPage = new BuilderPage();
});
test('should perform specific workflow', async ({ page }) => {
// Test implementation
});
});
should [action] [expected result]// Good test names
test('should send Slack message via flow', async ({ page }) => {});
test('should handle webhook with dynamic parameters', async ({ page }) => {});
test('should authenticate user with valid credentials', async ({ page }) => {});
// Avoid vague names
test('should work', async ({ page }) => {});
test('test flow', async ({ page }) => {});
Use the centralized config utility to handle different environments:
// helper/config.ts
export const configUtils = {
getConfig: (): Config => {
return process.env.E2E_INSTANCE_URL ? prodConfig : localConfig;
},
};
// Usage in pages
export class AuthenticationPage extends BasePage {
url = `${configUtils.getConfig().instanceUrl}/sign-in`;
}
Required environment variables for CI/CD:
E2E_INSTANCE_URL: Target application URLE2E_EMAIL: Test user emailE2E_PASSWORD: Test user passwordFollow this pattern for comprehensive tests:
test('should complete user workflow', async ({ page }) => {
// 1. Set up test data and timeouts
test.setTimeout(120000);
const config = configUtils.getConfig();
// 2. Authentication (if required)
await authenticationPage.actions.signIn(page, {
email: config.email,
password: config.password
});
// 3. Navigate to relevant page
await flowsPage.actions.navigate(page);
// 4. Clean up existing data (if needed)
await flowsPage.actions.cleanupExistingFlows(page);
// 5. Perform the main workflow
await flowsPage.actions.newFlowFromScratch(page);
await builderPage.actions.waitFor(page);
await builderPage.actions.selectInitialTrigger(page, {
piece: 'Schedule',
trigger: 'Every Hour'
});
// 6. Add assertions and validations
await builderPage.actions.testFlowAndWaitForSuccess(page);
// 7. Clean up (if needed)
await builderPage.actions.exitRun(page);
});
Use appropriate wait strategies instead of fixed timeouts:
// Good - Wait for specific conditions
await page.waitForURL('**/flows/**');
await page.waitForSelector('.react-flow__nodes', { state: 'visible' });
await page.waitForFunction(() => {
const element = document.querySelector('.target-element');
return element && element.textContent?.includes('Expected Text');
}, { timeout: 10000 });
// Avoid - Fixed timeouts
await page.waitForTimeout(5000);
Implement proper error handling and cleanup:
test('should handle errors gracefully', async ({ page }) => {
try {
await flowsPage.actions.navigate(page);
// Test logic
} catch (error) {
// Log error details
console.error('Test failed:', error);
// Take screenshot for debugging
await page.screenshot({ path: 'error-screenshot.png' });
throw error;
} finally {
// Clean up resources
await flowsPage.actions.cleanupExistingFlows(page);
}
});
Prefer semantic selectors over CSS selectors:
// Good - Semantic selectors
getters = {
createButton: (page: Page) => page.getByRole('button', { name: 'Create Flow' }),
emailField: (page: Page) => page.getByPlaceholder('[email protected]'),
searchInput: (page: Page) => page.getByRole('textbox', { name: 'Search' }),
};
// Avoid - Fragile CSS selectors
getters = {
createButton: (page: Page) => page.locator('button.btn-primary'),
emailField: (page: Page) => page.locator('input[type="email"]'),
};
Use dynamic test data to avoid conflicts:
// Good - Dynamic test data
const runVersion = Math.floor(Math.random() * 100000);
const uniqueFlowName = `Test Flow ${Date.now()}`;
// Avoid - Static test data
const flowName = 'Test Flow';
Use meaningful assertions that verify business logic:
// Good - Business logic assertions
await builderPage.actions.testFlowAndWaitForSuccess(page);
const response = await apiRequest.get(urlWithParams);
const body = await response.json();
expect(body.targetRunVersion).toBe(runVersion.toString());
// Avoid - Implementation details
expect(await page.locator('.success-message').isVisible()).toBe(true);
We use Checkly to run and debug E2E tests. Checkly provides video recordings for each test run, making it easy to debug failures.
# Run tests with Checkly (includes video reporting)
npx turbo run test-checkly --filter=tests-e2e
Manual deployment is rarely needed, but you can trigger it with:
npx turbo run deploy-checkly --filter=tests-e2e
When running tests with Checkly, each test execution is recorded and detailed reports are generated. This is the fastest way to debug failures:
For the best local debugging experience, install the Playwright Test for VSCode extension:
Benefits:
await page.pause() to pause execution and inspect the page.console.log() statements to track execution flow.test('should debug workflow', async ({ page }) => {
await page.goto('/flows');
// Pause execution for manual inspection
await page.pause();
// Take screenshot for debugging
await page.screenshot({ path: 'debug-screenshot.png' });
// Continue with test logic
await flowsPage.actions.newFlowFromScratch(page);
});
test('should authenticate user', async ({ page }) => {
const config = configUtils.getConfig();
await authenticationPage.actions.signIn(page, {
email: config.email,
password: config.password
});
await agentPage.actions.waitFor(page);
});
test('should create and test flow', async ({ page }) => {
await flowsPage.actions.navigate(page);
await flowsPage.actions.cleanupExistingFlows(page);
await flowsPage.actions.newFlowFromScratch(page);
await builderPage.actions.waitFor(page);
await builderPage.actions.selectInitialTrigger(page, {
piece: 'Schedule',
trigger: 'Every Hour'
});
await builderPage.actions.testFlowAndWaitForSuccess(page);
});
test('should handle webhook integration', async ({ page }) => {
const apiRequest = await page.context().request;
const response = await apiRequest.get(urlWithParams);
const body = await response.json();
expect(body.targetRunVersion).toBe(expectedValue);
});
When UI changes occur: