Skip to content
Goatfied

refactoring

Safely refactoring code that has no test coverage

Learn techniques for safely refactoring legacy code without tests using observability, characterization tests, and incremental changes.

2026-08-318 min readBy Goatfied
Safely refactoring code that has no test coverage

Legacy code doesn't come with a test suite. The module you inherited runs in production, processes real transactions, and has exactly zero automated tests. Now you need to refactor it—maybe to fix a bug, add a feature, or simply understand what it does. The traditional advice is "write tests first," but that can mean weeks of work before you touch the actual problem. Here's how to refactor safely when that luxury doesn't exist.

Establish external observability before you edit anything

The first move is not to open your editor. Before changing a single line, instrument the boundaries of the code you're about to touch. If it's a service, add structured logging at entry and exit points: log the incoming request shape, the response, and any external calls with their latency. If it's a library function, wrap it temporarily with a decorator or middleware that captures inputs, outputs, and exceptions.

This observability layer becomes your implicit test harness. When you refactor, you can diff logs from before and after runs with identical inputs. It won't catch every edge case, but it will surface behavioral changes you didn't intend. In production-like environments (staging, shadow traffic, or even carefully isolated prod requests), this logging gives you a safety net that pure code inspection cannot.

Goatfied's agent loop naturally supports this workflow: you can ask it to add logging or tracing spans as a standalone diff, validate that the logging compiles and doesn't break existing behavior, then commit that instrumentation before the real refactor begins. Small, reversible steps matter when you're working without tests.

Create characterization snapshots with real data

Martin Feathers called them "characterization tests"—tests that describe what the system does, not what it should do. You're not asserting correct behavior; you're capturing current behavior so you can detect drift. The fastest way to build these is to record inputs and outputs from real traffic.

Run your instrumented code against a representative dataset—replayed requests, anonymized production data, or even a script that exercises known code paths. Capture the results as JSON files, database dumps, or plain text. Your characterization snapshot is now: "Given input X, the system currently produces output Y." When you refactor, re-run the same inputs and diff the outputs. Any change is a signal to investigate.

This approach has limits. It won't tell you if the original output was correct, and it can't cover paths you didn't exercise. But it's vastly better than refactoring blind, and you can build it in hours rather than weeks. The key is to automate the replay and comparison so you can iterate quickly.


# Example: capture baseline behavior

def capture_baseline(inputs, func):

    results = {}

    for i, inp in enumerate(inputs):

        try:

            results[f"case_{i}"] = {

                "input": inp,

                "output": func(inp),

                "error": None

            }

        except Exception as e:

            results[f"case_{i}"] = {

                "input": inp,

                "output": None,

                "error": str(e)

            }

    return results



# After refactoring, diff the new results against baseline

Refactor in the smallest possible increments

Without tests, blast radius is your enemy. Every change you make could introduce a bug you won't discover until production. The antidote is to make your diffs so small that you can reason about correctness by inspection and rollback trivially if something breaks.

Instead of "rewrite the payment processing module," think "extract this 15-line conditional into a named function" or "inline this variable that's only used once." Each micro-refactor should be independently reviewable and deployable. If you need to rename a variable across ten files, that's one PR. If you then want to change its type, that's the next PR.

This discipline is tedious but survivable with tooling. Goatfied's plan-constrain-edit-validate loop is designed for exactly this: you describe one atomic change, the agent proposes a minimal diff, the system runs compile and lint checks, and you can validate or iterate before moving on. The cumulative effect of many small, validated steps is a large refactor you can trust.

Use type systems and linters as lightweight contracts

If your language has static typing, turn on strict mode and let the compiler become your test suite. Add type annotations to function signatures, enable null-safety checks, or configure linting rules that enforce invariants. When you refactor, the type checker will flag every call site that might break.

For dynamically typed languages, this is harder but not impossible. Use runtime type checkers (like Python's typeguard or JavaScript's zod) at module boundaries. Add assertions that fail fast if preconditions are violated. Configure linters to catch common mistakes—unused variables, shadowed names, unreachable code. These tools won't prove correctness, but they catch a surprising number of refactoring errors automatically.

Goatfied enforces compile and lint passes before any code lands. If your refactor breaks a type signature three files away, the validation step catches it before you merge. This compile-first approach is especially valuable without tests, because the feedback loop is fast and the error messages point directly to the problem.

Lean on code review and pair programming

When automated tests are absent, human review becomes critical. A second pair of eyes can spot logic errors, unintended side effects, or edge cases you missed. But not all code review is equal. Reviewing a 2,000-line refactor is a recipe for rubber-stamping. Reviewing a 50-line diff that does one thing is actually feasible.

Pair programming is even better for risky refactors. One person navigates the change while the other watches for mistakes in real time. You catch errors seconds after they're introduced instead of hours later in review. If you're refactoring alone, consider recording a short video walkthrough of your changes and narrating your reasoning—it forces you to articulate assumptions and often reveals gaps.

Deploy behind feature flags and monitor aggressively

Even with careful refactoring, you won't know if it works until real traffic hits it. Feature flags let you deploy your changes to production but keep them dormant, then enable them incrementally—first for internal users, then a small percentage of traffic, then everyone. If error rates spike or latency degrades, you flip the flag off without a rollback.

Pair this with aggressive monitoring. Set up alerts on error rates, response times, and any domain-specific metrics (e.g., transaction success rate, cache hit rate). Diff those metrics between the old and new code paths while the feature flag is partially rolled out. If you see divergence, you've caught a bug before it affects most users.

Goatfied's self-hosted deployment model means you can run the platform in your own environment, with access to your metrics and observability stack. You can wire the agent's validation steps to your CI/CD pipeline, so every proposed refactor is gated on passing your lint, compile, and integration smoke tests before it even reaches human review.

Accept that some risk is irreducible

No amount of technique eliminates all risk when refactoring without tests. You're essentially reverse-engineering a specification from an implementation, and some behaviors are too subtle or too rare to capture without exhaustive testing. The goal is not zero risk—it's acceptable risk that's proportional to the value of the refactor.

Sometimes the honest answer is "we need to write tests before we touch this." If the code is genuinely critical, poorly understood, and serves millions of users, investing in a real test suite might be the only responsible path. But often, the techniques above give you enough confidence to make progress without that upfront cost. The key is to be deliberate about the tradeoffs and transparent with your team about the risk you're accepting.

Related posts

Safely refactoring code that has no test coverage | Goatfied Blog