End-to-end tests are where most testing strategies break down — not because the tools are bad, but because maintaining them is painful. Pages change, selectors go stale, tests become flaky. Claude Code makes E2E testing faster to write and easier to maintain by generating tests from user flows, rewriting brittle selectors, and debugging failures by reading the actual error output.
This guide covers E2E testing with Playwright and Cypress using Claude Code: test structure, page object models, CI integration, and fixing flaky tests.
Setting Up Playwright
Set up Playwright in this project.
I want TypeScript tests, Chromium + Firefox, with a base URL from .env.
Claude Code installs Playwright and generates playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
retries: 2 on CI catches transient failures without masking real ones. trace: 'on-first-retry' captures a full trace of the retry run — the trace viewer shows DOM state, network, and console logs at every step.
CLAUDE.md for E2E Testing
## E2E Testing (Playwright)
- Tests in tests/e2e/
- Page Objects in tests/e2e/pages/ — one class per page
- Fixtures in tests/e2e/fixtures/ — shared setup/teardown
- Always use data-testid attributes for selectors — never CSS classes or text that changes
- Use role-based selectors (getByRole, getByLabel) for accessibility checks
- Tests must clean up their own test data after running
Page Object Model
Create a page object for the checkout flow.
It covers: cart, shipping form, payment form (Stripe iframe), and order confirmation.
// tests/e2e/pages/CheckoutPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class CheckoutPage {
private readonly page: Page;
private readonly emailInput: Locator;
private readonly continueToShippingBtn: Locator;
private readonly shippingFirstName: Locator;
private readonly shippingLastName: Locator;
private readonly shippingAddress: Locator;
private readonly continueToPaymentBtn: Locator;
private readonly orderConfirmationHeading: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.continueToShippingBtn = page.getByRole('button', { name: 'Continue to shipping' });
this.shippingFirstName = page.getByLabel('First name');
this.shippingLastName = page.getByLabel('Last name');
this.shippingAddress = page.getByLabel('Address');
this.continueToPaymentBtn = page.getByRole('button', { name: 'Continue to payment' });
this.orderConfirmationHeading = page.getByRole('heading', { name: /order confirmed/i });
}
async fillEmail(email: string) {
await this.emailInput.fill(email);
await this.continueToShippingBtn.click();
}
async fillShipping(details: { firstName: string; lastName: string; address: string }) {
await this.shippingFirstName.fill(details.firstName);
await this.shippingLastName.fill(details.lastName);
await this.shippingAddress.fill(details.address);
await this.continueToPaymentBtn.click();
}
async fillStripeCard(cardNumber: string, expiry: string, cvc: string) {
// Stripe uses an iframe — must switch context
const stripeFrame = this.page.frameLocator('[title="Secure payment input frame"]');
await stripeFrame.getByLabel('Card number').fill(cardNumber);
await stripeFrame.getByLabel('Expiration date').fill(expiry);
await stripeFrame.getByLabel('Security code').fill(cvc);
}
async placeOrder() {
await this.page.getByRole('button', { name: 'Place order' }).click();
}
async verifyConfirmation() {
await expect(this.orderConfirmationHeading).toBeVisible({ timeout: 10_000 });
}
}
The key pattern: all locators are declarative and role-based (getByRole, getByLabel). When the UI changes, you update the page object in one place instead of hunting through test files.
Writing Tests from User Stories
Write a test for the checkout flow:
1. User adds a product to the cart
2. Fills in shipping info
3. Pays with Stripe test card 4242 4242 4242 4242
4. Sees order confirmation
The test should work with a test user account that cleans itself up.
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/CheckoutPage';
import { ProductPage } from './pages/ProductPage';
test.describe('Checkout flow', () => {
let orderId: string;
test.beforeEach(async ({ page }) => {
// Use test account (seeded in dev/staging, cleaned up in afterEach)
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem('auth_token', process.env.TEST_USER_TOKEN!);
});
});
test.afterEach(async ({ request }) => {
// Clean up test order via API if one was created
if (orderId) {
await request.delete(`/api/test/orders/${orderId}`);
}
});
test('complete purchase', async ({ page }) => {
const productPage = new ProductPage(page);
const checkoutPage = new CheckoutPage(page);
// Navigate to product and add to cart
await productPage.goto('test-product-slug');
await productPage.addToCart();
// Begin checkout
await page.getByRole('link', { name: 'Checkout' }).click();
// Fill checkout steps
await checkoutPage.fillEmail('[email protected]');
await checkoutPage.fillShipping({
firstName: 'Test',
lastName: 'User',
address: '123 Test St, San Francisco, CA 94105',
});
await checkoutPage.fillStripeCard('4242424242424242', '12/29', '123');
await checkoutPage.placeOrder();
await checkoutPage.verifyConfirmation();
// Capture order ID for cleanup
const url = page.url();
orderId = url.match(/orders\/([^/]+)/)?.[1] ?? '';
});
});
Visual Regression Testing
Add visual regression tests for the product page.
I want to catch unintended CSS changes on each PR.
test('product page visual regression', async ({ page }) => {
await page.goto('/products/test-product-slug');
// Wait for dynamic content to settle
await page.waitForLoadState('networkidle');
await page.getByTestId('product-price').waitFor();
// Take full-page screenshot
await expect(page).toHaveScreenshot('product-page.png', {
fullPage: true,
// Mask dynamic elements that change across runs
mask: [
page.getByTestId('last-updated-timestamp'),
page.getByTestId('user-count'),
],
});
});
test('mobile product page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); // iPhone 14
await page.goto('/products/test-product-slug');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('product-page-mobile.png', {
fullPage: true,
});
});
On first run, Playwright creates baseline screenshots. On CI, it diffs against the baseline and fails if pixels changed beyond the threshold. To update baselines intentionally: npx playwright test --update-snapshots.
Debugging Flaky Tests
This test fails about 20% of the time with "element not found".
Here's the error and the test code. What's wrong?
Paste the test and the error. Claude diagnoses common causes:
Race condition (most common):
// Brittle — button may not exist yet when checked
await page.click('[data-testid="submit"]');
// Fixed — wait for the element to be actionable
await page.getByTestId('submit').click(); // Playwright auto-waits
Missing network idle:
// Navigation triggered an API call — content appears after response
await page.goto('/dashboard');
// Data table might not be loaded yet
await expect(page.getByTestId('user-table')).toBeVisible();
// Fixed — wait for API response
await page.waitForResponse('**/api/users');
await expect(page.getByTestId('user-table')).toBeVisible();
Animation interfering with clicks:
// Button animates in and click fires before it's stable
await page.getByRole('button', { name: 'Submit' }).click();
// Fixed — ensure it's not animating
await page.getByRole('button', { name: 'Submit' }).waitFor({ state: 'stable' });
await page.getByRole('button', { name: 'Submit' }).click();
Setting Up Cypress
This project already uses Cypress. Add a test for user login with
email/password and Google OAuth. Include custom commands.
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
loginWithEmail(email: string, password: string): Chainable<void>;
loginWithGoogle(): Chainable<void>;
clearTestData(): Chainable<void>;
}
}
}
Cypress.Commands.add('loginWithEmail', (email, password) => {
// Bypass the UI for test setup — faster and more reliable
cy.request('POST', '/api/auth/signin', { email, password })
.its('body.token')
.then((token) => {
window.localStorage.setItem('auth_token', token);
});
cy.visit('/dashboard');
cy.get('[data-testid="user-menu"]').should('be.visible');
});
Cypress.Commands.add('loginWithGoogle', () => {
// Stub OAuth provider in tests — don't hit real Google
cy.intercept('GET', 'https://accounts.google.com/o/oauth2/**', {
statusCode: 302,
headers: {
location: `${Cypress.config('baseUrl')}/api/auth/callback/google?code=test_code&state=test_state`,
},
});
cy.intercept('POST', '/api/auth/callback/google', {
statusCode: 200,
body: { token: 'test_google_token', user: { id: 'google_user_1', email: '[email protected]' } },
});
cy.visit('/login');
cy.get('[data-testid="google-signin"]').click();
cy.get('[data-testid="user-menu"]').should('be.visible');
});
// cypress/e2e/auth/login.cy.ts
describe('Authentication', () => {
beforeEach(() => {
cy.clearCookies();
cy.clearLocalStorage();
});
it('logs in with email and password', () => {
cy.loginWithEmail('[email protected]', 'testpass123');
cy.url().should('include', '/dashboard');
cy.get('[data-testid="welcome-message"]').should('contain', '[email protected]');
});
it('shows error for invalid credentials', () => {
cy.visit('/login');
cy.get('[data-testid="email-input"]').type('[email protected]');
cy.get('[data-testid="password-input"]').type('wrongpassword');
cy.get('[data-testid="login-button"]').click();
cy.get('[data-testid="error-message"]').should('contain', 'Invalid email or password');
});
it('redirects to originally requested URL after login', () => {
cy.visit('/settings/billing');
cy.url().should('include', '/login');
cy.loginWithEmail('[email protected]', 'testpass123');
cy.url().should('include', '/settings/billing');
});
});
CI Integration
Set up Playwright tests in GitHub Actions.
Run on every PR, post results as a PR comment with pass/fail counts.
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
branches: [main]
jobs:
test:
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 firefox
- name: Build application
run: npm run build
- name: Run E2E tests
run: npx playwright test
env:
BASE_URL: http://localhost:3000
TEST_USER_TOKEN: ${{ secrets.TEST_USER_TOKEN }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Post results as PR comment
uses: daun/playwright-report-summary@v3
if: always()
with:
report-file: test-results/results.json
github-token: ${{ secrets.GITHUB_TOKEN }}
Structuring Tests for a Large App
We have 50+ E2E tests and they take 8 minutes to run.
How do I parallelize them without test interference?
Claude recommends a sharding strategy:
# Matrix sharding — split test files across runners
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Run Playwright tests (shard ${{ matrix.shard }}/4)
run: npx playwright test --shard=${{ matrix.shard }}/4
And isolation patterns to prevent test interference:
- Each test creates its own user account with a unique ID (
test_${Date.now()}_${Math.random()}) - Tests never read data written by other tests
- Teardown via API, not UI (10x faster)
- Separate test databases per shard when possible
E2E tests are a force multiplier when they’re reliable. The testing and debugging guide covers unit and integration testing. The CI/CD guide covers the full pipeline from commit to deployment. For accessibility testing in your E2E suite — validating ARIA attributes, keyboard navigation, and contrast — the Claude Skills 360 bundle includes accessibility audit skills. Start with the free tier to try E2E test generation on your project.