Building Self-Contained Front-End Apps: A Testing-First Architecture
How a Three-Tier Testing Strategy Changed the Way We Think About Frontend Independence
There’s a moment every frontend team eventually hits: you’ve written hundreds of unit tests, your component library is well-covered, but your CI is still bottlenecked on a slow backend-dependent test suite that takes twenty minutes and breaks on unrelated server changes.
We hit that moment building a multi-step wizard — a product with complex navigation state, multi-page flows, and a backend that was evolving in parallel. The combination broke our existing testing strategy in ways that were hard to paper over.
Fixing it forced us to rethink what “self-contained” really means for a frontend app.
The Problem With Two Extremes
Most frontend teams end up with a testing setup that looks like this:
- Unit tests running in jsdom
- Fast
- Great for component logic
- But not a real browser
jsdom doesn’t execute real CSS, doesn’t handle layout, and can’t test browser-native behaviors like:
- back/forward navigation
- local storage
- file uploads
A field accidentally set to display: none would be unusable in production but completely invisible to a jsdom-based test.
So teams reach for E2E tests:
- real browser
- real backend
- real infrastructure
But every E2E test requires:
- a running server
- a database
- seeded data
- often a full auth stack
For us, that meant 120-180 seconds per test — expensive enough that we could only afford a small number of critical-path tests.
Worse, our E2E tests lived in the backend repository, so a UI selector change could block unrelated backend merges.
The result:
- thorough unit tests
- a handful of E2E happy paths
- no coverage for the middle ground
And the middle ground is where things actually break:
- multi-step flows
- navigation state
- conditional modals
- upload behavior
- browser interactions jsdom can’t simulate
The Insight: The Browser Is the Variable
When we diagnosed what was difficult to test, the common thread wasn’t the backend — it was the browser.
Most flows didn’t need real API responses. They just needed some API responses so the app could render and navigate.
That reframing led to a middle tier.
Integration Tests
- Real Chromium browser via Playwright
- No backend running
- All API calls intercepted by MSW
The browser is real:
- CSS executes
- navigation works
- URLs update
- browser APIs behave correctly
But no server exists.
Playwright wasn’t replacing E2E testing — it was filling a missing tier.
One Mock System for Both Tiers
A natural concern emerged.
If:
- unit tests use MSW
- Playwright tests use
page.route()
then every API contract exists in two places.
We solved this with an MSW adapter:
a thin bridge converting MSW-style handlers into Playwright route handlers.
This allowed the same mock definitions to work in both environments.
Testing Layers
| Tier | Environment | Backend | Speed |
|---|
| Unit | Node.js / jsdom | MSW mocks | Fast |
| Integration | Real browser | MSW mocks | Medium |
| E2E | Real browser | Real APIs | Slow |
The practical effect:
- scenario names became shared vocabulary
- mocks stayed centralized
- API contracts stayed consistent
What Self-Contained Actually Means
Self-contained doesn’t mean isolated from reality.
The frontend still understands:
- API contracts
- response shapes
- failure conditions
What it doesn’t need is a running backend to function.
In practice this means:
- Run locally with no backend:
VITE_ENABLE_MOCKING=true yarn dev
- Run CI without infrastructure:
npx playwright test
- Develop against mock data before APIs exist
- Demo without deployments
- Onboard engineers instantly
A new engineer can clone the repo and run the app immediately — realistic states, realistic failures, no database provisioning.
That meaningfully changes onboarding velocity.
Feature Flags Without a Backend
In production, feature flags often arrive through backend session payloads.
In a self-contained environment, there is no session.
Playwright’s addInitScript() solved this by injecting flags into the browser before the app booted.
That enabled deterministic testing of:
- feature combinations
- rollout paths
- conditional rendering
without requiring:
- separate test accounts
- backend configuration
- org-level setup
A Pitfall: Over-Mocking in Unit Tests
This architecture also exposed a common anti-pattern:
trying to unit test heavily connected features.
The result is often:
- 4–5 provider mocks
- assertion-heavy tests
- brittle implementation coupling
These tests pass because the mocks are correct — not because the feature works.
In many cases, one realistic Playwright integration test replaced five to ten fragile unit tests.
Placement Heuristic
Use Integration Tests (Playwright) For
- multi-step flows
- navigation state
- URL synchronization
- browser APIs
- CSS/layout behavior
- file uploads
- connected UI flows
Use Unit Tests For
- utility functions
- hooks
- form validation logic
- small UI state
- isolated rendering behavior
The Payoff
After adding the integration tier:
- new flows gained reliable browser-level coverage
- browser bugs were caught before E2E
- the E2E suite shrank significantly
- frontend work became backend-optional
- cross-repo breakage disappeared
But the deeper shift was conceptual.
The frontend became treated as a first-class system:
- independently testable
- independently runnable
- independently evolvable
Self-contained doesn’t mean disconnected from reality.
It means the application knows how to stand on its own.