Every Testing Library test you have written runs against a simulated DOM. You know this. You accepted it years ago, along with the small pile of things that come with it: no real layout, no real portals, jsdom throwing on APIs it does not implement, and the quiet suspicion that a passing test and a working component are not quite the same claim.
I co-maintain twd-js, which runs tests inside your actual dev server. It was built for flow testing: visit a route, click through the app, assert on what the user sees. Component testing was the thing it did not do, and I said so in print when I compared it to Vitest Browser Mode.
Then I tried calling render() inside a TWD test, mostly to see what would break.
import { render, screen, cleanup } from "@testing-library/react";
import { describe, it, beforeEach } from "twd-js/runner";
import { AppProvider } from "@/context/AppContext";
import { twd } from "twd-js";
import { Add } from "../Add";
describe("Add Component", () => {
beforeEach(() => {
cleanup();
});
it("renders the Add component", async () => {
await twd.visit("/testing-library");
render(<AppProvider><Add /></AppProvider>);
twd.should(screen.getByText("Add Item"), "be.visible");
});
});
Nothing broke. The component mounts into the page, the sidebar shows it running, and the assertion checks an element that exists in a real browser. Same Testing Library API, no fake DOM under it.
That AppProvider is the real one, not a test double. Add uses a hook that reads from context and posts to an API, and in a real browser both of those work, so there is nothing to stand in for. That turns out to matter more than the missing jsdom.
Why this works at all
Testing Library was never tied to jsdom. @testing-library/react renders a component into a DOM node and @testing-library/dom queries it. jsdom is just the DOM most people hand it. Give it a real one and the same code runs, except now getBoundingClientRect returns real numbers, portals go where portals go, and CSS applies.
TWD already runs inside your app in the browser, so the real DOM is right there. render() uses it.
The setup
Two small things make this pleasant.
Render on a blank route. If you mount a component on top of a page that already uses it, your queries find two of everything and Testing Library throws. Give the tests an empty route to render into:
<Routes>
{/* Blank mount point for Testing Library component tests. */}
<Route path="testing-library" element={<div />} />
<Route element={<Layout />}>
{/* the real app */}
</Route>
</Routes>
Then await twd.visit("/testing-library") once at the top of the suite.
Clean up between tests. render() appends to the document and does not remove anything on its own. In jsdom the environment is torn down for you between files, and in a real browser it is not, so renders stack up:
beforeEach(() => {
cleanup();
});
That is the whole setup.
Two things to consider
TWD exposes screenDom, a wrapper around Testing Library queries scoped to your app root so it never matches the TWD sidebar. Testing Library's render() mounts into a fresh div on document.body, which is outside that root. I probed it in a real app to be sure:
container.parentElement=<body> inside #root=false
screen: found screenDom: not found screenDomGlobal: found
So use Testing Library's own screen, or TWD's screenDomGlobal, which queries the whole document. If you pick screenDomGlobal, keep queries specific, because it can also match elements inside the sidebar.
Everything else works normally. twd.should takes any element you hand it, whether a query found it in your app or in a component you just rendered.
Component tests are .tsx. A pattern of /**/*.twd.test.ts skips them silently, with no error and no missing-file warning. They simply never appear in the sidebar:
twd({
testFilePattern: "/**/*.twd.test.{ts,tsx}",
}),
If you also run Vitest in the same repo, exclude the browser tests from it. Vitest matches *.test.tsx by default, collects the TWD files, finds no describe it recognises, and fails the run with No test suite found in file:
test: {
exclude: [...configDefaults.exclude, "**/*.twd.test.*"],
},
Both kinds of test, one run
This is the part I that I love.
Component tests and flow tests are now the same kind of artifact. They are files in the same project, running in the same browser, in the same session, against the same instrumented bundle. So one command runs both:
$ npx twd-cli run
Running 14 test(s)...
Code coverage data written to .nyc_output/out.json
--- Run complete ---
Passed: 14 | Failed: 0 | Skipped: 0
Duration: 6.9s
Ten of those drive the whole app through routing, search, sorting and deletion. Four render a single dialog component in isolation. One coverage file comes out the other end, covering both.
That last part is usually where multi-runner setups get tedious. Component coverage in one report, end-to-end coverage in another, and a merge step that someone maintains. Here there is nothing to merge, because there was only ever one run.
Where each style fits
Rendering a component in isolation is the right move when the component is the subject: a form's validation states, a dialog that opens and closes, a table that sorts. You skip the navigation, you skip the fixtures, and the test says exactly what it is about.
Flow tests stay the right move for anything that crosses a boundary. Routing, data loading, a sequence of screens, state that survives a navigation. Rendering a component in isolation to test those means rebuilding the app around it, which is how component test files end up longer than the components.
The useful change is not that one replaced the other. It is that choosing between them is now a decision about scope, made per test, instead of a decision about which runner and which DOM you are committing to.
Try it
If you already have Testing Library tests, the fastest way to see this is to copy one, change the imports for describe and it to twd-js/runner, add cleanup(), and point it at a blank route.
The example app from this post is on GitHub, with the same component tested in jsdom and in the browser side by side: kevinccbsg/frontend-challenge.
In the next post I look at what happened when I put those two versions next to each other. The jsdom test mocked the hook it was testing through, and once I stopped mocking, it turned out the mocks had been doing most of the work.

Top comments (0)