Modern Frontend Frameworks Are Failing at Testing
Source: Dev.to
Observations after releasing TWD
A few weeks after releasing TWD – a tool designed to integrate testing directly into the development workflow – I started creating testing recipes for different frontend frameworks. While doing that, a few patterns became very clear.
SPA‑focused applications
- Frontend‑only applications (SPAs) are relatively easy to test using tools like TWD and traditional runners such as Vitest.
- You can validate real user interactions, keep tests close to the UI, and iterate quickly.
SSR frameworks
- Frameworks like Next.js, Astro, TanStack, Qwik, and Solid provide little to no guidance on how to test applications in a reliable and maintainable way.
- Most of them simply delegate testing to external tools like Playwright or Cypress (e.g., the Next.js docs), treating testing as an afterthought rather than part of the framework itself.
Why SSR makes testing confusing
- SSR blurs the line between backend and frontend. In theory this sounds powerful; in practice it creates serious testing problems.
- You need to test both frontend and backend behavior, but SSR frameworks don’t offer a clear or opinionated way to do that. Their documentation usually falls into one of three categories:
- Completely missing
- Mentioned briefly
- Outsourced to E2E tools like Playwright or Cypress
“Here’s how to build fast. Testing is your problem.”
From a company perspective—especially one building a long‑lived product—this is a big red flag. You want:
- Tests that are easy to write
- Tests that reliably fail when something is actually broken
- Tests that help reproduce bugs
- Tests that evolve with the product
Most SSR frameworks don’t provide that. They optimize for speed of development, but you start accumulating testing debt from day one.
The reality of unit‑testing frontend code in SSR
- Technically possible, but in practice unit testing often becomes “testing for the sake of testing.”
- Typical pitfalls:
- Mocking half of the system
- Testing implementation details
- Not testing real user interactions
Eventually you fall back to Playwright, which introduces a new set of problems:
- Tests live outside your app
- They’re slower
- They’re harder to debug
- They don’t feel like part of your development workflow
This pattern is extremely common in Next.js applications. To this day I haven’t seen a truly robust testing setup for Next.js that:
- Fails only when something meaningful is broken
- Avoids implementation details
- Is easy to write and maintain
By “robust,” I mean tests that actually protect the product—not just increase coverage numbers.
A notable exception: React Router
In the React ecosystem, React Router (specifically createRoutesStub) stands out.
What createRoutesStub gives you
- Mock routes with loaders and actions
- Create different scenarios per page
- Test frontend behavior realistically
- Test loaders and actions separately as pure functions
This separation of concerns is something many frameworks try to replicate—but React Router gets it right.
Benefits for testing
- Straightforward – the API is simple to use.
- Explicit – you clearly see what is being stubbed.
- Predictable – behavior is deterministic.
Even better, migrating from SPA data mode to framework (SSR) mode is incremental and low‑risk.
After researching multiple alternatives and considering that testing is one of the most critical aspects of a product, React Router is the only SSR‑capable solution I’d personally choose today.
Using createRoutesStub with TWD
When building TWD we noticed how powerful createRoutesStub was—and decided to use it in a different way.
Approach
Instead of testing routes in isolation, we:
- Added a dedicated testing route
- Executed TWD tests inside the real application
- Rendered pages using route stubs
- Controlled loaders, actions, and scenarios per test
Setup
// src/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("todos", "routes/todolist.tsx"),
route("testing", "routes/testing-page.tsx"),
] satisfies RouteConfig;
// app/routes/testing-page.tsx
let twdInitialized = false;
export async function clientLoader() {
if (import.meta.env.DEV) {
const testModules = import.meta.glob("../**/*.twd.test.{ts,tsx}");
if (!twdInitialized) {
const { initTWD } = await import('twd-js/bundled');
initTWD(testModules, {
serviceWorker: false,
});
twdInitialized = true;
}
return {};
} else {
return {};
}
}
export default function TestPage() {
return (
<div data-testid="testing-page">
<h1>TWD Test Page</h1>
<p>This page is used as a mounting point for TWD tests.</p>
</div>
);
}
// test-utils/setupReactRoot.ts
import { createRoot } from "react-dom/client";
import { twd, screenDomGlobal } from "twd-js";
let root: ReturnType<typeof createRoot> | undefined;
export async function setupReactRoot() {
if (root) {
root.unmount();
root = undefined;
}
// Navigate to the empty test page
await twd.visit('/testing');
// Get the container from the test page
const container = await screenDomGlobal.findByTestId('testing-page');
root = createRoot(container);
return root;
}
// example.test.tsx
import { createRoutesStub } from "@react-router/dev";
import { useLoaderData, useParams, useMatches } from "react-router-dom";
import { Home } from "../routes/home";
import { setupReactRoot } from "./test-utils/setupReactRoot";
describe("Hello World Test", () => {
// root instance to re‑render the stub
let root: ReturnType<typeof createRoot> | undefined;
beforeEach(async () => {
// preparing the root instance
root = await setupReactRoot();
});
it("should render home page test", async () => {
// mocking the component
const Stub = createRoutesStub([
{
path: "/",
Component: () => {
const loaderData = useLoaderData();
const params = useParams();
const matches = useMatches() as any;
return (
<div>
{/* Your test UI here */}
</div>
);
},
loader() {
return { title: "Home Page test" };
},
},
]);
// Render the Stub
root!.render(<Stub />);
await twd.wait(300);
// Check for the element within our test container
// We scope the search to the container to be safe,
// or just search globally since …
});
});
Note: The test above demonstrates how you can render a stubbed route, control its loader data, and assert UI output—all within the real application context, using TWD as the test runner.
TL;DR
- SSR frameworks often leave testing as an afterthought, leading to fragile, hard‑to‑maintain test suites.
- React Router’s
createRoutesStubprovides a clean, opinionated way to test both routing logic and UI in isolation while still running inside the actual app. - By embedding TWD into a dedicated testing route, you get a fast, integrated workflow that feels like a natural extension of your development process.
If you’re building a long‑lived product on an SSR stack, consider adopting this pattern to avoid testing debt from day one.
Example: Integrating twd + React Router
// test file (e.g., HomePage.test.ts)
import { screenDom } from 'twd';
import { renderWithRouter } from './test-utils';
test('renders home page heading', async () => {
renderWithRouter();
// The harness is empty otherwise
const h1 = await screenDom.findByRole('heading', { level: 1 });
twd.should(h1, 'have.text', 'Home Page test');
});
Once the test runs, the route is rendered inside the real application, fully interactive, with all assertions passing.
Choosing the Right Strategy
- Keep using tools that optimize for speed but accumulate testing debt
- Or adopt an approach where testing is part of everyday development
Why This Setup Works Well for SSR Applications
- Real UI testing – the component is exercised in a real browser environment.
- Maintainability and easier debugging – tests mirror actual user flows.
- Long‑lived products – the test suite grows with the codebase without becoming brittle.
- Fast feedback – failures surface quickly, keeping the development loop tight.
If you work on large applications with complex requirements, this approach scales far better than traditional SSR testing strategies.