Keywords: Mockito | Unit Testing | Java Testing | Spy Objects | Constructor Mocking
Abstract: This article provides an in-depth exploration of testing legacy Java code containing new() operator calls using the Mockito framework. It analyzes three main solutions: partial mocking with spy objects, constructor mocking via PowerMock, and code refactoring with factory patterns. Through comprehensive code examples and technical analysis, the article demonstrates the applicability, advantages, and implementation details of each approach, helping developers effectively unit test legacy code without modifications.
Challenges in Legacy Code Testing
In unit testing practice, developers frequently encounter legacy code containing hard-coded new() operator calls. Such code is typically difficult to test because the instantiation process is tightly coupled with business logic, preventing replacement with mock objects through conventional dependency injection. This article examines this challenge through a typical login scenario and explores solutions using the Mockito framework.
Problem Scenario Analysis
Consider the following legacy code example:
public class TestedClass {
public LoginContext login(String user, String password) {
LoginContext lc = new LoginContext("login", callbackHandler);
return lc;
}
}
This code directly instantiates a LoginContext object, which typically requires complex JAAS security configuration for proper initialization. In unit testing environments, such external dependencies are often unavailable or difficult to configure.
Solution 1: Partial Mocking with Spy Objects
Mockito's spy functionality allows creating wrappers around real objects, enabling mocking of specific methods while preserving the real behavior of other methods. This approach is particularly suitable for legacy code that cannot be modified.
Implementation Principle
Spy objects implement the delegation pattern, where all method calls not explicitly mocked are forwarded to the real object. When specific methods need mocking, they can be configured using when().thenReturn() or doReturn().when().
Concrete Implementation
@Test
public void testLoginWithSpy() {
// Create spy instance of the class under test
TestedClass tc = spy(new TestedClass());
// Create mock object for LoginContext
LoginContext lcMock = mock(LoginContext.class);
// Configure spy to return mock object when login method is called
when(tc.login(anyString(), anyString())).thenReturn(lcMock);
// Execute test
LoginContext result = tc.login("testUser", "password");
// Verify result
assertSame(lcMock, result);
}
Important Considerations
When using spies, pay special attention to:
- When configuring spies with
when().thenReturn(), the real method is called once, potentially causing side effects - For methods that may throw exceptions or have complex logic, use
doReturn().when()to avoid real method execution - Spy objects increase test complexity and should be used judiciously
Solution 2: Constructor Mocking with Mockito Inline
Modern Mockito versions (3.4.0+) provide inline mocking capabilities that can directly mock constructor calls without additional dependencies.
Implementation Approach
@Test
public void testLoginWithMockedConstruction() {
try (MockedConstruction<LoginContext> mockedConstruction =
Mockito.mockConstruction(LoginContext.class)) {
TestedClass tc = new TestedClass();
LoginContext result = tc.login("something", "something else");
// Verify constructor was called
assertEquals(1, mockedConstruction.constructed().size());
// Can retrieve mock instance for further verification
LoginContext mockInstance = mockedConstruction.constructed().get(0);
assertNotNull(mockInstance);
}
}
Dependency Configuration
Using this feature requires adding the mockito-inline dependency:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
Solution 3: Code Refactoring with Factory Pattern
Although not a direct Mockito solution, this approach represents a long-term best practice worth considering. By refactoring code to separate object creation logic, testability can be significantly improved.
Refactored Code Structure
public interface LoginContextFactory {
LoginContext createLoginContext();
}
public class DefaultLoginContextFactory implements LoginContextFactory {
@Override
public LoginContext createLoginContext() {
return new LoginContext("login", callbackHandler);
}
}
public class TestedClass {
private final LoginContextFactory loginContextFactory;
public TestedClass(LoginContextFactory loginContextFactory) {
this.loginContextFactory = loginContextFactory;
}
public LoginContext login(String user, String password) {
return loginContextFactory.createLoginContext();
}
}
Corresponding Test Code
@Test
public void testLoginWithFactory() {
LoginContextFactory factoryMock = mock(LoginContextFactory.class);
LoginContext lcMock = mock(LoginContext.class);
when(factoryMock.createLoginContext()).thenReturn(lcMock);
TestedClass tc = new TestedClass(factoryMock);
LoginContext result = tc.login("user", "password");
assertEquals(lcMock, result);
verify(factoryMock).createLoginContext();
}
Solution Comparison and Selection Guidelines
Scenario Analysis
<table border="1"> <tr><th>Solution</th><th>Applicable Scenarios</th><th>Advantages</th><th>Disadvantages</th></tr> <tr><td>Spy Objects</td><td>Unmodifiable legacy code</td><td>No source code modification required</td><td>Higher test complexity</td></tr> <tr><td>Constructor Mocking</td><td>Modern Mockito environments</td><td>Powerful functionality, no extra configuration</td><td>Requires newer Mockito versions</td></tr> <tr><td>Factory Pattern Refactoring</td><td>Modifiable code</td><td>Clear code structure, easy testing</td><td>Requires source code modification</td></tr>Performance Considerations
Both spy objects and constructor mocking introduce some runtime overhead. In performance-sensitive scenarios, the factory pattern is usually the better choice as it avoids runtime bytecode manipulation.
Best Practices Recommendations
Test Design Principles
Regardless of the chosen solution, follow these test design principles:
- Single Responsibility: Each test verifies only one specific behavior
- Explicit Assertions: Ensure tests contain clear verification points
- Isolation: Tests should not depend on each other
- Readability: Test code should clearly express testing intent
Error Handling Testing
A complete test suite should also include tests for exception scenarios:
@Test
public void testLoginWithException() {
TestedClass tc = spy(new TestedClass());
// Simulate exception during login process
when(tc.login(anyString(), anyString()))
.thenThrow(new RuntimeException("Login failed"));
assertThrows(RuntimeException.class, () -> {
tc.login("user", "password");
});
}
Conclusion
Testing legacy code containing new() calls is a common challenge in unit testing. Mockito provides multiple solutions to address this challenge, ranging from simple spy objects to powerful constructor mocking capabilities. Choosing the appropriate method requires careful consideration of code modifiability, testing environment constraints, and long-term maintenance requirements. By properly applying these techniques, developers can effectively test various types of legacy code without compromising test quality.