Proposal · Frontend E2E

Isolated, parallel E2E tests on a multi-tenant backend

How we get a Playwright suite where any spec can run alone, in parallel, and in any order — without tests stepping on each other's data.

Playwright Nx · Angular GitLab CI real backend, no mocks
01

The core principle

parallelism unit (worker)  =  isolation unit (company)

Our app is already multi-tenant: two customers can never see each other's data. We reuse that exact wall for tests. Every Playwright worker (an OS process running spec files) provisions its own disposable company in the backend and does all its work inside it. Two workers can't collide for the same reason two customers can't.

playwright testone runner per CI job
spawns N workers ↓
Worker 0runs spec files, one after another
Worker 1runs spec files, one after another
Worker 2runs spec files, one after another
each provisions + later disposes ↓
Company e2e-…-w0users per role · seed data
Company e2e-…-w1users per role · seed data
Company e2e-…-w2users per role · seed data
all inside ↓
one shared e2e backend + DBisolation comes from tenancy, not from separate databases

Everything else — recipes, fixtures, sharding — is machinery to make this principle cheap and automatic.

02

How specs land on companies — live

Spec files declare a company profile (a seeding recipe). Playwright assigns files to workers from a queue; a worker only runs files matching its profile, and bakes one fresh company from that recipe for all the files it executes. Watch the provision counter — that's the money slide: companies scale with workers, not with spec files.

tick 0
Queue · 22 spec files
Workers
Companies in backend
0/22
spec files done
0
companies provisioned
22
if provisioned per file (hooks)
0
alive right now

Deliberately visible in the simulation: files of the same color may end up split across two workers → two identical copies of that recipe's company exist. Specs may rely on what the recipe guarantees, never on “the same physical company” as a sibling file.

03

A profile is a recipe, not a company

test.use({ companyProfile: 'measurements' }) does not mean “connect me to the measurements company”. There is no such single company. It means: run me inside some disposable company baked from the measurements recipe — and Playwright bakes as many identical copies as its scheduling needs.

recipe 'measurements'
seedMeasurements(api, 60)
→ rows m-000 … m-059
→ dataset "Energy"
// a function in our code
bakes
company #8817 · worker A
same content, own IDs — deleted after the run
company #8818 · worker B
same content, own IDs — deleted after the run

Three guarantees follow, and they're the only things a spec may rely on:

04

Data discipline — one question

Every test answers a single question: “where does the data I touch come from?” There are exactly three legal answers — that's the entire discipline.

answer 1 · I only read itFrom the recipe

Pagination, sort, filter, search. The seed is shared furniture that nobody ever mutates, so any number of tests can assert on it — “60 rows, 3 pages” — in any order, forever.

answer 2 · I'm going to change itFrom my own factory call

Create / edit / delete tests manufacture their own uniquely-named entity via one API call, then drive the UI on it. Nobody else knows rename-me-a1b3 exists — mutate it freely. Retry-proof by construction: a retried test re-runs its factory line and gets a fresh entity.

answer 3 · my assertion is about the whole tenantFrom a private company

Empty states, delete-all sweeps, exact unfiltered totals. The file takes a profile name nobody else uses → own worker → own company, untouched by structure, not by politeness. Rare by design.

What's forbidden falls out automatically: editing seeded m-007 (breaks answer 1 for everyone), and using another test's leftovers (breaks answer 2's retry story — the retry runs alone, its supplier doesn't).

You wantYou write
Run in whatever company the worker hasnothing (default profile)
“This file needs 60 measurements first”test.use({ companyProfile: 'measurements' })
“This file needs a parent business metric”beforeAll(({ api }) => createBusinessMetric(api))
“This test edits something”create it inside the test via a factory
“This file needs a company nobody touched”a profile name used by this file only

Write every it as order-independent even though we keep fullyParallel: false (in-file order preserved): CI retries re-run a failed test alone in a fresh worker, so hidden test-to-test dependencies always break there first.

05

Building blocks: fragments → page objects → specs

Selector knowledge is layered so that DOM changes hit exactly one file. The foundation is fragments: one driver class per shared UI component (cad-table, dropdown, tree, date-picker…) that hides the component's internal DOM and exposes an intent-level API — table.sortBy('Name'), dropdown.select('CO₂e'), tree.expand('Scope 1').

specs User stories only. Speak page-object language; a raw page.locator(…) in a spec is a review flag. uses ↓
page objects One per app page. Compose fragments + page-specific locators + navigation. No assertions, no HTTP. uses ↓
fragments tested against Storybook One per shared component. The only place that knows the component's internal DOM. Each fragment ships with its own tests running against Storybook stories. drives ↓
app DOM Shared cad-* components get a data-testid pass-through so fragments attach to stable hooks.

The Storybook tests are the clever part — one suite, two guarantees:

See the table.fragment.ts / measurements.page.ts / table.fragment.spec.ts tabs below for how the three layers look side by side.

06

What it looks like in code

All infrastructure lives in one fixture file; spec files stay one-liner-declarative. Snippets are editable — click into them during the demo.


  
// contenteditable — tweak live while presenting; “copy” grabs the current text
07

Scaling in GitLab CI

Two multiplying levels: GitLab parallel: 4 gives four runner machines, each running one shard of the suite (--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL); inside each shard, Playwright spawns its workers. The architecture needs zero changes — more workers simply bake more disposable companies.

job 1 · shard 1/4
WWWW
job 2 · shard 2/4
WWWW
job 3 · shard 3/4
WWWW
job 4 · shard 4/4
WWWW

4 shards × 4 workers = 16 tests running at once, 16 tenants, one backend.

The real ceilings

08

Decisions we need to make

1 · Which backend do tests hit?

The suite runs against a real backend — whose?

bootstrapShared dev env — works today, but collides with manual QA; fine only while building the suite.
targetDedicated e2e environment — resettable, seedable, stable URL for CI.
laterEphemeral docker backend per pipeline — hermetic, enables backend-PR testing, needs backend-repo support.

2 · How fast can we provision a company?

This single number decides how much isolation we can afford.

measureTime company+user creation via existing admin APIs — an afternoon of work.
ask BEOne-call test-support endpoint: “create company + users + seed pack” (and dispose). ≤ ~3 s unlocks per-file companies if we ever want them.
fallbackCompany pool with reset-on-checkout — only if provisioning can't be made fast.

3 · When does the suite run?

Maturity ladder, in order.

nowManual, while the foundation (fixtures, factories, first specs) is built.
nextNightly full run against the e2e env, report to Slack; someone owns triage.
then@smoke subset (~5–10 min) gating every merge request.