Flat-vector illustration of a QA engineer exploring an ancient temple-like legacy software system with no existing tests, searching for hidden pathways and discovering new test coverage.

How to Test a Legacy Codebase That Has Zero Tests

You’ve inherited a codebase. It’s been running in production for years. It’s critical to the business. And it has zero automated tests.

Making any change is terrifying. How do you know you didn’t break something? How do you add tests when the code wasn’t designed for testing? This is the legacy code dilemma.

Michael Feathers, author of Working Effectively with Legacy Code, famously defines legacy code as “code without tests” . The good news: you can break the cycle. Here’s how.

The Short Answer

To test legacy codebase with zero tests, start by adding characterization tests (also called Golden Master or approval tests). These tests capture the current behavior of the system without judging whether it’s “correct.” You run the code with realistic inputs, record the outputs, and use those as your baseline. Once you have this safety net, you can refactor and add more specific unit tests.

Why Legacy Code Has No Tests (And Why It Matters)

Legacy code often lacks tests for several reasons:

Reason Impact
Testing wasn’t a priority when the code was written No safety net for changes 
Deadline pressure pushed testing aside Bugs compound over time 
Code wasn’t designed to be testable Dependencies make it hard to isolate 
Fear of changing the code The legacy code dilemma: to test, you must change; to change safely, you need tests 

 

Root cause table: Testing wasn’t a priority → no safety net. Deadline pressure → bugs compound. Code not designed to be testable → hard to isolate. Fear of changing code → the legacy dilemma. Orange ‘Root Causes’ badge. test legacy code.

The risk of untested legacy code is real. A study found that poorly maintained codebases can have up to 15 times more defects and extend feature development time by as much as 124% .

“Legacy code is code without tests. It’s extremely challenging to change code that doesn’t have tests.” 


Step 1: Start with an End-to-End Test

Before making any changes, add a simple end-to-end test that exercises the entire codebase with representative inputs and verifies the output.

How to do it:

  1. Identify a common use case that the system handles

  2. Prepare a sample input (e.g., a test file, API request, or database state)

  3. Run the code and capture the output

  4. Write a test that runs the code with the same input and compares the output to your captured baseline

Example (simple script):

bash
python legacy_script.py input.txt > output.txt
cmp output.txt expected_output.txt

This gives you immediate feedback when changes break existing behavior.

End‑to‑end test commands: python legacy_script.py input.txt > output.txt – run script with sample input; cmp output.txt expected_output.txt – compare output with baseline. Orange ‘Start Here’ badge.

Step 2: Add Characterization Tests (Golden Master)

Characterization tests document the actual behavior of the code – not what it should do, but what it actually does.

The process:

  1. Select the code you want to test – pick one class, module, or function

  2. Write a test with an assertion you know will fail – e.g., assertEquals(0, result)

  3. Run the test – the error message reveals the actual behavior

  4. Update the assertion – change it to match what the code actually returns

  5. Repeat with different inputs – build coverage gradually

Key rule: Characterization tests verify existing behavior, not correct behavior. If the code has a bug, the test documents it. You fix the bug later, after you have a safety net.

Vertical flowchart of 4 steps for characterization tests: 1 Select code to test; 2 Write test with failing assertion; 3 Run test – capture actual output; 4 Update assertion to match actual. Orange circles, orange arrows, orange ‘Golden Master’ badge.

Golden Master Testing (Approval Testing)

A powerful form of characterization testing is Golden Master (also called approval testing).

How it works:

  1. Generate random or representative inputs (using a fixed seed for reproducibility)

  2. Run the system with these inputs and record all outputs – this is your “Golden Master”

  3. Review and approve the outputs (save them as .approved files)

  4. When you change the code, run the test again – if outputs differ, you’ll see a diff

Example with ApprovalTests (Java):

java
@Test
public void should_generate_update_quality_output() {
    List<Item> items = generateRandomItems(2000);
    gildedRose.updateQuality(items);
    Approvals.verify(getStringRepresentationFor(items));
}

The first run creates a .received.txt file. If you’re happy with the output, you rename it to .approved.txt. Subsequent runs compare against it.

Advantages of Golden Master testing:

Advantage Why It Matters
Easy to implement on complex legacy systems No need to understand all the logic upfront 
Great for complex outputs (PDFs, XML, images) Checking all attributes with assertions is impractical 
Provides a safety net for refactoring You can make changes confidently 
Works in any language without tooling Simple file comparisons 

Disadvantages to watch for:

Disadvantage Mitigation
Depends on repeatability Mask non-deterministic values (timestamps, IDs) 
Doesn’t infer correctness Only detects changes – use human judgment 
Can generate massive files Use intelligent sampling 

Golden Master testing two‑phase process. Phase 1: Generate random inputs → Run system → Record outputs → .received.txt. Phase 2: Review & approve (orange) → .approved.txt → Subsequent runs compare. Orange ‘Golden Master’ badge.

Step 3: Break Dependencies to Enable Testing

Legacy code often has dependencies that make it impossible to test in isolation: database connections, file systems, network calls. You need to break these dependencies.

The “Seams” technique:

seam is a place where you can alter behavior without editing the code at that location. Every seam has an enabling point – where you decide which behavior to use.

Types of seams:

Seam Type How It Works When to Use
Object seam Subclass or implement an interface; inject the test version Most OO languages 
Link seam Substitute implementations at link time C/C++ with separate test builds 
Preprocessing seam Use macros (#ifdef TESTING) C/C++ 

Example (Object seam – Java):

java
// Production code depends on a database connection
public class OrderProcessor {
    private Database db;

    // The enabling point – constructor injection
    public OrderProcessor(Database db) {
        this.db = db;
    }
}

// Test code injects a fake
public class OrderProcessorTest {
    @Test
    public void testProcessOrder() {
        FakeDatabase fakeDb = new FakeDatabase(); // implements Database
        OrderProcessor processor = new OrderProcessor(fakeDb);
        // ... test logic
    }
}

For dependencies that are hard to break, use fake objects – simplified implementations that emulate the dependency sufficiently for the test.

Three seam types: Object seam – subclass or implement interface (most OO languages); Link seam – substitute at link time (C/C++); Preprocessing seam – use #ifdef TESTING macros (C/C++). Orange top borders and orange ‘Seams’ badge.

Step 4: Use the Legacy Code Dilemma Loop

When dealing with legacy code, you face a cycle:

“When we change code, we should have tests in place. To put tests in place, we often have to change code.” 

How to break the loop:

  1. Identify the change you need to make – what exactly must change?

  2. Identify the points to test – which classes or functions are involved?

  3. Break dependencies so the code can run in a test harness

  4. Write tests using characterization/Golden Master

  5. Make the change with the safety net in place

  6. Refactor the code to make it more testable over time

Cycle diagram: Need to change code → Need tests to change safely → Need to change code to add tests → Break the cycle with seams (orange). Orange arrow breaks the cycle. Orange ‘Dilemma’ badge.

Step 5: Add More Specific Tests After Refactoring

Once you have a characterization test safety net, you can start adding more specific unit tests.

The evolution:

  1. Start with end-to-end tests – broad, slow, but cover the whole system

  2. Add characterization tests – document the current behavior

  3. Break dependencies – introduce seams, fakes, mocks

  4. Add focused unit tests – test individual classes or functions

  5. Refactor incrementally – improve design, replace the safety net with better tests

Tip: As you refactor and add real unit tests, you can eventually retire some characterization tests. They’ve done their job.

Real‑World Example: Testing a Database-Centric Legacy System

A Codurance team faced a legacy system with no tests that was heavily dependent on a database. Here’s how they approached it:

  1. Started manually: Wrote a single scenario by hand – setting up tables, invoking the code, checking results

  2. Hypothesized and experimented: Could they automate the setup from the input data?

  3. Built an automated solution: They recorded what the production code did to the database

  4. Generated tests automatically: Each test contained the input and the expected DB state

  5. Achieved massive time savings: 10 manual tests took 2.5 hours; 1,000 automated tests took 1 hour 40 minutes 

How AI Can Help Test Legacy Code

Recent advances in AI offer new ways to tackle legacy code testing.

AI-driven characterization tests: A dedicated AI agent can read the original legacy code and auto-generate thousands of test cases designed to document current behavior exhaustively. This is particularly valuable when the code has no documentation and you don’t know all its edge cases.

UnitTenX: An open‑source AI multi‑agent system that uses formal verification and large language models to automatically generate unit tests for legacy C codebases. It identifies crash states and creates tests that achieve maximum coverage.

Caution: AI can generate tests but is limited in finding bugs. Use AI to bootstrap coverage, but rely on human judgment for correctness.

Two cards: AI‑driven characterization tests – auto‑generate thousands of test cases for undocumented code. UnitTenX – open‑source AI multi‑agent system for C codebases using formal verification and LLMs. Caution: AI generates tests but human judgment is needed. Orange top borders and orange ‘AI Boost’ badge.

Summary: Your Legacy Code Testing Checklist

Step Action Time to Implement
1 Add an end-to-end test (realistic input, baseline output) 1–2 hours
2 Run characterization tests – capture current behavior 1 day
3 Try Golden Master testing – generate random inputs, record outputs 1–2 days
4 Break dependencies with seams and fakes Varies
5 Refactor incrementally with the safety net Ongoing
6 Add real unit tests for new changes Ongoing

What If You Need Professional Help?

Testing a legacy codebase is one of the most challenging tasks in software engineering. If you’re stuck, TestUnity’s Quality Assurance services can help. We assess legacy codebases, implement characterization test suites, and train your team to maintain them.

Need expert help with legacy code? Contact TestUnity today for a consultation.

Related Resources

TestUnity is a leading software testing company dedicated to delivering exceptional quality assurance services to businesses worldwide. With a focus on innovation and excellence, we specialize in functional, automation, performance, and cybersecurity testing. Our expertise spans across industries, ensuring your applications are secure, reliable, and user-friendly. At TestUnity, we leverage the latest tools and methodologies, including AI-driven testing and accessibility compliance, to help you achieve seamless software delivery. Partner with us to stay ahead in the dynamic world of technology with tailored QA solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *

Index