
E2e Testing
- 1.6k installs
- 8 repo stars
- Updated February 6, 2026
- hieutrtr/ai1-skills
e2e-testing is an agent skill for -
About
The e2e-testing skill - It covers writing E2E tests for complete user workflows login, CRUD operations, multi-page flows. Key workflows include creating critical path regression tests that validate the full stack. Activate this skill when: - Writing E2E tests for complete user workflows login, CRUD operations, multi-page flows - Creating critical path regression tests that validate the full stack - Testing cross-browser compatibility Chromium, Firefox, WebKit - Validating authentication flows end-to-end - Testing file upload/download workflows - Writing smoke tests for deployment verification Do NOT use thi Developers invoke e2e-testing when the task matches the triggers and reference files in SKILL.md for grounded, stepwise execution. Reference files and progressive disclosure keep context focused while preserving concrete commands, configuration fields, and validation checks copied from the upstream documentation. Reference files and progressive disclosure keep context focused while preserving concrete commands, configuration fields, and validation checks copied from the upstream documentation.
- Writing E2E tests for complete user workflows login, CRUD operations, multi-page flows
- Creating critical path regression tests that validate the full stack
- Testing cross-browser compatibility Chromium, Firefox, WebKit
- Validating authentication flows end-to-end
- Testing file upload/download workflows
E2e Testing by the numbers
- 1,629 all-time installs (skills.sh)
- +3 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #459 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
e2e-testing capabilities & compatibility
- Capabilities
- writing e2e tests for complete user workflows lo · creating critical path regression tests that val · testing cross browser compatibility chromium, fi · validating authentication flows end to end · testing file upload/download workflows
- Use cases
- testing · debugging · documentation
What e2e-testing says it does
End-to-end testing patterns with Playwright for full-stack Python/React applications.
npx skills add https://github.com/hieutrtr/ai1-skills --skill e2e-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.6k |
|---|---|
| repo stars | ★ 8 |
| Security audit | 3 / 3 scanners passed |
| Last updated | February 6, 2026 |
| Repository | hieutrtr/ai1-skills ↗ |
What problem does e2e-testing solve for developers using the documented workflows?
-
Who is it for?
Developers working with e2e-testing patterns described in the skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill documented scope.
When should I use this skill?
Use when -
What you get
Actionable e2e-testing guidance grounded in SKILL.md workflows and reference files.
- Playwright spec files
- Page object modules
- API test helper utilities
Files
E2E Testing
When to Use
Activate this skill when:
- Writing E2E tests for complete user workflows (login, CRUD operations, multi-page flows)
- Creating critical path regression tests that validate the full stack
- Testing cross-browser compatibility (Chromium, Firefox, WebKit)
- Validating authentication flows end-to-end
- Testing file upload/download workflows
- Writing smoke tests for deployment verification
Do NOT use this skill for:
- React component unit tests (use
react-testing-patterns) - Python backend unit/integration tests (use
pytest-patterns) - TDD workflow enforcement (use
tdd-workflow) - API contract testing without a browser (use
pytest-patternswith httpx)
Instructions
Test Structure
e2e/
├── playwright.config.ts # Global Playwright configuration
├── fixtures/
│ ├── auth.fixture.ts # Authentication state setup
│ └── test-data.fixture.ts # Test data creation/cleanup
├── pages/
│ ├── base.page.ts # Base page object with shared methods
│ ├── login.page.ts # Login page object
│ ├── users.page.ts # Users list page object
│ └── user-detail.page.ts # User detail page object
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ ├── users/
│ │ ├── create-user.spec.ts
│ │ ├── edit-user.spec.ts
│ │ └── list-users.spec.ts
│ └── smoke/
│ └── critical-paths.spec.ts
└── utils/
├── api-helpers.ts # Direct API calls for test setup
└── test-constants.ts # Shared constantsNaming conventions:
- Test files:
<feature>.spec.ts - Page objects:
<page-name>.page.ts - Fixtures:
<concern>.fixture.ts - Test names: human-readable sentences describing the user action and expected outcome
Page Object Model
Every page gets a page object class that encapsulates selectors and actions. Tests never interact with selectors directly.
Base page object:
// e2e/pages/base.page.ts
import { type Page, type Locator } from "@playwright/test";
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** Navigate to the page's URL. */
abstract goto(): Promise<void>;
/** Wait for the page to be fully loaded. */
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
/** Get a toast/notification message. */
get toast(): Locator {
return this.page.getByRole("alert");
}
/** Get the page heading. */
get heading(): Locator {
return this.page.getByRole("heading", { level: 1 });
}
}Concrete page object:
// e2e/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test";
import { BasePage } from "./base.page";
export class UsersPage extends BasePage {
// ─── Locators ─────────────────────────────────────────
readonly createButton: Locator;
readonly searchInput: Locator;
readonly userTable: Locator;
constructor(page: Page) {
super(page);
this.createButton = page.getByTestId("create-user-btn");
this.searchInput = page.getByRole("searchbox", { name: /search users/i });
this.userTable = page.getByRole("table");
}
// ─── Actions ──────────────────────────────────────────
async goto(): Promise<void> {
await this.page.goto("/users");
await this.waitForLoad();
}
async searchFor(query: string): Promise<void> {
await this.searchInput.fill(query);
// Wait for search results to update (debounced)
await this.page.waitForResponse("**/api/v1/users?*");
}
async clickCreateUser(): Promise<void> {
await this.createButton.click();
}
async getUserRow(email: string): Promise<Locator> {
return this.userTable.getByRole("row").filter({ hasText: email });
}
async getUserCount(): Promise<number> {
// Subtract 1 for header row
return (await this.userTable.getByRole("row").count()) - 1;
}
}Rules for page objects:
- One page object per page or major UI section
- Locators are public readonly properties
- Actions are async methods
- Page objects never contain assertions -- tests assert
- Page objects handle waits internally after actions
Selector Strategy
Priority order (highest to lowest):
| Priority | Selector | Example | When to Use |
|---|---|---|---|
| 1 | data-testid | getByTestId("submit-btn") | Interactive elements, dynamic content |
| 2 | Role | getByRole("button", { name: /save/i }) | Buttons, links, headings, inputs |
| 3 | Label | getByLabel("Email") | Form inputs with labels |
| 4 | Placeholder | getByPlaceholder("Search...") | Search inputs |
| 5 | Text | getByText("Welcome back") | Static text content |
NEVER use:
- CSS selectors (
.class-name,#id) -- brittle, break on styling changes - XPath (
//div[@class="foo"]) -- unreadable, extremely brittle - DOM structure selectors (
div > span:nth-child(2)) -- break on layout changes
Adding data-testid attributes:
// In React components -- add data-testid to interactive elements
<button data-testid="create-user-btn" onClick={handleCreate}>
Create User
</button>
// Convention: kebab-case, descriptive
// Pattern: <action>-<entity>-<element-type>
// Examples: create-user-btn, user-email-input, delete-confirm-dialogWait Strategies
NEVER use hardcoded waits:
// BAD: Hardcoded wait -- flaky, slow
await page.waitForTimeout(3000);
// BAD: Sleep
await new Promise((resolve) => setTimeout(resolve, 2000));Use explicit wait conditions:
// GOOD: Wait for a specific element to appear
await page.getByRole("heading", { name: "Dashboard" }).waitFor();
// GOOD: Wait for navigation
await page.waitForURL("/dashboard");
// GOOD: Wait for API response
await page.waitForResponse(
(response) =>
response.url().includes("/api/v1/users") && response.status() === 200,
);
// GOOD: Wait for network to settle
await page.waitForLoadState("networkidle");
// GOOD: Wait for element state
await page.getByTestId("submit-btn").waitFor({ state: "visible" });
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });Auto-waiting: Playwright auto-waits for elements to be actionable before clicking, filling, etc. Explicit waits are needed only for assertions or complex state transitions.
Auth State Reuse
Avoid logging in before every test. Save auth state and reuse it.
Setup auth state once:
// e2e/fixtures/auth.fixture.ts
import { test as base } from "@playwright/test";
import path from "path";
const AUTH_STATE_PATH = path.resolve("e2e/.auth/user.json");
export const setup = base.extend({});
setup("authenticate", async ({ page }) => {
// Perform real login
await page.goto("/login");
await page.getByLabel("Email").fill("testuser@example.com");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for auth to complete
await page.waitForURL("/dashboard");
// Save signed-in state
await page.context().storageState({ path: AUTH_STATE_PATH });
});Reuse in tests:
// playwright.config.ts
export default defineConfig({
projects: [
// Setup project runs first and saves auth state
{ name: "setup", testDir: "./e2e/fixtures", testMatch: "auth.fixture.ts" },
{
name: "chromium",
use: {
storageState: "e2e/.auth/user.json", // Reuse auth state
},
dependencies: ["setup"],
},
],
});Test Data Management
Principles:
- Tests create their own data (never depend on pre-existing data)
- Tests clean up after themselves (or use API to reset)
- Use API calls for setup, not UI interactions (faster, more reliable)
API helpers for test data:
// e2e/utils/api-helpers.ts
import { type APIRequestContext } from "@playwright/test";
export class TestDataAPI {
constructor(private request: APIRequestContext) {}
async createUser(data: { email: string; displayName: string }) {
const response = await this.request.post("/api/v1/users", { data });
return response.json();
}
async deleteUser(userId: number) {
await this.request.delete(`/api/v1/users/${userId}`);
}
async createOrder(userId: number, items: Array<Record<string, unknown>>) {
const response = await this.request.post("/api/v1/orders", {
data: { user_id: userId, items },
});
return response.json();
}
}Usage in tests:
test("edit user name", async ({ page, request }) => {
const api = new TestDataAPI(request);
// Setup: create user via API (fast)
const user = await api.createUser({
email: "edit-test@example.com",
displayName: "Before Edit",
});
try {
// Test: edit via UI
const usersPage = new UsersPage(page);
await usersPage.goto();
// ... perform edit via UI ...
} finally {
// Cleanup: remove test data
await api.deleteUser(user.id);
}
});Debugging Flaky Tests
1. Use trace viewer for failures:
// playwright.config.ts
use: {
trace: "on-first-retry", // Capture trace only on retry
}View trace: npx playwright show-trace trace.zip
2. Run in headed mode for debugging:
npx playwright test --headed --debug tests/users/create-user.spec.ts3. Common causes of flaky tests:
| Cause | Fix |
|---|---|
| Hardcoded waits | Use explicit wait conditions |
| Shared test data | Each test creates its own data |
| Animation interference | Set animations: "disabled" in config |
| Race conditions | Wait for API responses before assertions |
| Viewport-dependent behavior | Set explicit viewport in config |
| Session leaks between tests | Use storageState correctly, clear cookies |
4. Retry strategy:
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // Retry in CI only
});CI Configuration
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start application
run: |
docker compose up -d
npx wait-on http://localhost:3000 --timeout 60000
- name: Run E2E tests
run: npx playwright test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Upload traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-traces
path: test-results/Use scripts/run-e2e-with-report.sh to run Playwright with HTML report output locally.
Examples
See references/page-object-template.ts for annotated page object class. See references/e2e-test-template.ts for annotated E2E test. See references/playwright-config-example.ts for production Playwright config.
/**
* e2e-test-template.ts — Annotated E2E test using Playwright.
*
* Place at: e2e/tests/users/create-user.spec.ts
*
* This template demonstrates:
* - Using page objects (tests never touch selectors directly)
* - API-based test data setup (fast) and cleanup (reliable)
* - Explicit waits (never hardcoded timeouts)
* - Testing success paths, error paths, and edge cases
* - Auth state reuse via storageState
*/
import { test, expect } from "@playwright/test";
import { UsersPage, CreateUserDialog } from "../pages/users.page";
import { TestDataAPI } from "../utils/api-helpers";
// ─── Test Suite: Create User ────────────────────────────────────────────────────
test.describe("Create User", () => {
let usersPage: UsersPage;
let api: TestDataAPI;
test.beforeEach(async ({ page, request }) => {
// Initialize page objects and API helpers
usersPage = new UsersPage(page);
api = new TestDataAPI(request);
// Navigate to the users page (auth state is pre-loaded via storageState)
await usersPage.goto();
});
// ─── Success Path ───────────────────────────────────
test("creates a new user with valid data", async ({ page }) => {
// Arrange: open the create dialog
await usersPage.clickCreateUser();
const dialog = new CreateUserDialog(page);
// Act: fill and submit the form
await dialog.fillForm({
email: `e2e-create-${Date.now()}@example.com`,
displayName: "E2E Test User",
role: "member",
});
await dialog.submit();
// Assert: success toast appears
await expect(usersPage.toast).toContainText(/created successfully/i);
// Assert: user appears in the list
await expect(
usersPage.getUserRow(`e2e-create-${Date.now()}@example.com`),
).toBeVisible();
});
// ─── Validation Error Path ──────────────────────────
test("shows validation error for invalid email", async ({ page }) => {
await usersPage.clickCreateUser();
const dialog = new CreateUserDialog(page);
// Act: submit with invalid email
await dialog.fillForm({
email: "not-an-email",
displayName: "Invalid Email User",
});
await dialog.submitButton.click();
// Assert: validation error appears (form is NOT submitted)
const errorMessage = page.getByText(/invalid email/i);
await expect(errorMessage).toBeVisible();
});
// ─── Duplicate Error Path ──────────────────────────
test("shows error when creating user with duplicate email", async ({
page,
request,
}) => {
const duplicateEmail = `e2e-dup-${Date.now()}@example.com`;
// Arrange: create a user via API first (fast setup)
const existingUser = await api.createUser({
email: duplicateEmail,
displayName: "Existing User",
});
try {
// Act: try to create another user with the same email via UI
await usersPage.clickCreateUser();
const dialog = new CreateUserDialog(page);
await dialog.fillForm({
email: duplicateEmail,
displayName: "Duplicate User",
});
await dialog.submitButton.click();
// Assert: error toast with conflict message
await expect(usersPage.toast).toContainText(/already exists/i);
} finally {
// Cleanup: remove the test user
await api.deleteUser(existingUser.id);
}
});
// ─── Cancel Flow ────────────────────────────────────
test("cancelling the dialog does not create a user", async ({ page }) => {
const countBefore = await usersPage.getUserCount();
// Act: open dialog, fill form, then cancel
await usersPage.clickCreateUser();
const dialog = new CreateUserDialog(page);
await dialog.fillForm({
email: "should-not-exist@example.com",
displayName: "Cancelled User",
});
await dialog.cancel();
// Assert: user count unchanged
const countAfter = await usersPage.getUserCount();
expect(countAfter).toBe(countBefore);
});
});
// ─── Test Suite: User List ──────────────────────────────────────────────────────
test.describe("User List", () => {
let usersPage: UsersPage;
let api: TestDataAPI;
const testUsers: Array<{ id: number }> = [];
test.beforeAll(async ({ request }) => {
// Create test data via API (runs once for the suite)
api = new TestDataAPI(request);
for (let i = 0; i < 3; i++) {
const user = await api.createUser({
email: `e2e-list-${i}-${Date.now()}@example.com`,
displayName: `List User ${i}`,
});
testUsers.push(user);
}
});
test.afterAll(async () => {
// Cleanup all test users
for (const user of testUsers) {
await api.deleteUser(user.id);
}
});
test.beforeEach(async ({ page, request }) => {
usersPage = new UsersPage(page);
api = new TestDataAPI(request);
await usersPage.goto();
});
test("displays users in a table", async () => {
const count = await usersPage.getUserCount();
expect(count).toBeGreaterThanOrEqual(3);
});
test("filters users by search query", async () => {
await usersPage.searchFor("List User 0");
// Verify filtered results
const count = await usersPage.getUserCount();
expect(count).toBeGreaterThanOrEqual(1);
});
test("shows empty state for no results", async () => {
await usersPage.searchFor("nonexistent-user-xyz-999");
await expect(usersPage.emptyState).toBeVisible();
});
});
// ─── Test Suite: User Edit (example with navigation) ─────────────────────────
test.describe("Edit User", () => {
let usersPage: UsersPage;
let api: TestDataAPI;
let testUser: { id: number; email: string };
test.beforeEach(async ({ page, request }) => {
api = new TestDataAPI(request);
usersPage = new UsersPage(page);
// Create a fresh user for each edit test
testUser = await api.createUser({
email: `e2e-edit-${Date.now()}@example.com`,
displayName: "Before Edit",
});
await usersPage.goto();
});
test.afterEach(async () => {
// Cleanup
await api.deleteUser(testUser.id);
});
test("edits user display name", async ({ page }) => {
// Act: click edit on the user row
await usersPage.clickEditUser(testUser.email);
// Wait for edit page/dialog to load
await page.waitForURL(`**/users/${testUser.id}/edit`);
// Fill in new name
const nameInput = page.getByLabel("Display name");
await nameInput.clear();
await nameInput.fill("After Edit");
// Submit
await page.getByRole("button", { name: /save/i }).click();
// Assert: redirect back to list with success message
await page.waitForURL("**/users");
await expect(page.getByRole("alert")).toContainText(/updated/i);
});
});
/**
* page-object-template.ts — Annotated page object class for Playwright E2E tests.
*
* Place at: e2e/pages/users.page.ts
*
* This template demonstrates:
* - Extending a base page object for shared behavior
* - Declaring locators as public readonly properties
* - Encapsulating user actions as async methods
* - Internal wait handling (page objects handle waits, tests handle assertions)
* - Composing page objects for forms and dialogs
*/
import { type Page, type Locator, expect } from "@playwright/test";
// ─── Base Page Object ───────────────────────────────────────────────────────────
/**
* Abstract base class for all page objects.
* Provides shared navigation and utility methods.
*/
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** Navigate to the page's URL. Subclasses must implement. */
abstract goto(): Promise<void>;
/** Wait for the page to be fully loaded (network idle). */
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
/** Get toast/notification messages (role="alert"). */
get toast(): Locator {
return this.page.getByRole("alert");
}
/** Get the primary page heading (h1). */
get heading(): Locator {
return this.page.getByRole("heading", { level: 1 });
}
/** Check if a loading spinner is visible. */
async isLoading(): Promise<boolean> {
return this.page.getByTestId("loading-spinner").isVisible();
}
}
// ─── Users List Page ────────────────────────────────────────────────────────────
/**
* Page object for the Users list page (/users).
*
* Usage in tests:
* const usersPage = new UsersPage(page);
* await usersPage.goto();
* await usersPage.searchFor("alice");
* const count = await usersPage.getUserCount();
*/
export class UsersPage extends BasePage {
// ─── Locators (public, readonly) ──────────────────────
// Use data-testid for interactive elements, role for semantic elements
readonly createButton: Locator;
readonly searchInput: Locator;
readonly userTable: Locator;
readonly emptyState: Locator;
readonly paginationNext: Locator;
readonly paginationPrev: Locator;
constructor(page: Page) {
super(page);
this.createButton = page.getByTestId("create-user-btn");
this.searchInput = page.getByRole("searchbox", { name: /search users/i });
this.userTable = page.getByRole("table");
this.emptyState = page.getByTestId("empty-state");
this.paginationNext = page.getByRole("button", { name: /next/i });
this.paginationPrev = page.getByRole("button", { name: /previous/i });
}
// ─── Navigation ──────────────────────────────────────
async goto(): Promise<void> {
await this.page.goto("/users");
await this.waitForLoad();
}
// ─── Actions ─────────────────────────────────────────
/** Type a search query and wait for the results API response. */
async searchFor(query: string): Promise<void> {
await this.searchInput.fill(query);
// Wait for the debounced API call to complete
await this.page.waitForResponse(
(res) => res.url().includes("/api/v1/users") && res.status() === 200,
);
}
/** Click the "Create User" button. */
async clickCreateUser(): Promise<void> {
await this.createButton.click();
}
/** Get a specific user's row by email. */
getUserRow(email: string): Locator {
return this.userTable.getByRole("row").filter({ hasText: email });
}
/** Click the edit button for a specific user row. */
async clickEditUser(email: string): Promise<void> {
const row = this.getUserRow(email);
await row.getByRole("button", { name: /edit/i }).click();
}
/** Click the delete button for a specific user row. */
async clickDeleteUser(email: string): Promise<void> {
const row = this.getUserRow(email);
await row.getByRole("button", { name: /delete/i }).click();
}
/** Get the total count of user rows (excluding header). */
async getUserCount(): Promise<number> {
const rows = this.userTable.getByRole("row");
const count = await rows.count();
return Math.max(0, count - 1); // Subtract header row
}
/** Navigate to the next page. */
async goToNextPage(): Promise<void> {
await this.paginationNext.click();
await this.waitForLoad();
}
}
// ─── Create User Dialog ─────────────────────────────────────────────────────────
/**
* Page object for the Create User dialog/form.
*
* Usage in tests:
* const dialog = new CreateUserDialog(page);
* await dialog.fillForm({ email: "test@example.com", displayName: "Test" });
* await dialog.submit();
*/
export class CreateUserDialog {
readonly dialog: Locator;
readonly emailInput: Locator;
readonly displayNameInput: Locator;
readonly roleSelect: Locator;
readonly submitButton: Locator;
readonly cancelButton: Locator;
constructor(private readonly page: Page) {
this.dialog = page.getByRole("dialog", { name: /create user/i });
this.emailInput = this.dialog.getByLabel("Email");
this.displayNameInput = this.dialog.getByLabel("Display name");
this.roleSelect = this.dialog.getByLabel("Role");
this.submitButton = this.dialog.getByRole("button", { name: /create/i });
this.cancelButton = this.dialog.getByRole("button", { name: /cancel/i });
}
/** Fill in the create user form. */
async fillForm(data: {
email: string;
displayName: string;
role?: string;
}): Promise<void> {
await this.emailInput.fill(data.email);
await this.displayNameInput.fill(data.displayName);
if (data.role) {
await this.roleSelect.selectOption(data.role);
}
}
/** Submit the form and wait for the API response. */
async submit(): Promise<void> {
await this.submitButton.click();
await this.page.waitForResponse(
(res) => res.url().includes("/api/v1/users") && res.status() === 201,
);
}
/** Cancel and close the dialog. */
async cancel(): Promise<void> {
await this.cancelButton.click();
await this.dialog.waitFor({ state: "hidden" });
}
}
/**
* playwright-config-example.ts — Production Playwright configuration.
*
* Place at: e2e/playwright.config.ts (or project root playwright.config.ts)
*
* This config provides:
* - Auth state reuse via a setup project
* - Multi-browser testing (Chromium, Firefox, WebKit)
* - CI-aware settings (retries, workers, traces)
* - Automatic dev server startup
* - Sensible timeouts and viewport defaults
*/
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables.
* See https://playwright.dev/docs/test-configuration
*/
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const CI = !!process.env.CI;
export default defineConfig({
// ─── Test Discovery ───────────────────────────────────
testDir: "./tests",
testMatch: "**/*.spec.ts",
// ─── Execution ────────────────────────────────────────
fullyParallel: true,
forbidOnly: CI, // Fail in CI if test.only is left in code
retries: CI ? 2 : 0, // Retry failed tests in CI only
workers: CI ? 2 : undefined, // Limit workers in CI for stability
// ─── Timeouts ─────────────────────────────────────────
timeout: 30_000, // Per-test timeout (30 seconds)
expect: {
timeout: 10_000, // Per-assertion timeout (10 seconds)
},
// ─── Reporting ────────────────────────────────────────
reporter: [
["list"], // Console output during runs
["html", { open: CI ? "never" : "on-failure" }], // HTML report
...(CI ? [["junit", { outputFile: "test-results/junit.xml" }] as const] : []),
],
// ─── Shared Settings ─────────────────────────────────
use: {
baseURL: BASE_URL,
// Traces: capture on first retry for debugging flaky tests
trace: "on-first-retry",
// Screenshots: capture on failure only
screenshot: "only-on-failure",
// Video: capture on first retry
video: "on-first-retry",
// Viewport
viewport: { width: 1280, height: 720 },
// Disable animations for faster, more reliable tests
// (CSS animations and transitions are skipped)
// Note: Uncomment the line below to disable animations
// actionTimeout: 10_000,
// Ignore HTTPS errors in dev/staging
ignoreHTTPSErrors: !CI,
// Locale and timezone (consistent across environments)
locale: "en-US",
timezoneId: "America/New_York",
},
// ─── Projects ─────────────────────────────────────────
projects: [
// --- Setup: authenticate once, save state --------
{
name: "setup",
testDir: "./fixtures",
testMatch: "auth.fixture.ts",
},
// --- Chromium (primary) --------------------------
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
// --- Firefox -------------------------------------
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
// --- WebKit (Safari) -----------------------------
{
name: "webkit",
use: {
...devices["Desktop Safari"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
// --- Mobile Chrome -------------------------------
{
name: "mobile-chrome",
use: {
...devices["Pixel 7"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
// --- Mobile Safari -------------------------------
{
name: "mobile-safari",
use: {
...devices["iPhone 14"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
],
// ─── Dev Server ───────────────────────────────────────
// Automatically start the app before running tests (local dev only).
// In CI, the app should already be running (via docker compose or a prior step).
webServer: CI
? undefined
: {
command: "npm run dev",
url: BASE_URL,
reuseExistingServer: true,
timeout: 120_000, // 2 minutes to start
stdout: "pipe",
stderr: "pipe",
},
});
#!/usr/bin/env bash
# run-e2e-with-report.sh — Run Playwright E2E tests and generate an HTML report.
#
# Usage:
# ./run-e2e-with-report.sh [--output-dir <dir>] [--project <name>] [--headed]
#
# Options:
# --output-dir <dir> Directory to write reports (default: ./e2e-results)
# --project <name> Playwright project to run (default: all)
# --headed Run in headed mode for debugging
set -euo pipefail
# ─── Defaults ───────────────────────────────────────────────────────────────────
OUTPUT_DIR="./e2e-results"
PROJECT=""
HEADED=""
# ─── Parse arguments ────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--output-dir)
OUTPUT_DIR="$2"
shift 2
;;
--project)
PROJECT="--project $2"
shift 2
;;
--headed)
HEADED="--headed"
shift
;;
-h|--help)
echo "Usage: $0 [--output-dir <dir>] [--project <name>] [--headed]"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ─── Setup ───────────────────────────────────────────────────────────────────────
mkdir -p "$OUTPUT_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RESULTS_FILE="$OUTPUT_DIR/e2e-summary-${TIMESTAMP}.txt"
REPORT_DIR="$OUTPUT_DIR/html-report-${TIMESTAMP}"
echo "=== E2E Test Run ==="
echo "Output directory: ${OUTPUT_DIR}"
echo "Report directory: ${REPORT_DIR}"
echo ""
# ─── Verify Playwright is installed ──────────────────────────────────────────────
if ! npx playwright --version >/dev/null 2>&1; then
echo "ERROR: Playwright is not installed. Run: npm install @playwright/test" >&2
exit 1
fi
# ─── Run Playwright tests ───────────────────────────────────────────────────────
EXIT_CODE=0
npx playwright test \
${PROJECT} \
${HEADED} \
--reporter=html \
--output="$OUTPUT_DIR/test-results" \
2>&1 | tee "$RESULTS_FILE" || EXIT_CODE=$?
# ─── Move HTML report to output directory ────────────────────────────────────────
if [[ -d "playwright-report" ]]; then
mv playwright-report "$REPORT_DIR"
echo "HTML report: ${REPORT_DIR}/index.html"
fi
# ─── Write summary ──────────────────────────────────────────────────────────────
echo "" >> "$RESULTS_FILE"
echo "Timestamp: $(date -Iseconds)" >> "$RESULTS_FILE"
echo "Report: ${REPORT_DIR}/index.html" >> "$RESULTS_FILE"
if [[ $EXIT_CODE -eq 0 ]]; then
echo ""
echo "PASS: All E2E tests passed."
echo "Status: PASS" >> "$RESULTS_FILE"
else
echo ""
echo "FAIL: Some E2E tests failed. Review report at: ${REPORT_DIR}/index.html"
echo "Status: FAIL" >> "$RESULTS_FILE"
fi
# ─── Open report (local only) ───────────────────────────────────────────────────
if [[ -z "${CI:-}" && $EXIT_CODE -ne 0 && -d "$REPORT_DIR" ]]; then
echo "Opening report in browser..."
npx playwright show-report "$REPORT_DIR" || true
fi
exit $EXIT_CODE
Related skills
Forks & variants (1)
E2e Testing has 1 known copy in the catalog totaling 71 installs. They canonicalize to this original listing.
- hairyf - 71 installs
How it compares
Choose this for opinionated Playwright page-object and API-fixture templates instead of bare Playwright codegen snippets.
FAQ
Who is e2e-testing for?
Developers and software engineers working with e2e-testing patterns described in the skill documentation.
When should I use e2e-testing?
When -.
Is e2e-testing safe to install?
Review the Security Audits panel on this page before installing in production.