Prioritize the critical path ruthlessly

E2E tests are the most expensive tests you own — slow to run, slower to debug, sensitive to every layer of the stack. Spending that budget on a settings toggle while signup-to-checkout goes untested is malpractice. Start by mapping your revenue and trust funnel: signup, login, search, cart, checkout, refund, password reset. Those flows get coverage first, deepest, and best maintenance.

A concrete allocation: identify the flows that generate or protect revenue, write one clean test per flow plus variants only where risk justifies it, and hold that set to a strict runtime budget appropriate for your pull request gate. Everything beyond the critical path belongs in lower-cost layers — component tests, API tests — or in scheduled runs rather than PR gating. If you’re building this foundation deliberately, our guide to regression testing strategies shows how to layer suites by cost without leaving gaps.

SEED

Unique test data

Use repeatable records, clocks, and test identities with clear prefixes.

created for this run
WORKER

Isolated browser + services

One run can change its records without racing another run.

isolated worker
RESET

Restore or discard

Start the next run from a known baseline instead of deleting shared rows.

repeat from clean state
Fresh state keeps parallel runs honestCreate or restore the state each worker needs; cleanup instructions alone do not make tests independent.

Choose locators that describe the visible control

The single highest-leverage habit in browser automation is selecting elements the way a user perceives them: by role and accessible name, not by DOM coordinates. A locator like getByRole('button', { name: 'Pay now' }) survives redesigns, refactors, and CSS rewrites; #checkout-form > div:nth-child(3) > button survives nothing.

Example to adapt: helper functions, accounts, and endpoints depend on your test project. Adapt this example to your project.

Code example
test('signed-in customer can complete checkout', async ({ page }) => {
  const user = await makeUser();            // factory: unique, disposable
  await login(page, user);
  await page.getByRole('link', { name: 'Cart' }).click();
  await page.getByRole('button', { name: 'Checkout' }).click();
  await expect(page.getByText('Payment confirmed')).toBeVisible();
});

Role-based selection has a second payoff people underrate: it continuously audits accessibility. A test that can’t find a button by accessible name often means screen readers can’t either. When UIs change legitimately, ranked locators also give self-healing systems something principled to work with — see how that re-ranking works. And when a failure is about how something looks rather than behaves, route it to dedicated visual checks as described in our visual regression guide, instead of bolting pixel assertions onto functional flows.

Every test stands alone

Order dependence is the quiet killer. The suite passes when run alphabetically on Monday and fails when sharded across four runners on Tuesday, because test 47 assumed test 12 created its account. Each fix buys down future flakiness: every test creates its own data through factories; no test reads state another test wrote; cleanup removes what setup created. As a forcing function, shuffle execution order locally — if the suite breaks under shuffling, it was already broken, just quietly.

Independence isn’t free: per-test setup costs seconds, and shared fixtures are tempting. Take them, but scope them correctly — a worker-scoped database container is fine; a worker-scoped user silently reintroduces coupling. The reward compounds elsewhere: independent tests are safe to retry individually, safe to shard arbitrarily, and safe to skip selectively when debugging. They’re also the prerequisite for the parallelization math in the next section.

Deterministic data beats cleaned-up data

Most “flaky” data problems are really non-determinism: tests that depend on whatever rows previous runs left behind, timestamps computed at runtime, counters asserted against unbounded tables. The fix is to make every input explicit. Factories mint fresh records with unique suffixes per test; fixed seeds replace random generators; clocks freeze where time matters. Assert against data you created — never against global counts like “the orders table has N rows”, which any parallel test can invalidate.

Side-effecting third parties deserve special treatment, because their state is both nondeterministic and expensive: real cards decline randomly on test numbers, real email arrives late, real SMS gets rate-limited. Stateful clones that speak the vendor’s API deterministically solve all three — the full pattern is in Stripe testing without real cards, and the broader strategy (factories, seeds, cleanup policies, clone lifecycles) is covered in test data management best practices.

Parallelize deliberately

Serial suites die by wall-clock arithmetic. A 40-minute serial suite is not a quality gate, it’s a context-switch generator. Sharding divides the work across machines, and the math is simple: shards = ceil(total_runtime / target_runtime), so 40 minutes across 4 runners lands near 10 minutes — assuming balanced shards. Balance matters more than count: group by measured duration, not file size, or one runner inherits the two slowest specs while three finish early.

Two caveats keep parallelism honest. First, contention is real — parallel workers hammering one database turn logical races into physical ones, which is why the isolation practices above come first; give each worker its own schema or use row-level uniqueness aggressively. Second, diminishing returns kick in around setup costs and license-constrained resources; going from 4 to 16 shards helps far less than going from 0 to 4. The complete CI configuration — caching, matrix sharding, merged reports — is walked through in our GitHub Actions guide.

Keep the details needed to investigate a failure

An E2E failure without artifacts costs a round of archaeology: reproduce, guess, instrument, repeat. A trace shortens the investigation — open the timeline, read the network waterfall, watch the DOM at the failing step. Configure artifact capture before you need it, because you will need it.

The minimum viable set: trace (DOM snapshots, network, console, action log), screenshots at failure, video on retry, and server logs correlated by request ID. Retain them long enough to debug — a week is plenty for PR artifacts — and link them directly in the failure report so nobody hunts through CI logs. Deterministic replay goes one step further than capture: being able to re-run the exact scenario against recorded third-party responses converts “couldn’t reproduce” into a routine morning task.

Start with a critical task, use stable locators, isolate its data, and retain the details needed to investigate failure. Apply the same review to generated tests. Molar’s generated Playwright export and clean replay remain under validation in beta; inspect the code and prove repeatability before adding it to a release gate.