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 living at two extremes.
On one end are unit tests running in jsdom. They’re fast, reliable, and easy to maintain. They provide excellent coverage for component logic, hooks, utility functions, and isolated state transitions.
On the other end are end-to-end tests running against real infrastructure. These tests provide the highest level of confidence because they exercise the application in an environment that closely resembles production.
As our applications became more complex—multi-step wizards, conditional workflows, navigation-heavy experiences, and stateful user journeys—we began noticing a pattern.
Our unit tests were passing. Our end-to-end smoke tests were passing. Yet issues were still reaching production.
Unit tests running in jsdom do not execute a real browser. They do not render real CSS, compute layout, exercise browser navigation, or expose many browser APIs. They’re extremely effective for validating logic, but cannot validate the environment in which users actually interact with the application.
At the same time, end-to-end tests are expensive. Every test requires a running backend, seeded data, authentication setup, infrastructure provisioning, and significant execution time. As a result, most teams keep their E2E suite intentionally small.
The result is a coverage gap between what is practical to validate with unit tests and what is economical to validate through end-to-end testing.

That gap typically includes:
- Multi-step user journeys
- Navigation state
- Browser APIs
- Layout regressions
- CSS issues
- File uploads
- Route synchronization
- Complex UI interactions
These are difficult to validate in unit tests, but too expensive to cover comprehensively through E2E testing.
Once we recognized that gap, the next question became obvious:
What if the browser was the variable we actually needed to test?
Not the backend.
The browser.
The Missing Layer
When we analyzed the failures we were seeing, most of them had nothing to do with backend behavior.
The application simply needed data realistic enough for the UI to render and the user flow to proceed. In many cases, the backend itself was incidental.
That realization led us to introduce a third testing layer.
Instead of choosing between:
- Fast unit tests in jsdom
- Slow end-to-end tests against production-like environments
we introduced:
- Browser-based execution
- Mocked API responses
- No backend dependency
This became our integration testing layer.
The application now ran inside a real Chromium browser, while network requests were intercepted and satisfied locally through mocks.
That meant:
- CSS executed normally
- Layout calculations worked
- Navigation behaved correctly
- Browser APIs were available
while avoiding the infrastructure requirements of a traditional end-to-end environment.
The result was a testing environment that exercised the application in realistic conditions without requiring backend services.
The testing strategy evolved into a three-tier model:

Each layer had a clearly defined responsibility, reducing the pressure to force every testing problem into either unit tests or E2E tests.
One Scenario System Across Development and Testing
A natural concern emerged almost immediately.
By this point, mock data was already being used in multiple places:
- Local development (
yarn dev)
- Unit tests
- Browser integration tests
Without a shared approach, each environment risked developing its own fixtures, mock handlers, and assumptions about application behavior.
That would have introduced duplication, inconsistency, and long-term maintenance overhead.
We wanted a single definition of application behavior.
The solution was to keep MSW as the source of truth and introduce a lightweight adapter that allowed those same handlers to execute inside Playwright’s browser environment.
Rather than maintaining separate mocking implementations, we reused the same handler definitions across development and testing environments.

The benefits became apparent quickly.
The same scenarios used during local development could also be consumed by browser-level integration tests. Fixture data remained centralized, contracts stayed consistent, and engineers only needed to learn a single vocabulary for representing application state.
Instead of maintaining separate mock implementations across environments, we maintained a shared model of application behavior.
Note: While this approach worked well for development and integration testing, I found the trade-offs around scenario-driven mocking in unit tests to be more nuanced. That’s a topic worthy of its own article.
The Payoff
The immediate benefits were tangible.
The frontend stopped being treated as a dependent artifact waiting for backend systems to become available.
It became a system in its own right—one that could be:
- developed independently
- tested independently
- validated independently
- evolved independently

The operational improvements were easy to measure:
- Faster CI feedback
- Reduced infrastructure dependencies
- Fewer cross-repository failures
- Easier onboarding
- Better browser coverage
Closing Thoughts
The integration layer didn’t replace unit testing or end-to-end testing. It filled the space between them.
Looking back, the lesson wasn’t really about Playwright or MSW. It was about recognizing that there was an entire category of problems living between unit tests and end-to-end tests—and designing explicitly for that space.
The practical outcomes were faster feedback loops, simpler onboarding, and fewer infrastructure dependencies. But the more important change was architectural: the frontend became capable of standing on its own.