Every Java developer eventually faces the challenge of working with legacy codebases—systems that have grown over years, often with little or no automated tests. These projects may power critical business workflows, but their lack of tests makes even the smallest change risky. Adding unit and integration tests to legacy applications is not just about quality—it’s about ensuring confidence, maintainability, and production stability.
In this tutorial, we’ll explore how to effectively introduce JUnit 5, Mockito, and Testcontainers into legacy Java projects. You’ll learn step-by-step strategies for writing tests in environments with poor design, tight coupling, or outdated practices.
Understanding the Challenge of Legacy Code
What is Legacy Code?
- Legacy code is not just old—it’s any code without sufficient tests.
- Common issues include:
- No clear boundaries between layers.
- Excessive use of static methods or singletons.
- Hidden dependencies and poor modularity.
- Fear of change due to brittle behavior.
Why Testing Legacy Code Matters
- Bug Prevention: Identify regressions before they hit production.
- Maintainability: Easier to refactor code safely.
- CI/CD Confidence: Automated tests unlock faster deployment pipelines.
- Microservices Reliability: Ensures consistent behavior during service evolution.
Step 1: Establishing a Testing Baseline
Before refactoring, start by:
- Adding characterization tests that document current behavior.
- Using JUnit 5 for modern test features:
@Test void shouldReturnDiscountForLoyalCustomer() { LegacyBillingService service = new LegacyBillingService(); double result = service.calculateDiscount("LOYAL", 200); assertEquals(20.0, result); }
- These tests may not assert “correctness” initially but serve as safety nets.
Step 2: Introducing Mockito for Isolation
Legacy code often has tight coupling. Mockito helps decouple tests:
@ExtendWith(MockitoExtension.class)
class OrderProcessorTest {
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderProcessor orderProcessor;
@Test
void shouldInvokePaymentGateway() {
Order order = new Order("123", 500);
orderProcessor.process(order);
verify(paymentGateway).charge(order);
}
}
Benefits:
- Test in isolation, even with messy dependencies.
- Control hard-to-reach code paths via stubbing.
- Reduce reliance on brittle setups.
Step 3: Using Testcontainers for Legacy Integration Points
Legacy apps often depend on databases or external services. Testcontainers makes integration testing manageable:
@Testcontainers
class CustomerRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("legacydb")
.withUsername("test")
.withPassword("test");
private CustomerRepository repository;
@BeforeEach
void setUp() throws SQLException {
DataSource ds = getDataSource(postgres);
repository = new CustomerRepository(ds);
}
@Test
void shouldSaveAndRetrieveCustomer() {
Customer customer = new Customer("c1", "Alice");
repository.save(customer);
Customer found = repository.findById("c1");
assertEquals("Alice", found.getName());
}
}
✅ This ensures database logic works exactly as in production.
Step 4: Strategies for Legacy Refactoring with Tests
- Strangle Pattern: Wrap legacy logic in new tested services.
- Dependency Injection Retrofits: Replace static calls with injectable collaborators.
- Incremental Migration: Add tests around the most volatile areas first.
- Golden Master Testing: Capture input/output pairs to lock down behavior.
Tooling for Legacy Test Automation
- JUnit 5 Extensions: for parameterized and dynamic tests.
- Mockito Improvements: Mock static/final methods (since Mockito 3.4+).
- JaCoCo Coverage Reports: Identify untested hotspots.
- CI/CD Integration: Run tests in Jenkins or GitHub Actions with Docker support.
Version Tracker
- JUnit 4 → JUnit 5 transition: Legacy projects should migrate to JUnit 5 for better extension APIs.
- Mockito updates: Support for static/final/constructor mocking simplifies legacy refactoring.
- Testcontainers: Expanded support for databases, message brokers, and LocalStack for AWS mocks.
Best Practices
- Start small: test critical workflows first.
- Favor characterization tests over refactoring without safety nets.
- Use Mockito spies for partial verification.
- Use Testcontainers reusable mode to speed up builds.
- Add tests incrementally to avoid overwhelming the team.
Real-World Case Study
In a Spring Boot monolith (10+ years old), engineers:
- Began by writing characterization tests for billing flows.
- Introduced Mockito to break dependencies on legacy payment gateways.
- Migrated integration tests to PostgreSQL Testcontainers for CI/CD parity.
- Gradually increased coverage from 15% to 70%, enabling faster releases.
Conclusion & Key Takeaways
- Legacy code can be safely modernized with incremental tests.
- JUnit 5, Mockito, and Testcontainers offer a modern toolset to manage complexity.
- Tests provide confidence for refactoring and enable CI/CD agility.
- Prioritize critical business logic first before chasing full coverage.
FAQ
1. What’s the difference between unit and integration tests?
Unit tests focus on isolated components; integration tests validate interactions with external systems.
2. How do I mock a static method in Mockito?
Use Mockito.mockStatic()
introduced in Mockito 3.4+.
3. How can Testcontainers help in CI/CD pipelines?
They provide real infrastructure (DBs, brokers) in ephemeral containers for reliable test runs.
4. What is the difference between TDD and BDD?
TDD focuses on “how code works,” BDD emphasizes “how behavior should look” in business terms.
5. How do I fix flaky tests in Java?
Stabilize async behavior with awaits, avoid time-dependent assertions, and use Testcontainers reusable mode.
6. Should I refactor legacy code before testing?
No. Write characterization tests first, then refactor safely.
7. Can Mockito handle final classes in legacy code?
Yes, newer versions of Mockito support mocking final classes/methods.
8. What if my legacy project is still on JUnit 4?
JUnit 4 works, but consider upgrading to JUnit 5 for better maintainability.
9. Can Testcontainers simulate production-like systems?
Yes—databases, Kafka, RabbitMQ, and LocalStack can mirror real infra.
10. What’s the best way to start testing a 15-year-old monolith?
Pick one critical module, write characterization tests, and expand coverage gradually.