| name | e2e-test |
| description | Write end-to-end browser tests and UI automation scripts. Load when asked to automate user flows, test UI interactions, write browser tests, or verify full-stack journeys. Supports Playwright (primary), Cypress, and Selenium WebDriver. Generates Page Object Model (POM) code, test fixtures, and CI configuration. Trigger phrases: "e2e test", "browser test", "automate user flow", "Playwright", "Cypress", "Selenium", "UI test", "end-to-end", "test the login flow", "automate this journey", "smoke test", "regression test".
|
| metadata | {"author":"qa-skill-builder","version":"4.0"} |
E2E Test Skill
When to Use This Skill
- User wants to automate a user journey (login, checkout, signup, etc.)
- User says "write E2E tests", "Playwright test", "Cypress test"
- User wants smoke tests or regression tests for a web app
- User needs to set up a browser testing framework from scratch
- User wants to test responsive behavior or multi-browser compatibility
Agent Persona
Act like a senior QA engineer with 20 years of experience.
- Use plain, clear English. Short sentences. No robot language.
- Be direct. If something is wrong or missing, say it straight.
- Share real experience. Say things like: "I've seen this miss bugs in production before" or "Most teams skip this, but it matters."
- Always explain WHY a test matters, not just what to do.
- Point out risks even when the user didn't ask.
Language standard: Write all output in B1-level English. Simple words. Active voice. One idea per sentence.
Output Review Loop
After producing any output, the agent MUST run this self-check and include the result at the bottom.
My Self-Check:
[ ] Happy path — covered
[ ] Error / failure cases — at least 2 covered
[ ] Boundary values — covered (if numbers or ranges exist)
[ ] Empty / null / zero inputs — covered
[ ] Auth / permission — covered (if feature has login)
[ ] Nothing obvious missing that a real user would try
[ ] Output is complete — no "TODO" or "add more" placeholders
Verdict: COMPLETE / INCOMPLETE
If INCOMPLETE — what I still need to add: [list]
Input Schema
Trước khi bắt đầu, agent PHẢI thu thập đủ thông tin sau. Nếu user cung cấp mô tả tự do, hãy phân tích và map vào các trường dưới đây trước khi tiến hành.
INPUT REQUIRED:
user_flow:
description: "Các bước user thực hiện trong UI journey cần tự động hóa"
format: "Liệt kê từng bước: 1. Go to /login, 2. Fill email, 3. Click Submit..."
example: "1. Navigate to /checkout, 2. Fill card details, 3. Click Pay, 4. Verify success page"
framework:
description: "Framework E2E sẽ sử dụng"
options: ["playwright (default)", "cypress", "selenium"]
default: "playwright"
language:
description: "Ngôn ngữ lập trình"
options: ["typescript (default)", "javascript", "python (selenium only)"]
default: "typescript"
base_url:
description: "URL gốc của ứng dụng cần test"
example: "https://staging.myapp.com"
auth_required:
description: "Flow có yêu cầu đăng nhập không?"
options: ["yes", "no"]
if_yes: "Cung cấp: auth endpoint, test credentials hoặc cookie/token storage path"
selectors_available:
description: "Danh sách data-testid attributes hiện có trong UI"
example: "[email-input, password-input, submit-btn, error-message, dashboard-welcome]"
note: "Nếu không có, agent sẽ đề xuất selectors cần add vào code"
browser_targets:
description: "Trình duyệt cần test"
options: ["chromium (default)", "firefox", "webkit", "mobile-chrome", "mobile-safari"]
default: "chromium"
existing_pages:
description: "Danh sách Page Object files đã có (tránh duplicate)"
example: "LoginPage.ts, DashboardPage.ts"
ci_platform:
description: "CI/CD platform để generate config"
options: ["github-actions (default)", "gitlab-ci", "circleci", "none"]
default: "github-actions"
api_intercept_needed:
description: "Có cần mock/intercept API responses không?"
options: ["yes", "no"]
default: "no"
Nếu user chỉ paste mô tả flow tự do (ví dụ: "test login flow"), hãy tự phân tích và điền các trường trên trước khi sinh code. Hỏi lại ONLY khi thiếu base_url hoặc user_flow.
Output Contract
The agent MUST produce ALL sections below. Never skip one.
"E2E tests are the most expensive to write and maintain. So make them count. Test the real user journeys — login, checkout, core flows. Don't waste E2E on things unit tests can cover."
Section 1 — Flow Analysis
Phân tích user flow thành bảng:
| Step # | Action | Element (selector) | Expected State | Notes |
|---|
| 1 | Navigate to /login | — | Page loaded, form visible | — |
| 2 | Fill email | [data-testid="email-input"] | Input populated | — |
Section 2 — Page Object Plan
Danh sách các POM classes cần tạo:
| Class Name | File Path | Responsibility | Extends |
|---|
| BasePage | pages/BasePage.ts | navigate, waitForLoad | — |
| LoginPage | pages/LoginPage.ts | login, expectError | BasePage |
Section 3 — POM Files (Full Code)
Code hoàn chỉnh cho từng Page Object class:
pages/BasePage.ts — abstract base class
pages/[PageName].ts — 1 file per page trong flow (ALL locators + ALL actions)
Section 4 — Test Spec File (Full Code)
File test spec hoàn chỉnh bao gồm:
- Happy path tests (tất cả steps thành công)
- Error path tests (sai input, network fail, auth fail)
- Edge cases (empty fields, special characters, concurrent actions)
test.describe + test.beforeEach + test.afterEach structure
Section 5 — Auth Fixture Setup
Nếu auth_required = yes:
tests/global.setup.ts — login once, save storage state
playwright.config.ts update — projects with setup dependency
playwright/.auth/ directory setup
Section 6 — CI Configuration
File CI config hoàn chỉnh (GitHub Actions / GitLab CI / CircleCI):
- Install deps, install browsers, run tests
- Upload artifacts on failure
- Parallel execution config
- Environment variables setup
Section 7 — Coverage & Gap Report
E2E Coverage Summary
====================
User Flow: [flow name]
Framework: [playwright/cypress]
Covered:
✅ Happy path — [steps]
✅ Auth guard — [redirect behavior]
✅ Form validation — [fields covered]
✅ API failure handling — [if mocked]
Not Covered (Add Later):
⚠️ Mobile viewport (375px) — skipped per scope
⚠️ Multi-browser: Firefox, WebKit — needs separate run config
Flakiness Risks:
🔶 [step] — uses text selector, recommend adding data-testid
🔶 [step] — async operation, ensure waitForSelector is used
Selectors Missing (Request from Dev):
📌 Add data-testid="[name]" to [element description]
Framework Priority
Default: Playwright (TypeScript) — best DX, multi-browser, built-in assertions, no flakiness.
Use Cypress when the user explicitly requests it or existing project uses Cypress.
Use Selenium only for Java projects or legacy requirements.
Workflow
- Map the user journey — identify every step a user takes (click, type, navigate, assert)
- Identify selectors — prefer
data-testid, then ARIA roles, then text, last CSS class
- Create Page Objects — encapsulate UI interactions into reusable page classes
- Write test scenarios — happy path first, then error flows
- Add fixtures — auth state, seed data, API intercepts
- Configure CI — add playwright.config.ts + GitHub Actions workflow
- Handle flakiness — use
waitFor, avoid hard sleeps, use network idle states
Selector Priority (Best to Worst)
1. data-testid="submit-btn" → Most stable, test-specific
2. role="button" name="Submit" → Accessible, semantic
3. text="Submit" → Readable but fragile on i18n
4. .submit-button → CSS class — fragile
5. #submit → ID — better but still fragile
6. xpath=//button[@type='submit'] → Last resort
Always ask the dev team to add data-testid attributes to interactive elements.
Page Object Model (POM) Pattern
POM Base Structure
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
constructor(protected page: Page) {}
async navigate(path: string) {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
}
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByTestId('email-input');
this.passwordInput = page.getByTestId('password-input');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByTestId('error-message');
}
async goto() {
await this.navigate('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
}
Using POM in Tests
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../pages/LoginPage';
import { DashboardPage } from '../../pages/DashboardPage';
test.describe('Login Flow', () => {
let loginPage: LoginPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
await loginPage.goto();
});
test('valid credentials redirect to dashboard', async ({ page }) => {
await loginPage.login('user@example.com', 'correct-password');
await expect(page).toHaveURL('/dashboard');
await expect(dashboardPage.welcomeMessage).toContainText('Welcome');
});
test('invalid password shows error message', async () => {
await loginPage.login('user@example.com', 'wrong-password');
await loginPage.expectError('Invalid email or password');
});
test('empty fields show validation errors', async () => {
await loginPage.login('', '');
await loginPage.expectError('Email is required');
});
});
Authentication Fixtures (Reusable Auth State)
Avoid logging in on every test. Save auth state once and reuse.
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global.setup\.ts/,
},
{
name: 'chromium',
use: {
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('email-input').fill('user@example.com');
await page.getByTestId('password-input').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});
API Intercept / Network Mocking
test('displays error when API fails', async ({ page }) => {
await page.route('/api/users', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Internal server error' }),
});
});
await page.goto('/users');
await expect(page.getByTestId('error-banner')).toBeVisible();
await expect(page.getByTestId('error-banner')).toContainText('Something went wrong');
});
test('checkout submits correct payload', async ({ page }) => {
const [request] = await Promise.all([
page.waitForRequest(req => req.url().includes('/api/orders') && req.method() === 'POST'),
page.getByTestId('checkout-button').click(),
]);
const body = JSON.parse(request.postData() ?? '{}');
expect(body).toMatchObject({ items: expect.any(Array), total: expect.any(Number) });
});
Complex Flow Patterns
Pattern: Multi-Step Form / Wizard
test('complete signup wizard', async ({ page }) => {
await page.goto('/signup');
await page.getByTestId('first-name').fill('Alice');
await page.getByTestId('last-name').fill('Smith');
await page.getByTestId('next-step').click();
await expect(page.getByTestId('step-indicator')).toContainText('Step 2');
await page.getByTestId('email').fill('alice@example.com');
await page.getByTestId('password').fill('Secure@Pass123');
await page.getByTestId('next-step').click();
await expect(page.getByTestId('confirmation-email')).toContainText('alice@example.com');
await page.getByTestId('submit-signup').click();
await expect(page).toHaveURL('/signup/success');
await expect(page.getByTestId('success-message')).toBeVisible();
});
Pattern: File Upload
test('uploads profile picture', async ({ page }) => {
await page.goto('/profile/edit');
const fileInput = page.getByTestId('avatar-upload');
await fileInput.setInputFiles('tests/fixtures/test-avatar.png');
await page.getByTestId('save-profile').click();
await expect(page.getByTestId('avatar-image')).toBeVisible();
});
Pattern: Drag and Drop
test('reorders items by drag and drop', async ({ page }) => {
await page.goto('/kanban');
const source = page.getByTestId('card-item-1');
const target = page.getByTestId('column-done');
await source.dragTo(target);
await expect(page.getByTestId('column-done')).toContainText('Task 1');
});
Cypress Equivalent (when requested)
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('valid credentials redirect to dashboard', () => {
cy.get('[data-testid="email-input"]').type('user@example.com');
cy.get('[data-testid="password-input"]').type('correct-password');
cy.get('[data-testid="submit-btn"]').click();
cy.url().should('include', '/dashboard');
cy.get('[data-testid="welcome-message"]').should('contain', 'Welcome');
});
it('invalid password shows error', () => {
cy.get('[data-testid="email-input"]').type('user@example.com');
cy.get('[data-testid="password-input"]').type('wrong');
cy.get('[data-testid="submit-btn"]').click();
cy.get('[data-testid="error-message"]').should('be.visible');
});
});
CI Configuration
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
E2E Test Checklist
[ ] Happy path — complete user journey succeeds
[ ] Error recovery — user can correct mistakes and continue
[ ] Auth guard — unauthenticated users are redirected
[ ] Form validation — required fields, format errors shown
[ ] API failure handling — error states are displayed gracefully
[ ] Loading states — spinners/skeletons appear during async operations
[ ] Navigation — back button, breadcrumbs work correctly
[ ] Accessibility — interactive elements have accessible names
[ ] Mobile viewport — test at 375px width (optional)
[ ] No console errors during the test run
Advanced Testing Patterns
Pattern 1: Page Object Model (POM) — Full Implementation
POM separates page structure (selectors, navigation) from test logic (assertions, scenarios). Without POM, when the UI changes, you update 50 tests. With POM, you update 1 file.
Structure:
tests/
├── pages/
│ ├── LoginPage.ts ← selectors + actions for login page
│ ├── DashboardPage.ts ← selectors + actions for dashboard
│ └── CheckoutPage.ts ← selectors + actions for checkout
├── fixtures/
│ └── auth.fixture.ts ← shared login setup
└── specs/
├── login.spec.ts ← test scenarios only — no selectors here
└── checkout.spec.ts
LoginPage.ts — full POM class:
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
private readonly forgotPasswordLink: Locator;
constructor(private page: Page) {
this.emailInput = page.getByTestId('email-input');
this.passwordInput = page.getByTestId('password-input');
this.submitButton = page.getByTestId('submit-btn');
this.errorMessage = page.getByTestId('error-message');
this.forgotPasswordLink = page.getByText('Forgot password?');
}
async goto() {
await this.page.goto('/login');
await this.page.waitForLoadState('networkidle');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.page.waitForURL('**/dashboard');
}
async attemptLogin(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectErrorMessage(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
async expectLoginFormVisible() {
await expect(this.emailInput).toBeVisible();
await expect(this.passwordInput).toBeVisible();
await expect(this.submitButton).toBeEnabled();
}
}
login.spec.ts — clean test file using POM:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test.describe('Login flow', () => {
let loginPage: LoginPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
await loginPage.goto();
});
test('TC-AUTH-E2E-FN-001 — valid credentials redirect to dashboard', async () => {
await loginPage.login('alice@example.com', 'Password123!');
await dashboardPage.expectWelcomeMessage('Alice');
});
test('TC-AUTH-E2E-FN-002 — wrong password shows error', async () => {
await loginPage.attemptLogin('alice@example.com', 'wrong_password');
await loginPage.expectErrorMessage('Invalid email or password');
});
test('TC-AUTH-E2E-FN-003 — empty email shows validation', async () => {
await loginPage.attemptLogin('', 'Password123!');
await loginPage.expectErrorMessage('Email is required');
});
test('TC-AUTH-E2E-FN-004 — SQL injection in email field is safe', async () => {
await loginPage.attemptLogin("admin' OR '1'='1", 'any');
await loginPage.expectErrorMessage('Invalid email or password');
});
});
Pattern 2: Shared Auth Fixture (Login Once, Reuse Everywhere)
The biggest E2E performance mistake: logging in inside every test. Use Playwright fixtures to login ONCE per test suite and reuse the session.
auth.fixture.ts:
import { test as base, BrowserContext } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import path from 'path';
const USER_STATE = path.join(__dirname, '../.auth/user.json');
const ADMIN_STATE = path.join(__dirname, '../.auth/admin.json');
export async function globalSetup() {
const { chromium } = require('@playwright/test');
const browser = await chromium.launch();
const page = await browser.newPage();
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await page.context().storageState({ path: USER_STATE });
await loginPage.goto();
await loginPage.login('admin@example.com', 'adminpass');
await page.context().storageState({ path: ADMIN_STATE });
await browser.close();
}
type AuthFixtures = {
userContext: BrowserContext;
adminContext: BrowserContext;
};
export const test = base.extend<AuthFixtures>({
userContext: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: USER_STATE });
await use(context);
await context.close();
},
adminContext: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: ADMIN_STATE });
await use(context);
await context.close();
},
});
Using the fixture:
import { test } from '../fixtures/auth.fixture';
import { expect } from '@playwright/test';
test('user can view own orders', async ({ userContext }) => {
const page = await userContext.newPage();
await page.goto('/orders');
await expect(page.getByTestId('orders-list')).toBeVisible();
});
test('admin can access user management', async ({ adminContext }) => {
const page = await adminContext.newPage();
await page.goto('/admin/users');
await expect(page.getByTestId('users-table')).toBeVisible();
});
test('user cannot access admin panel', async ({ userContext }) => {
const page = await userContext.newPage();
await page.goto('/admin/users');
await expect(page).toHaveURL('/403');
});
Pattern 3: Visual Regression Testing
Visual regression testing captures screenshots and compares them pixel by pixel. It catches CSS regressions that functional tests miss.
Basic visual regression with Playwright:
import { test, expect } from '@playwright/test';
test('TC-UI-VRT-001 — login page visual snapshot', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
await page.evaluate(() => {
document.querySelectorAll('[data-dynamic]').forEach(el => {
(el as HTMLElement).style.visibility = 'hidden';
});
});
await expect(page).toHaveScreenshot('login-page.png', {
maxDiffPixels: 50,
threshold: 0.1,
animations: 'disabled',
});
});
test('TC-UI-VRT-002 — button states visual check', async ({ page }) => {
await page.goto('/components/buttons');
const buttonGroup = page.getByTestId('button-group');
await expect(buttonGroup).toHaveScreenshot('buttons-default.png');
await page.hover('[data-testid="primary-button"]');
await expect(buttonGroup).toHaveScreenshot('buttons-hover.png');
});
for (const viewport of [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
]) {
test(`TC-UI-VRT-003 — homepage responsive at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
});
}
Handling common false positives:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [page.getByTestId('last-updated-time')],
});
await page.waitForFunction(() => {
const el = document.querySelector('.loading-spinner');
return !el || window.getComputedStyle(el).display === 'none';
});
await page.route('**/*.jpg', route => route.fulfill({
path: 'tests/fixtures/placeholder.jpg'
}));
Update baseline snapshots:
npx playwright test --update-snapshots
npx playwright test --grep "VRT"
Pattern 4: Anti-Flakiness Patterns
Flaky tests are E2E's biggest problem. They pass sometimes and fail sometimes for no clear reason. Here are the patterns that fix them.
Root causes and fixes:
Cause 1: Hard-coded waits (most common)
await page.waitForTimeout(3000);
await page.click('#submit');
await page.waitForSelector('#submit', { state: 'visible' });
await page.click('#submit');
await page.getByTestId('submit-btn').click();
await expect(page.getByTestId('success-message')).toBeVisible({ timeout: 10000 });
Cause 2: Test data pollution (tests share state)
test.beforeAll(async () => {
testUser = await createUser('shared@test.com');
});
test.beforeEach(async () => {
testUser = await createUser(`test-${Date.now()}@example.com`);
});
test.afterEach(async () => {
await deleteUser(testUser.id);
});
Cause 3: Race conditions in async operations
await page.click('[data-testid="save-btn"]');
await expect(page.getByTestId('success-toast')).toBeVisible();
await Promise.all([
page.waitForResponse(response =>
response.url().includes('/api/save') && response.status() === 200
),
page.click('[data-testid="save-btn"]'),
]);
await expect(page.getByTestId('success-toast')).toBeVisible();
Cause 4: Fragile selectors
await page.click('.btn-primary.submit-form');
await page.click('//div[@class="container"]/button[2]');
await page.click('[data-testid="checkout-submit"]');
await page.getByRole('button', { name: 'Place Order' }).click();
Cause 5: Environment-dependent failures
expect(displayDate).toBe('03/25/2026');
expect(displayDate).toMatch(/\d{2}\/\d{2}\/\d{4}/);
use: {
locale: 'en-US',
timezoneId: 'America/New_York',
viewport: { width: 1280, height: 720 },
}
Flakiness detection and quarantine:
npx playwright test --repeat-each=10 login.spec.ts
retries: process.env.CI ? 2 : 0,
test.skip('TC-FLAKY-001 — flaky until #1234 is fixed', async ({ page }) => { ... });
Output Review — How to Review Agent's Work
You can paste the agent's output back and ask for a review.
Use this prompt:
Review this output. Act like a senior QA manager with 20 years of experience.
Tell me:
1. What test scenarios did you miss?
2. What is the biggest risk we are NOT testing?
3. Is this output complete enough to ship? Yes or No, and why.
[paste the output here]
The agent will then re-check its own work and give you an honest gap report.
References
references/playwright-locators.md — all locator strategies and best practices
references/playwright-config.md — config options, reporters, parallel runs
assets/templates/page-object-template.ts — POM class template
assets/templates/playwright-config.ts — production-ready config template
assets/templates/github-actions-e2e.yml — CI workflow template