Refactoring Without Breaking Everything: Why Tests Come Before the Refactor
I still remember the moment the test run finished and the summary line came back red. 42 failed, out of a suite we'd only just finished writing. My first thought wasn't relief that we'd caught something. It was closer to dread, because I already knew what most of those failures were going to be about before I opened a single one.
the redesign that came first
A few months earlier, the team had put this construction industry app through a full UI redesign. It had started life as a full stack replacement for a workflow that had been living in Excel, built fast and shipped as a working system the client could actually run their business on. A while in, we moved it from an older component approach to Tailwind CSS v4 and shadcn/ui, and along the way we consolidated some patterns that had grown messy. Sheets got folded into Dialogs. Navigation got flattened from a deeper hierarchy into something simpler.
None of that was reckless. It was the kind of cleanup every app needs eventually, and the reasoning behind each change held up on its own. The problem was sequencing. We did the redesign before we had any real end to end coverage for the screens it touched. There was no safety net under the cut, just the redesign itself and whatever manual clicking around happened before it shipped.
building the suite afterward
The original build of the app had been put together fast, and like a lot of projects built under that kind of pressure, it went out without a Playwright suite. We built one afterward, once the pace of feature work made it obvious we needed something automated watching for regressions. That's when we ran it for the first time against the redesigned app and got the 42 failures.
Almost all of them turned out to be stale selectors, tests written against class names and structures that the shadcn migration had already replaced. That part was tedious but not alarming. Selectors rot, that's expected, and updating them is routine maintenance. What wasn't routine was finding, buried in that same batch of failures, that an entire Edit Project feature had been silently dropped or broken by the redesign. Nobody had caught it, not in review, not in the manual click through before merging it. The tests found it because they were the first thing to actually exercise that flow end to end since the change went in.
That part of the story stuck with me longer than the selector cleanup did. The redesign itself wasn't the mistake. Shipping it with no coverage watching the behavior underneath it was.
what unit tests catch that e2e tests don't
Once we'd cleaned up that first run, the instinct going into the next refactor was different. Before touching the code, we asked what the smallest unit of behavior was that we were about to change, and we wrote or strengthened tests around that first. Unit tests are good at pinning down a function's contract: given this input, this output, every time, regardless of what's rendered around it. They run fast enough that you can lean on them constantly while you're in the middle of a refactor, and when one fails it usually points at almost exactly the line that broke.
What unit tests can't tell you is whether the pieces still work together the way a person experiences them. A component can pass every unit test in isolation and still end up wired into the page incorrectly, or end up sitting under something it shouldn't be sitting under. That's a different category of bug, and it needs a different kind of test to catch it.
We had a good, concrete example of exactly that gap already living in the codebase. An AG Grid table had a row click handler, onRowClicked, that listens on the native DOM event rather than going through React's synthetic event tree. On the screen for invoices awaiting confirmation, clicking "Review" was supposed to open a payment confirmation modal. Instead, because the grid library and the framework around it handle events differently, a read only detail drawer also opened and stacked underneath the modal, every single time, for every user. Unit tests on the drawer component and unit tests on the modal component would both have passed cleanly. Neither one owns the bug. It only exists in the interaction between them, which is exactly the kind of thing only an end to end test, one that actually clicks the button and looks at what's on screen afterward, was ever going to catch.
the refactor, then the proof
With that lesson already learned once, the actual refactor followed a pattern that felt almost boring by comparison, which was the point. Strengthen or write the unit tests around the piece of logic being changed. Add or extend e2e coverage around the behavior users actually see that touches that piece, especially anything involving modals, drawers, or overlapping UI, since that's where the AG Grid bug had taught us to be suspicious. Do the refactor. Then run everything and treat a fully green suite as the actual bar for "behavior didn't change," not a visual glance at the screen or a quick manual click through.
That last part matters more than it sounds. "Behavior didn't change" is a specific claim, not a vibe. It means the same inputs produce the same outputs, the same user actions produce the same visible results, and the same edge cases that were handled before are still handled. A test suite that actually encodes those specifics is what lets you say that sentence with confidence instead of hoping it's true.
By the time this project's testing matured, it had grown into 1,322 unit tests and a Playwright suite spanning 27 specs, running live against a real Supabase backend and a real sandbox from a third party, a Xero demo tenant, rather than against mocks standing in for both. That scale wasn't built to look impressive. It was built reactively, spec by spec, specifically to catch regressions from ongoing feature work after a redesign had already slipped a real regression through once.
where tests don't give you enough confidence
I'd be lying if I said the suite catches everything now. It doesn't, and treating it as if it does is its own kind of risk. A green test suite tells you the behaviors someone thought to write a test for are still working. It says nothing about the behaviors nobody thought of yet, and a redesign or a big refactor is exactly the kind of change that tends to touch things nobody was watching for.
For anything touching money, like the payment confirmation flow that surfaced the AG Grid bug in the first place, we didn't stop at green tests. We did a manual pass through the real flow against the Xero sandbox before calling a change done, on the theory that a human looking at the actual screen catches a different class of problem than an assertion does. For bigger structural changes, staged rollout is another lever worth pulling once there are real users to roll out to, shipping to a smaller slice first and watching for anything odd before it reaches everyone else. None of that replaces the test suite. It fills the gap the suite can't close on its own, which is confidence about the things you didn't think to test.
the actual lesson
The 42 failures we found that first day were a real save, and I don't want to undersell that. A whole feature had broken and the tests found it before it ever reached the client in that state. But the honest accounting has to include the other side too: that feature had been sitting broken, silently, since the redesign went in, because the safety net arrived after the cut instead of before it. The tests did their job the moment they existed. The cost was entirely in the gap before they existed, and that gap is worth remembering the next time a redesign feels urgent enough to skip ahead of the coverage that should sit underneath it.