You do not need 500 end-to-end tests. You need about ten that cover the paths where a bug costs money, run on every pull request, and never cry wolf. A login form that rejects valid passwords, or a Pay button that creates two orders on a double tap, can ship past a green unit test suite because each function works on its own. Only a real browser clicking through real pages catches the gap between them.
At Loop Origin we build storefronts and B2B portals, and a small, strict E2E suite on the revenue paths is part of how we ship weekly without manual regression rounds. I wrote this with help from AI tools and checked every command and claim myself. Every test run below happened on my machine today with Playwright 1.64.1 and Chrome 156, including the run where my own test turned out flaky.
Which E2E tests should a business run on every deploy?
Direct answer: Cover only flows that lose revenue or lock users out: sign-in, password reset, add to cart, checkout, payment failure handling and duplicate-submit protection. Run them in a real browser on every pull request, isolate test data per test, mock third-party gateways at the network layer, and treat any flaky test as a bug.
Where E2E fits in the test budget
| Layer | Typical runtime per test | Flake risk | What it catches | What it misses |
|---|---|---|---|---|
| Unit | Milliseconds | Very low | Logic errors in one function | Wiring between pages, APIs and the browser |
| Integration / API | Tens to hundreds of ms | Low | Contracts, database queries, auth rules | JavaScript, rendering, real clicks |
| E2E in a browser | Hundreds of ms to seconds | Medium to high if designed badly | Broken buttons, redirects, double submits, mobile layout bugs | Edge-case logic that should be a unit test |
The rule I use: if a bug in a flow would trigger a refund, a support ticket from every user or a lost sale, it gets an E2E test. Everything else is cheaper to test lower down.
A minimal shop to test against
To make this concrete, I wrote a 22-line Node server with a login page, a cart showing "Total: PKR 4,500", a Pay button and a /api/pay endpoint. Each cart page generates an idempotency key, and the server stores one order per key, which is the same pattern I described in preventing double booking.
The Playwright config runs the suite on a desktop and a mobile profile and starts the app automatically:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: process.env.CI ? 1 : 0, // one retry in CI, reported as "flaky"
reporter: [['list'], ['html', { open: 'never' }]],
use: { baseURL: 'http://localhost:4010', trace: 'on-first-retry', screenshot: 'only-on-failure' },
webServer: { command: 'node app/server.mjs', url: 'http://localhost:4010/login',
reuseExistingServer: !process.env.CI },
projects: [
{ name: 'desktop-chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
],
});
Locally I added channel: 'chrome' to each project to use the installed Chrome. In CI the bundled Chromium is fine.
My first double-submit test was flaky
Here is the first version of the duplicate-order test:
test('double-clicking Pay creates exactly one order', async ({ page, request }) => {
const before = Number(await (await request.get('/api/orders/count')).text());
await page.goto('/cart');
await page.getByRole('button', { name: 'Pay now' }).dblclick();
await expect(page.getByRole('status')).toContainText('confirmed');
const after = Number(await (await request.get('/api/orders/count')).text());
expect(after - before).toBe(1);
});
The real output with CI=1 npx playwright test (file paths and Node warnings trimmed):
Running 4 tests using 2 workers
✓ 1 [desktop-chrome] › signed-in user can pay and sees a confirmation (496ms)
✓ 2 [mobile-chrome] › signed-in user can pay and sees a confirmation (567ms)
✘ 3 [desktop-chrome] › double-clicking Pay creates exactly one order (391ms)
✓ 4 [mobile-chrome] › double-clicking Pay creates exactly one order (405ms)
✓ 5 [desktop-chrome] › double-clicking Pay creates exactly one order (retry #1) (449ms)
Error: expect(received).toBe(expected) // Object.is equality
Expected: 1
Received: 2
1 flaky
3 passed (2.7s)
The app was fine. The test was wrong. Two workers ran the desktop and mobile versions at the same time against one server, and both changed the same global order count. The retry passed, the run went green, and a bug-shaped signal got labeled "flaky". That is how teams learn to ignore E2E failures.
The fix is to assert only on what this test caused:
test('double-clicking Pay creates exactly one order', async ({ page }) => {
const orderIds: string[] = [];
page.on('response', async (res) => {
if (res.url().endsWith('/api/pay')) orderIds.push((await res.json()).order);
});
await page.goto('/cart');
await page.getByRole('button', { name: 'Pay now' }).dblclick();
await expect(page.getByRole('status')).toContainText('confirmed');
await expect.poll(() => orderIds.length).toBe(2); // both clicks reached the API
expect(new Set(orderIds).size).toBe(1); // ...and produced one order
});
With --repeat-each=10, all 40 runs passed in 8.1 seconds:
40 passed (8.1s)
A test that always passes proves nothing, so I broke the server on purpose: I replaced the idempotency key with crypto.randomUUID() so every click creates a new order. Both projects failed, as they should:
✘ 1 [desktop-chrome] › double-clicking Pay creates exactly one order (421ms)
✘ 2 [mobile-chrome] › double-clicking Pay creates exactly one order (439ms)
Expected: 1
Received: 2
2 failed
Login once, reuse the session
Logging in through the UI in every test is slow and adds a failure point to every test. Playwright's setup projects log in once and save the session:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_USER!);
await page.getByLabel('Password').fill(process.env.E2E_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: authFile });
});
Then add { name: 'setup', testMatch: /.*\.setup\.ts/ } as a project and give the other projects dependencies: ['setup'] and use: { storageState: authFile }. Keep one dedicated test for the login form itself, including a wrong-password case. Add playwright/.auth to .gitignore; it holds live session cookies.
Test data isolation strategies
| Strategy | How it works | Parallel safe? | Cost | Use when |
|---|---|---|---|---|
| Shared seeded database | Everyone uses the same demo user and cart | No | Lowest | Never for write paths |
| Unique data per test | Each test creates its own user or order via the API | Yes | Low | Default choice |
| Assert on own effects | Check IDs this test produced, not global counts | Yes | Free | Always, alongside the above |
| Database per worker | Each worker gets its own schema or container | Yes | Medium | Heavy data setup, legacy apps |
| Ephemeral environment per PR | Full stack spun up per branch | Yes | Highest | Large teams, complex infrastructure |
Mock the payment gateway at the network layer
Never let CI hit a real payment provider. Use the provider's sandbox in a nightly run, and in PR runs fulfill the request yourself so you can test failures on demand:
await page.route('**/api/payments/intent', (route) =>
route.fulfill({ status: 402, json: { error: 'card_declined' } }));
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('alert')).toHaveText(/card was declined/i);
Declined cards, timeouts and gateway 500s are the paths users hit and teams rarely test.
Run it on every pull request
name: e2e
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { shard: [1/2, 2/2] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard={{ '${{ matrix.shard }}' }}
- uses: actions/upload-artifact@v4
if: {{ '${{ !cancelled() }}' }}
with: { name: e2e-report-{{ '${{ strategy.job-index }}' }}, path: playwright-report/, retention-days: 7 }
If your app runs in containers, test against the same image you deploy; my multi-stage Dockerfile guide shows how we build those.
The revenue-path E2E checklist
- List the five to ten flows where a bug costs money or locks users out.
- Use role and label locators (
getByRole,getByLabel), not CSS classes that change with redesigns. - Give every test its own data, and assert only on effects it caused.
- Mock third-party gateways in PR runs; use sandboxes nightly.
- Run desktop and at least one mobile profile.
- Keep
retriesat 1 in CI and review every test marked flaky that week. - Before trusting a new test, break the feature and confirm the test fails.
- Upload traces and reports as CI artifacts so failures are debuggable without rerunning.
For storefront projects, this suite is part of our e-commerce development work from the first sprint.
Frequently asked questions
How many E2E tests should a small product have?
Start with five to ten covering sign-in, checkout and payment failures. Add one when a bug escapes to production in a flow that a browser test would have caught.
Playwright or Cypress for checkout tests?
Both work. I use Playwright for multi-browser projects, built-in parallelism and network mocking without plugins. Pick one and invest in stable locators and data isolation; those matter more than the tool.
Should E2E tests run against production?
Not the full suite. Run a few read-only smoke checks against production after each deploy, and run write flows like checkout against staging or an ephemeral environment.
How do I stop flaky E2E tests?
Remove shared state, wait on assertions instead of fixed timeouts, and use stable locators. Treat each flaky result as a bug in either the test or the app, as my first test above showed.
Should I retry failed tests in CI?
One retry is reasonable if flaky results are reported and reviewed. Unlimited retries hide real race conditions like double submits.
Is it safe to store test credentials in CI?
Use a dedicated test account with no real data, store its password as a CI secret, and never commit the saved auth state file.
Follow & Connect
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)