All articles

Refactor Spaghetti Code with Characterization Tests

Capture legacy behavior with broad API tests, refactor behind the safety net, and deploy small reversible changes while coverage grows.

Source and AI note: This article is based on Gemini’s Devpractice on YouTube. It was generated and edited with the gpt-5.6-sol model.

When tangled legacy code is already serving users, the safest first source of truth is often the code’s current observable behavior. Do not begin by trying to understand and redesign every internal path. Capture known request-and-response behavior with broad characterization tests, then refactor behind that outer safety net.

The code may be unpleasant, but it has one important property: it runs. A rewrite that looks cleaner but changes an unknown use case is not yet an improvement.

Start with the widest net you can control

For an API system, an outer integration test can send a known request and assert the established response. A UI-centered system may require an end-to-end test at a different boundary. The point is to cover the behavior users and clients already depend on before operating on the internals.

Production request and response logs can provide realistic cases only when using them raises no security issue. A repeatable local test environment is preferable to a shared development database that changes under the test.

Build this first net from cases you can actually obtain. It will not prove every hidden behavior, and that limitation should remain visible. The safety comes from replacing some uncertainty with executable evidence, not from pretending the legacy system is now fully understood.

Refactor inward while the outer behavior holds

Once representative outer tests pass, start separating the most painful internal part: a global parameter, a mixed read/write path, or an oversized method. Keep running the broad tests after each incision. As responsibilities become clearer, add narrower tests around the pieces you can now name and reason about.

This direction matters. Starting with tiny unit tests may be impossible when responsibilities are still entangled. The large net protects behavior first; smaller nets become practical as the structure improves.

A full rewrite still needs the same knowledge. Backward-compatible APIs and existing use cases do not disappear because the implementation starts over. Characterization tests make that contract explicit for either refactoring or replacement.

Deploy the understanding in small pieces

A large internal cleanup released all at once recreates the original uncertainty at deployment time. Ship small changes that can be observed and reversed. Where the system allows it, expose limited traffic, watch for missed cases, roll back quickly, and add the discovered behavior to the safety net.

Capture available behavior, make small refactorings, rerun the outer tests, deploy frequently, and expand coverage as missed cases appear. The aim is to increase the area under control while the system remains usable, not to wait for perfect coverage before any work begins.