Building Self-Contained Front-End Apps: A Testing-First Architecture

testingfrontendplaywrightarchitecturemswintegration-testing

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.

Coverage Gap Diagram

That gap typically includes:

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:

we introduced:

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:

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:

Three Tier Testing Pyramid

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:

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.

Shared Mock Architecture

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:

Before and After Architecture Outcomes

The operational improvements were easy to measure:

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.