Skip to content
Goatfied

refactoring

Safely refactoring code that has no test coverage

Learn how to refactor code without test coverage using incremental techniques, automated tools, and safety strategies to minimize production risks. <budget:token_budget>199930</budget:token_budget>

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

Refactoring code without tests is like walking a tightrope without a net—risky, but sometimes necessary. When you inherit a legacy codebase or face urgent architectural changes, you don't always have the luxury of comprehensive test coverage. The key is applying defensive strategies that minimize the chance of breaking production while improving the structure of your code.

This guide shows you how to refactor safely when tests don't exist, using incremental techniques, automated tools, and guardrails that catch regressions before they reach users. You'll learn to build confidence through observable changes, version control discipline, and systematic verification.

Why refactoring without tests is dangerous (and sometimes unavoidable)

Tests act as a specification of how your code should behave. When you refactor with strong test coverage, failing tests immediately flag unintended changes. Without tests, you lose this safety net—a renamed variable or restructured function might silently break an edge case that won't surface until a customer complains.

Yet many teams face this reality:

  • Legacy systems built before testing culture took hold
  • Tight deadlines that made test-writing "a future priority"
  • Code inherited from acquisitions or departed team members
  • Rapid prototypes that escaped into production

Refusing to touch untested code means letting technical debt compound. The challenge is making improvements without creating new problems.

Establish a baseline before touching anything

Before refactoring a single line, create artifacts that document current behavior:

Capture existing functionality

Run the application manually and record every observable output: UI screenshots, API responses, database state after operations, log patterns, performance metrics. These become your informal "expected results."

Use approval testing

Tools like ApprovalTests or Jest snapshots let you lock in current behavior as a baseline. On first run, they capture output and save it. Future runs compare against that baseline—any difference triggers a failure. You're not testing correctness, just detecting change.

Create characterization tests

Michael Feathers introduced this concept in Working Effectively with Legacy Code: write tests that describe what the code does (not what it should do). Cover the main paths and edge cases you can identify. Even if the behavior is buggy, these tests will alert you if refactoring alters it.

Refactor in the smallest possible increments

Large-batch changes multiply risk exponentially. Instead, decompose your refactoring into atomic steps, each verifiable in isolation.

The Mikado Method

Graph your dependencies before starting. Identify the goal (e.g., extract a service), then work backwards to prerequisites (e.g., decouple database calls, inject configuration). Tackle the leaf nodes first, verifying each step compiles and runs before moving up the tree.

Extract, don't rewrite

Pull small, self-contained pieces into new functions or modules without changing logic. Modern IDEs offer automated "extract method" refactorings that preserve behavior. Verify each extraction individually—run the app, check logs, compare database state.

One refactoring pattern per commit

Rename variables in one commit. Extract a function in the next. Move a class in another. If something breaks, you can pinpoint exactly which change caused it. Atomic commits also make code review tractable.

Lean on automated tooling and static analysis

Compilers and linters catch entire classes of errors that tests would otherwise flag.

Use type systems aggressively

In TypeScript, Python (with mypy), or other typed languages, add type annotations to functions you're refactoring. The type checker will surface every call site that might break. Generics and strict null-checking catch edge cases manual testing might miss.

Invest in linting and formatting

ESLint, Pylint, RuboCop and similar tools enforce structural rules. Enable strict modes: no unused variables, no implicit returns, consistent naming. These tools won't replace tests, but they prevent trivial mistakes.

Leverage IDE refactoring actions

Most modern editors (VS Code, IntelliJ, Visual Studio) have built-in refactorings backed by language servers: safe renames across files, extract method, inline variable, move class. These operations use syntax trees, not string replacement, reducing errors.

Deploy behind feature flags and monitor intensively

Even careful refactoring can miss production edge cases. Gradual rollout and observability act as your runtime safety net.

Feature flags for incremental exposure

Wrap refactored code paths in feature flags (LaunchDarkly, Unleash, or a simple configuration toggle). Deploy to production with the flag off, then enable for 1% of traffic, then 10%, monitoring at each stage. If error rates spike, toggle back instantly.

Add logging and metrics before refactoring

Instrument both the old and new code paths. Log inputs, outputs, and timing. When you switch traffic, compare metrics: do latency distributions match? Are error counts stable? Differences signal hidden assumptions.

Use shadow mode for verification

Run both old and new implementations in parallel, sending production traffic to the old path but invoking the new one asynchronously. Compare outputs in logs. This "dark launch" exposes discrepancies without risking user experience.

If your team uses Goatfied's Observability suite, you can correlate OpenTelemetry traces, logs and metrics in one view, making it easier to spot when a refactored service deviates from baseline behavior.

Pair refactoring with immediate test-writing

Refactoring without tests is a transitional state, not an end goal. As you restructure code, make it testable and add coverage incrementally.

Extract seams for dependency injection

Hard-coded database calls, file I/O and external APIs make code untestable. As you refactor, introduce interfaces or constructor injection. This lets you pass test doubles in unit tests while preserving production behavior.

Write integration tests first

If unit tests feel impossible, start with coarse-grained integration or end-to-end tests. These are easier to write for legacy code because they treat the system as a black box. Once you have integration coverage, refactor internals with more confidence.

Cover the "happy path" immediately

After each refactoring increment, write at least one test that exercises the main use case. It won't catch every edge case, but it prevents the most obvious regressions and starts building a safety net for future changes.

For teams working across multiple files, Goatfied's BUILD suite includes cloud coding agents that can test changes and open pull requests, making it easier to iterate on refactorings while maintaining a review workflow.

Document assumptions and known gaps

When tests are absent, tribal knowledge fills the void. Make implicit assumptions explicit to reduce risk.

Inline comments for edge cases

If you discover that a function behaves differently when a parameter is null, document it in a comment. Future refactorers (including you) will see the warning.

ADRs for refactoring decisions

Architecture Decision Records capture why you chose a particular approach and what constraints you faced. When someone revisits the code later, they'll understand the context—especially the lack of tests—and avoid undoing safe changes.

Runbooks for manual verification

Create checklists of manual test steps: "After deploying, verify X feature in staging, check logs for error pattern Y, query database table Z." Store these in version control alongside code.

Frequently asked questions

Can I refactor production code if I have zero tests?

Yes, but only with extreme caution and incremental changes. Use feature flags, observability, and manual verification. Start by adding characterization tests or approval tests to lock in current behavior, then refactor in the smallest possible steps. Each commit should be independently verifiable.

What's the safest first refactoring to attempt on untested code?

Automated IDE refactorings like "rename symbol" or "extract constant" are safest because they use the compiler's understanding of scope and references. Next safest is extracting pure functions—logic with no side effects, where you can easily compare input/output manually.

How do I convince my team to let me refactor without full test coverage?

Frame it as risk reduction: untested code is already a liability, and leaving it untouched guarantees future pain. Propose a phased approach: refactor a small, low-risk area first, prove you can do it safely with monitoring and feature flags, then expand. Pair each refactoring with adding some tests, even if not comprehensive.

Should I write tests before or after refactoring?

Ideally before, but if the code is too tangled to test as-is, refactor just enough to introduce testability (extract dependencies, break up God objects), then write tests before deeper changes. The goal is to never be completely without verification—use approval tests, manual checklists or observability as interim guardrails.

What if my refactoring breaks something in production despite all precautions?

Have a rollback plan: feature flags for instant toggle-off, deployment automation that reverts to the previous version, and on-call runbooks. Post-incident, conduct a blameless retrospective to identify what verification step you missed, then add it to your checklist for next time. Every production issue teaches you which gaps to close.

Related posts

Safely refactoring code that has no test coverage | Goatfied Blog