Back to Blog
Software Testing

How to Test Legacy Code in 2026: Characterization Tests, Dependency Breaking, and Safe Automation Strategies

Avanish Pandey

September 15, 2026

How to Test Legacy Code in 2026: Characterization Tests, Dependency Breaking, and Safe Automation Strategies

How to Test Legacy Code in 2026: Characterization Tests, Dependency Breaking, and Safe Automation Strategies

Testing legacy code effectively requires accepting one constraint that does not apply to greenfield projects: you typically cannot refactor before you test, because refactoring without tests is how you introduce regressions in code you do not fully understand. The practical starting point is characterization tests—tests that document what the code currently does, correct or not—which create a safety net before any modification. Once characterization tests exist, dependency breaking at identified seams allows you to introduce unit tests for isolated components without restructuring the entire codebase. This sequence—characterize, identify seams, isolate, test—is the pattern that makes legacy code safely modifiable in 2026, regardless of the language or framework involved.

The challenge is not purely technical. Legacy codebases in 2026 often lack test infrastructure entirely: no test runner configured, no mocking library installed, no CI pipeline running tests, and no documentation of what the code is supposed to do versus what it actually does. Adding tests to this context requires building infrastructure while simultaneously learning the codebase—a slower process than testing greenfield code but a necessary one before any substantive change is safe. Astaqc’s manual testing services team frequently works on legacy systems where exploratory testing is the first step, not automation, because the behavior space has not been mapped at all.

What Characterization Tests Are and How to Write Them

A characterization test does not assert what the code should do. It asserts what the code currently does, and it passes as long as the behavior does not change. If the current behavior is a bug, the characterization test documents the bug—it does not fix it. This distinction is important: the goal of a characterization test is to create a change-detection net, not to specify correct behavior. Correct behavior comes later, once the codebase is covered and you can safely modify it.

The process for writing a characterization test is to call the unit under test with a representative input, observe the actual output, and write an assertion against that observed output. If the function returns 42 when you expect 40, your characterization test asserts 42—not 40. The test will break if the output changes from 42 to anything else, which is the signal you need before proceeding with refactoring. If you later determine that 40 is correct, you change the implementation and update the characterization test to assert 40, at which point it has become a specification test.

Characterization tests should cover every externally observable behavior of the unit: return values, side effects (file writes, database writes, HTTP calls), exception types, and any other observable outcome. For functions with many possible inputs, focus on the inputs that callers actually use—trace through the call graph to find the representative inputs rather than writing exhaustive coverage of the input space. A practical heuristic is to write enough characterization tests that you would notice any unintentional behavior change during a refactoring pass.

How to Identify Seams and Break Dependencies

A seam is a place in the code where you can change behavior without editing the production code itself—typically by substituting a dependency. Michael Feathers defined the concept in “Working Effectively with Legacy Code,” and it remains the most useful mental model for introducing testability into code that was not designed for it. The three types of seams that appear most frequently in legacy codebases are object seams (subclass and override a method), link seams (substitute a library at link time, used in C/C++ codebases), and preprocessor seams (conditionally compile test stubs).

For most web application and API codebases in 2026, the relevant seams are object seams and interface seams. A class that directly instantiates its dependencies—creating a database connection inside a constructor, calling a third-party API directly from a business logic method—has no seam for dependency substitution. Introducing a seam means extracting the dependency behind an interface (or abstract class) and passing the dependency in rather than creating it internally. This is the minimal change needed to make the class testable in isolation, and it is the safest change to make because it does not alter behavior—only the structure of how the dependency is obtained.

The practical sequence for breaking a dependency is: identify the dependency that makes the code hard to test, extract it behind an interface, inject the interface through the constructor or a setter, write the unit test using a test double (stub, mock, or fake), and verify the characterization test still passes. The characterization test is the regression check that the refactoring preserved the behavior. Astaqc’s test automation services team uses this exact sequence when adding automation coverage to legacy codebases for clients who need to increase their coverage before a refactoring or migration project.

Dependency Type Common Pattern in Legacy Code How to Break It Test Double Type
Database Direct SQL calls or ORM calls inside business logic methods Extract repository interface; inject through constructor Fake (in-memory implementation) or stub
External HTTP API HTTP client instantiated inline; URL hardcoded Extract HTTP client interface; inject; or use HTTP interception (WireMock, nock) Mock server or stub
File system Direct file read/write in business logic Extract file abstraction interface; use temp directory in tests In-memory filesystem or temp directory
Clock / time Direct calls to Date.now(), System.currentTimeMillis(), datetime.now() Inject a clock abstraction or use a test library clock override Fake clock with controlled time
Third-party SDK SDK client instantiated directly; calls scattered through business logic Wrap SDK in adapter class; extract adapter interface; inject adapter Stub of the adapter interface

Test Coverage Strategy for Legacy Codebases

Attempting to achieve 80 percent line coverage on a legacy codebase before any refactoring is an inefficient use of time and often not achievable without significant code changes. A more practical coverage strategy prioritizes the code that is about to change, then the code with the highest risk of regression, then code that is called frequently by users.

Coverage priority for legacy code, in order:

  • Code in the change scope of the current ticket. Before modifying any legacy code, write characterization tests for the specific methods and classes that will change. This is the minimum viable safety net. No change should go to production without at least one characterization test for each modified unit.
  • High-traffic paths. Instrument the application with basic metrics or review access logs to identify which code paths handle the most user interactions. A method called ten thousand times per hour has higher regression risk than one called twice per day. Prioritize characterization tests for high-traffic paths.
  • Code adjacent to recent bugs. Legacy codebases accumulate bugs in specific areas—components that were patched many times, or modules that interact with external systems under changing contracts. Cluster tests around these high-defect-density areas first.
  • Stable code with no recent changes. Do not spend time adding tests to code that has not changed in two years and has no planned changes. The opportunity cost is too high relative to the regression risk.

Once characterization tests cover the change scope, the next layer is end-to-end smoke tests that verify the critical user flows work after changes are deployed. For most legacy applications, three to five end-to-end tests covering the most important user journeys—authentication, core transaction, export or report—provide a practical regression check without the cost of full E2E coverage. These smoke tests can be written in a no-code platform like TestInspector without requiring the legacy application’s internals to be refactored for testability. The smoke tests operate at the HTTP and browser layer; they do not care about the code structure below them. See the manual vs. automated testing guide for a framework on where to apply each test type.

Common Mistakes Teams Make When Testing Legacy Code

The most common mistake is attempting to write unit tests before adding characterization tests and seam refactoring. This produces tests that are difficult to write, require large amounts of mocking to compile, and fail after any significant refactoring. The characterization-first sequence exists precisely to avoid this: write tests against the current observable behavior, then refactor toward testability, then add specification tests for the intended behavior.

The second common mistake is using integration tests as a substitute for unit tests in legacy codebases. Integration tests that hit a real database or external service are easier to write for tightly coupled legacy code—there are no dependencies to break, and the test just calls the full stack. The problem is that integration tests are slow, require external infrastructure, and provide imprecise failure signals: a failure tells you something is wrong but not where. For legacy codebases specifically, imprecise failure signals are especially costly because the code is unfamiliar. Unit tests with test doubles give you the precise failure signal you need to make changes confidently.

A third mistake is treating the legacy codebase as a monolith when refactoring toward testability. Large-scale refactors that touch many files simultaneously produce large diffs that are hard to review and high-risk to merge. The safer approach is strangler fig refactoring: introduce new, well-tested code alongside the legacy code, route traffic to the new code incrementally, and retire the legacy code module by module. This approach keeps changes small, reviewable, and reversible at each step. Astaqc’s outsourced QA guide covers how to bring external QA capacity into legacy refactoring projects without disrupting the development team’s throughput.

Frequently Asked Questions

What is the minimum viable test setup for a legacy codebase with no existing tests?

The minimum viable setup is a test runner that can execute tests in CI without any code changes to the production application. For Node.js, this is Jest with a basic configuration pointing at test files. For Java, this is JUnit with Maven or Gradle configured. For Python, this is pytest with a minimal conftest.py. The goal is to have a test runner that passes when run on main branch code—even with zero tests—so that adding tests is a simple file addition, not a configuration project. Attempting to add tests before the test runner works reliably in CI creates a compound problem: you are debugging test infrastructure and test logic simultaneously.

How do you write characterization tests for code that has side effects like sending emails or charging payments?

The approach is to use a spy or mock on the side-effecting dependency so the test captures what the code attempts to do without executing the side effect. Install a test double on the email sender and assert that it was called with the expected recipient, subject, and body. Install a test double on the payment SDK and assert that the charge was attempted with the expected amount and customer ID. The characterization test documents the calling behavior—what arguments the production code passes to the dependency—not the dependency’s own behavior. This is the seam approach: test the code up to the boundary, not across it.

Should teams use static analysis to identify high-risk legacy code before writing tests?

Static analysis tools like SonarQube, CodeClimate, or language-specific linters can identify cyclomatic complexity, duplication, and code smell hotspots in legacy codebases. These metrics are useful for prioritizing where to invest in characterization tests: high-complexity methods with no tests are the highest-risk change targets and should get coverage first. The limitation is that static analysis reports on structure, not on actual runtime behavior, and some high-complexity legacy code is stable and rarely changed. Use static analysis to generate a list of candidates, then apply the actual-traffic and change-history filters to prioritize within that list.

How do teams handle legacy code that is too coupled to test without a large refactoring?

The strangler fig pattern is the standard approach: rather than trying to add tests to the coupled code, write new, testable code alongside it and route new feature work through the new code. Over time, the new code handles an increasing share of the system’s work, and the coupled legacy code handles less. Each new component written this way is fully tested from the start. The legacy code is retired incrementally rather than refactored wholesale. This approach avoids the high-risk large refactor while still building a testable codebase over a timeline of months to years, depending on the system’s complexity.

What is the right balance between characterization tests and specification tests for legacy code?

Characterization tests are temporary scaffolding; specification tests are the permanent target. As you refactor legacy code and clarify what the correct behavior should be, replace characterization tests—which assert the current behavior—with specification tests that assert the intended behavior. The ratio shifts over time: a newly covered legacy module might have 100 percent characterization tests initially, transitioning to 50 percent characterization and 50 percent specification as refactoring progresses, and eventually to mostly specification tests once the module is well understood and well structured. Do not delete characterization tests until the refactoring they protect is complete and the specification tests cover the same behavior space.

How can teams use TestInspector alongside unit tests for legacy applications?

TestInspector operates at the browser and HTTP layer, which is entirely independent of the application’s internal structure. Legacy applications with no unit tests can still have E2E smoke tests written in TestInspector’s chat interface—the tool does not require the codebase to be refactored for testability. This makes TestInspector a practical first layer of test coverage for legacy web applications: write smoke tests that verify the critical user flows work before starting any internal refactoring. The smoke tests provide regression protection during refactoring, complementing the unit-level characterization tests you add as you work through the codebase. Astaqc’s software testing services team combines both approaches when working on legacy modernization projects—E2E coverage through TestInspector, unit coverage through characterization tests—to maximize regression protection throughout the transition.

You cannot refactor before you test, because refactoring without tests is how you introduce regressions in code you do not fully understand. Characterization tests create the safety net that makes refactoring safe.

Avanish Pandey

September 15, 2026

icon
icon
icon

Subscribe to our Newsletter

Sign up to receive and connect to our newsletter

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Latest Article

Ask our AI assistant…