Using Diffblue Cover on Legacy Java Code: Adding Tests Without Breaking Changes

Using Diffblue Cover on Legacy Java Code: Adding Tests Without Breaking Changes

Legacy Java codebases are the most compelling use case for Diffblue Cover. These are systems that have been in production for years, with no unit tests, where every change carries risk because there's no safety net. Adding tests manually is slow and risky — touching legacy code to make it testable can itself introduce bugs. Diffblue Cover generates tests against the code as it stands, without requiring refactoring first.

This guide covers the practical approach to introducing Diffblue Cover on a legacy codebase: where to start, what to expect, and how to build coverage incrementally without disrupting ongoing development.

Why Legacy Code Is Hard to Test Manually

The standard advice for legacy code is: "Before you change it, write tests. To write tests, you may need to refactor. But refactoring without tests is risky." This circular problem is why legacy codebases stay untested.

Common legacy patterns that make manual testing hard:

  • Static method calls: UserUtils.getCurrentUser() can't be mocked without PowerMock or code changes
  • Singleton dependencies: ServiceLocator.getInstance().getService(UserService.class) is not injectable
  • Direct instantiation: new EmailSender().send(message) instead of an injected interface
  • God classes: Single classes with 3,000 lines and 80 methods, each with side effects
  • Database calls in service logic: Transaction management mixed with business logic

Diffblue Cover can generate tests against this code without requiring refactoring because it works at the bytecode level and uses reflection-based techniques to reach code paths that conventional mocking can't.

Starting Assessment: Where Is Your Coverage?

Before running Diffblue Cover, establish your baseline. Add JaCoCo to the build:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.11</version>
  <executions>
    <execution>
      <id>default-prepare-agent</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>default-report</id>
      <goals><goal>report</goal></goals>
    </execution>
  </executions>
</plugin>
mvn test jacoco:report
open target/site/jacoco/index.html

If you have near-zero unit tests, the report will show 0–10% coverage. That's your starting point. Note which packages have the most complex logic — those are the highest-value targets.

Where to Start on a Legacy Codebase

Don't try to generate tests for the entire codebase at once. Prioritize:

High-value targets (start here):

  • Utility classes with pure logic (formatters, calculators, validators)
  • Service classes that were recently modified or are frequently modified
  • Classes involved in recent production bugs
  • Code you're about to change

Lower-value or difficult targets (defer):

  • Classes with heavy static dependencies
  • Database access objects (DAOs) that mix query logic with business logic
  • Classes that extend framework base classes with complex initialization
  • Auto-generated code (Hibernate entities, JAXB-generated classes)

Running on a Specific Package

# Build first — always
mvn compile test-compile -DskipTests

# Target a specific package rather than the whole codebase
dcover create --class com.example.billing.BillingCalculator

# Or a package
dcover create --include-filter "com.example.billing.*"

# Exclude generated or DAO classes
dcover create --module src/main/java \
  --exclude-filter "com.example.generated.*" \
  --exclude-filter "com.example.dao.*"

Handling Common Legacy Patterns

Static Methods

Legacy code frequently calls static methods that can't be mocked. Diffblue Cover handles many of these by using the real implementation:

// Legacy code with static dependency
public class InvoiceFormatter {
    public String format(Invoice invoice) {
        String date = DateUtils.formatDate(invoice.getDate()); // static call
        return String.format("Invoice #%s dated %s", invoice.getId(), date);
    }
}

If DateUtils.formatDate() is deterministic and doesn't have external dependencies, Cover calls it directly in the generated test. The test becomes:

@Test
void testFormat() {
    Invoice invoice = new Invoice();
    invoice.setId("INV-001");
    invoice.setDate(LocalDate.of(2024, 1, 15));

    String result = invoiceFormatter.format(invoice);

    assertEquals("Invoice #INV-001 dated 15 Jan 2024", result);
}

This is a reasonable test. It's tightly coupled to DateUtils's output format, but for utility logic that rarely changes, that coupling is acceptable.

For static methods that access external state (current time, file system, database), Cover may skip generating tests for those paths or generate tests with limited assertions. That's the right call — you don't want tests that assert the current timestamp.

Long Methods with Many Branches

Legacy code often has methods with deeply nested conditionals that accumulated over years:

public BigDecimal calculateDiscount(Customer customer, Order order) {
    BigDecimal discount = BigDecimal.ZERO;
    
    if (customer.getTier() == CustomerTier.PLATINUM) {
        discount = discount.add(order.getSubtotal().multiply(new BigDecimal("0.15")));
    } else if (customer.getTier() == CustomerTier.GOLD) {
        if (order.getSubtotal().compareTo(new BigDecimal("500")) > 0) {
            discount = discount.add(new BigDecimal("50"));
        }
        discount = discount.add(order.getSubtotal().multiply(new BigDecimal("0.10")));
    }
    
    if (customer.isLoyaltyMember() && order.getItemCount() > 5) {
        discount = discount.add(new BigDecimal("10"));
    }
    
    if (discount.compareTo(order.getSubtotal()) > 0) {
        discount = order.getSubtotal();
    }
    
    return discount;
}

Cover generates tests for multiple branches but may not cover every combination. After generation, check the JaCoCo branch coverage report for calculateDiscount. Any uncovered branch is an input Diffblue didn't exercise — add manual tests for those combinations.

Classes With Heavy Constructor Logic

Some legacy classes do significant work in constructors:

public class ReportGenerator {
    private final Connection dbConnection;
    private final Map<String, Template> templates;
    
    public ReportGenerator() {
        this.dbConnection = DatabasePool.getConnection(); // static factory
        this.templates = loadTemplates(); // reads from filesystem
    }
}

Diffblue Cover often struggles here because the constructor requires external infrastructure. Solutions:

  1. Extract constructor logic into init methods — not always safe on legacy code
  2. Use dcover create --skip-constructor-side-effects — generates tests with less coverage but doesn't fail on constructor issues
  3. Accept that some classes need refactoring before testing — document which classes these are and target them for incremental refactoring

Incremental Adoption Strategy

Don't try to get to 80% coverage in one sprint. A realistic 3-month plan:

Month 1: Establish baseline and tooling

  • Add JaCoCo to the build, publish the baseline coverage report
  • Install Diffblue Cover, run it on 3–5 utility/calculator classes
  • Review, clean up, and commit those tests
  • Set a minimum coverage threshold of 20% in the build (below your current level — just to block regression)

Month 2: Target high-churn areas

  • Identify the 10 classes with the most commits in the last 6 months (these are the ones most likely to introduce bugs)
  • Run Diffblue Cover on each, review and commit
  • Raise the coverage threshold to 30%

Month 3: Cover recent bug locations

  • Pull your bug tracker: which classes had the most bug fixes?
  • Run Cover on those classes
  • Add manual tests for the specific conditions that caused bugs (Cover can't generate tests for bugs it doesn't know about)
  • Raise the threshold to 50%

Reviewing Generated Tests on Legacy Code

Generated tests on legacy code require more scrutiny than on clean code. Watch for:

Tests asserting on side effects you don't want:

// Cover generated this — it asserts the legacy logger is called
// but you're planning to remove the legacy logger
@Test
void testProcess() {
    service.process(order);
    verify(legacyLogger).log(anyString()); // delete this assertion
}

Tests with hardcoded data that reflects legacy bugs:

// Cover observed that calculateTax returns 0 for orders over $10,000
// This might be a bug, not a feature
@Test
void testCalculateTax_largeOrder() {
    assertEquals(BigDecimal.ZERO, taxService.calculateTax(new BigDecimal("15000")));
}

Before committing this test, ask: is this intended behavior or a bug? If it's a bug, document it before committing the test (or don't commit the test at all).

Measuring ROI

Track these metrics over time to quantify the investment:

# Coverage over time
mvn test jacoco:report
# Record: overall line coverage %, branch coverage %

# Time to run test suite
time mvn test
# Record: test execution time in seconds

# Bug escape rate
# Record: number of production bugs per sprint (compare before/after)

A typical ROI profile for Diffblue Cover on a legacy codebase:

  • Week 1: Coverage jumps from 5% to 40% on targeted packages. Test suite still fast (under 2 minutes) because these are unit tests with no I/O.
  • Month 1: First benefit realized — a refactoring triggers a generated test failure, catching a regression before production.
  • Month 3: Team velocity increases because developers trust the test suite and make changes with less fear.

The hard-to-measure ROI is psychological: developers change legacy code more confidently when tests exist, even generated ones.

The Limit of Generated Tests on Legacy Code

Diffblue Cover captures what the code does. It cannot tell you what the code should do. On a legacy codebase, these two things are often different. The code may have accumulated bugs that are now "features" because everything downstream depends on the buggy behavior.

Generated tests codify the current behavior, which means:

  • They protect you from accidental regression (good)
  • They protect bugs from being fixed (requires awareness)

When a generated test breaks because you fixed a bug, that's a false failure — delete the test, fix the bug, write a new test that asserts the correct behavior.

Keep this distinction visible in your team: generated tests are a regression baseline, not a specification. The specification still needs to come from humans who understand the business.

For legacy Java applications without browser or API test coverage, HelpMeTest provides a quick way to add end-to-end test coverage for the user-visible behavior — complementing the unit-level safety net that Diffblue provides at the code level.

Read more

Start now free