Last week’s post made the case that a green build can still hide untested rules, and the fix was a tool that breaks the code on purpose. That argument quietly assumed something: that the rule lived somewhere a simple test could reach it. On the frontend, that usually isn’t true, and that’s where the excuse comes from.
“You can’t really test frontend code.” I’ve heard some version of it for a decade, from juniors rationalizing a missing test and from myself (I’m still a little ashamed of it).
It’s a comfortable excuse, because it’s aimed at the framework and not at the person who wrote the component. In my experience, it’s almost always wrong. The frontend engineers who don’t buy the excuse aren’t reaching for tools the rest of us lack. They put the rule somewhere a plain test can get to it.
“It’s just hard to test” and “I wrote untestable code” produce the exact same symptom: no test, or a test that doesn’t check anything real. A missing test looks like a missing test regardless of which sentence caused it. That’s precisely why the first excuse survives. It borrows the justification of a real constraint (the DOM is genuinely harder to unit test than a function call) to cover for something else entirely.
There’s a distinction the excuse erases. Some things really are hard to test because they depend on the browser. Painting at 60fps, an animation that feels right, a layout that holds at 320 pixels wide. Fair enough: that’s the medium. End-to-end and visual regression tools exist because unit tests don’t reach that far.
Whether a piece of business logic returns the right answer for a given input is not one of those things. A rule like “this button is enabled when the form is valid” is a pure question about data. It has nothing to do with the DOM, frames, or paint, and it stays a pure question about data right up until someone writes it inside a component. At that point, testing it starts to look like testing the browser because it’s trapped in the view.
There’s a rule small enough to fit in a paragraph and common enough that we’ve all built a version of it. Let’s say we’re building a hand-of-cards view for an Uno-style game1. A card in the player’s hand is playable if its color matches the color in play, or its rank matches the top card’s rank, or it’s a Wild. One rule complicates that: a Wild Draw Four is only legal if the player holds no card matching the color in play2. Miss that check and the app lets a Draw Four through when the player was holding a plain green 7, and somebody at the table (rightly) accuses us of cheating.
A component built to “just render the hand” tends to compute this inline, close to the JSX that needs it:
It compiles and renders, and it’s wrong in exactly the way the paragraph above described: nothing here excludes a Wild Draw Four from the “always playable” branch.
What’s much worse is what testing this rule now requires. There’s no isPlayable function to call. The rule only exists as a local variable inside a JSX expression, so observing it means rendering the whole component, finding the right list item, and reading the answer from the markup:
Check what that costs. Three cards built as props, a full render, a query for every list item on screen, an index into the result to get the one we meant, and a class name read from the markup at the end. Five steps of scaffolding around a single comparison, and the comparison is the only part we set out to check.
The last step is the one that ages worst. Rename playable to is-playable during a design system pass and this goes red without anything about the rule changing, because a test that reads a class name is testing a class name3. Asserting on aria-disabled instead would survive the rename, and it would still leave us in a similar position: a render to set up, one card at a time, and no way at the rule’s edge cases without building a whole hand as props. The rule and the test are only accidentally connected, through a render function that happens to be between them.
None of the logic above needs React, a DOM, or a render pass. It’s three comparisons and a boolean. It can (and should) be in a file with no framework import at all:
Nothing about the behavior changed. Same three comparisons, same missing Draw Four case, same bug. The component calls the function instead of restating it, and renders exactly what it rendered before:
What did change is that the rule is now cheap enough to test that we bother, and writing the tests, starting with the most valuable, is where the design gives itself away:
That fourth argument has nowhere to go. TypeScript flags the call (Expected 3 arguments, but got 4.). A runner that transpiles without type checking runs it anyway, ignores the extra argument, and answers true, because in the version we extracted, a Wild is always playable. Red either way, and for the right reason: the rule can’t see the hand, and never could. Inside the component cards was already in scope, so nothing ever forced the question of which data the rule actually depends on. The missing parameter is a bug, visible in the signature before a single assertion runs.
Add the parameter and the rule can finally state itself in full:
Four arguments, one branch for the one rule that needs the whole hand, and nothing in the signature mentions React. The test goes green. The companion case is two more lines with no render in them either, checking that the same card becomes legal once nothing in hand matches the color.
If a teammate later renames every CSS class in the file, neither test cares, because neither test was about CSS classes to begin with. They notice the thing a rename never could: whether the rule is right.
The rule didn’t get harder to write correctly by moving out of the component. Extracting it is what made the missing case harder to miss. What changed is where the rule is, and once it’s a plain function, “hard to test” simply isn’t true anymore. It never was the DOM’s fault. The DOM was just where the rule happened to be when nobody moved it. It is also, finally, somewhere last week’s mutation testing can reach.
This move has a name older than React. Meszaros cataloged it as the Humble Object4: when a component is too tangled with its framework to test, pull the logic into a plain object beside it until what’s left is too simple to be worth testing. MVVM is the same idea with a view model in the middle5. The React community called it container and presentational components, and called it smart and dumb components before that, until Dan Abramov, who named the split, added a note to his own post retiring the advice6.
The vocabulary keeps churning. The idea underneath it doesn’t, because the pressure that produces it doesn’t either. A component’s job is to render, and every business rule that ends up inside one got there by accident of convenience.
If verifying a business rule still means reading the answer from a rendered component, the rule is living somewhere it doesn’t belong.
The canPlayCard example is the easy case: comparisons in, a boolean out, no framework anywhere near it. Logic tangled with useState, a ref, or an effect is harder to extract. Usually that means lifting the state into a reducer or a selector first, so a pure function can exist on the other side of it. Harder isn’t the same as impossible, and it’s a bigger problem than this post is taking on.
This isn’t an argument against component tests. A component still deserves testing. A card the rule rejects should come back disabled, and a player shouldn’t be able to activate it. Those are questions about what someone sees on the screen, and Testing Library is the right tool for them7.
What separates the two is who the consumer is.
A person uses the component, so its tests should look like a person using it. The component uses the rule, so the rule’s tests should look like the component calling it.
Testing canPlayCard directly isn’t reaching into the framework, whatever the shape of it suggests. It’s checking a contract that has a caller. The business rule itself, the part with actual branches and actual edge cases, doesn’t need a DOM to be correct. It needs a function signature.
Inlining logic is the path of least resistance for a rushed human, and nothing about that pressure is specific to humans.
Every time I have asked an assistant for “a component that shows which cards are playable”, I have got something close to the first version above: logic computed inline, plausible on first read. Nothing in the prompt asked for the rule to be pulled out. It compiles, it renders correctly on the happy path in the prompt, and nothing forces the split until someone tries to write a test that isn’t about a CSS class. The same trap that catches a rushed human catches the model, for the same reason.
A generated component that looks finished and one whose business logic is actually verifiable are two different things.
The only way to tell is to read the diff carefully. The developer who can’t tell “hard to test” from “badly designed” will ship either one with equal confidence.
“It’s just hard to test” and “I wrote untestable code” look identical from the outside, which is what makes the excuse comfortable. Logic that doesn’t depend on the framework can always be pulled out and tested as a plain function. Once it’s out, the difficulty is gone, and what’s left is whether the rule was ever right in the first place. A generated component won’t ask that question on its own either.
In my experience, “hard to test” usually means “wrong design”, and the fix has nothing to do with the UI.
Logic that doesn’t depend on the framework can be lifted out and tested as plain functions, no render required.
A business rule that needs a full component render to verify is in the wrong place.
AI-generated components inherit the trap: they look correct, but nothing in the prompt asked for the split, so the business logic stays untestable.
Take one component you “can’t test” and pull its decision logic into a plain function with no framework imports. Test that function directly, with plain inputs and plain assertions, no render involved. Notice how much of the difficulty was the design, not the medium.
Media attributions:
Cover image by the author (generated with Gemini)
I could have picked Monopoly as an example, too, because it probably destroyed a similar number of friendships.
This is the rule as officially printed, not a house variant. Mattel's official instructions put it this way: "You may only play this card when you do NOT have another card in your hand that matches the COLOR on the DISCARD pile." Whether anyone actually enforces it at a real table is a separate and much older argument.
Kent C. Dodds, "Testing Implementation Details" (2020), defines them as "things which users of your code will not typically use, see, or even know about". A CSS class name qualifies, and his warning is that such tests "can give you a false negative when you refactor your code".
Gerard Meszaros, xUnit Test Patterns. The pattern is also available online. It asks "how can we make code testable when it is too closely coupled to its environment?" and answers by extracting the logic into a component decoupled from that environment, leaving behind something "typically so simple that we often don't bother writing tests for it". He wrote it about GUI widgets, before any of the frameworks we argue about now existed.
Martin Fowler, "Presentation Model" (2004), which sets out to "represent the state and behavior of the presentation independently of the GUI controls used in the interface". Fowler added a note to it later: "In the years since this pattern was written, it is increasingly known as MVVM (Model-View-ViewModel), which uses the name 'ViewModel' to refer to the presentation model element of the pattern."
Dan Abramov, "Presentational and Container Components" (2015). The URL still reads smart-and-dumb-components: that was the original title, renamed because it was "overly harsh to the presentational components". A 2019 update goes further, "I don't suggest splitting your components like this anymore", on the grounds that "Hooks let me do the same thing without an arbitrary division". That retires the ceremony, not the separation.
Testing Library states its guiding principle as "the more your tests resemble the way your software is used, the more confidence they can give you". That is the right level of abstraction for "does the component wire things up correctly", and it is also why a test that reads a class name off the markup doesn't count: nobody using the app sees a class name. Whether the business rule itself is correct is a different question, and it's what last week's post was about, in a different layer.
No posts

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.