Postponing a (major) dependency update for too long can harm our productivity in the long run as we might not be able to switch to a recent Java version. Especially for testing libraries, the need to migrate to the latest major version might not be as pressing as a bump for our core application framework (e.g., Spring, Jakarta EE, Quarkus, etc.). Depending on the size of the codebase, such dependency migrations are not worth doing by hand. Fortunately, there are tools out there that help with large-scale refactorings and code migrations. One of such tools is OpenRewrite. For a recent project, I used OpenRewrite to start the migration from Mockito 1 to Mockito 4 for a Java project. With this blog post, we'll take a closer look into OpenRewrite and how it can help us to automatically migrate our project and do refactorings on a large scale.
Introducing OpenRewrite
The OpenRewrite documentation describes itself as the following:
OpenRewrite enables large-scale distributed source code refactoring for framework migrations, vulnerability patches, and API migrations with an early focus on the Java language.
Technically speaking, OpenRewrite creates Lossless Semantic Tree (LST) from our source code. The code refactorings are operations on this AST, which OpenRewrite will then be written back to our source code files.
OpenRewrite uses the visitor design pattern to perform the code refactorings. These visitors can are similar to event-handlers that include a single refactoring change for our source code. One such visitor could, for example, replace a deprecated method or change all imports to static imports:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class UseStaticImportVisitor extends JavaIsoVisitor<ExecutionContext> { @Override public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { MethodMatcher methodMatcher = new MethodMatcher(methodPattern); J.MethodInvocation m = super.visitMethodInvocation(method, ctx); if (methodMatcher.matches(m)) { if (m.getMethodType() != null) { JavaType.FullyQualified receiverType = m.getMethodType().getDeclaringType(); maybeRemoveImport(receiverType); AddImport<ExecutionContext> addStatic = new AddImport<>( receiverType.getFullyQualifiedName(), m.getSimpleName(), false); if (!getAfterVisit().contains(addStatic)) { doAfterVisit(addStatic); } } if (m.getSelect() != null) { m = m.withSelect(null).withName(m.getName().withPrefix(m.getSelect().getPrefix())); } } return m; } } |
The code above is from the OpenRewrite GitHub repository and showcases how such visitors are implemented. This visitor modifies the visited class to change all imports to static imports.
Let's assume we have two OpenRewrite visitors (UseStaticImportVisitor
and UseArgumentMatchersVisitor
) that migrate the old Mockito Matchers
usage to ArgumentMatchers
and use static imports consistently across the codebase.
Before OpenRewrite refactors our code, our tests share the following stubbing setup:
1 2 3 | import org.mockito.Matchers; when(mockedProductVerifier.isCurrentlyInStockOfCompetitor(Matchers.startsWith("Air"))).thenReturn(true); |
After running the migration recipe (use static imports and ArgumentMatchers
instead of Matchers
) with OpenRewrite, our source code has been modified to the following:
1 2 3 | import static org.mockito.ArgumentMatchers; when(mockedProductVerifier.isCurrentlyInStockOfCompetitor(startsWith("Air"))).thenReturn(true); |
OpenRewrite combines a set of visitors into so-called Recipes. These recipes are ready-to-use blueprints (i.e., construction plans) that include a list of visitors to, for example, upgrade to the next major version of our framework by automatically replacing deprecated code.
Based on recipes that transform code as part of our build process. This includes replacing deprecated methods, adjusting imports, and finally bumping the dependency version.
We're going to showcase OpenRewrite based on a testing-related migration: Migrating our Java test suite from Mockito 1 to Mockito 3.
Mockito 1 Migration Setup with OpenRewrite
We're starting our OpenRewrite Mockito Java migration journey with a sample project that relies on Mockito 1:
1 2 3 4 5 6 | <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>1.10.19</version> <scope>test</scope> </dependency> |
The project contains some tests that use various Mockito features that are either no longer available, deprecated, or have moved to a different package/class with Mockito 3.
An exemplary Mockito 1 test looks like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // changed to org.mockito.junit.MockitoJUnitRunner with Mockito 3 @RunWith(MockitoJUnitRunner.class) public class PricingServiceTest { private ProductVerifier mockedProductVerifier = mock(ProductVerifier.class); private ProductReporter mockedProductReporter = mock(ProductReporter.class); @Test public void shouldReturnExpensivePriceWhenProductIsNotInStockOfCompetitor() { // changed to ArgumentMatchers with Mockito 3 when(mockedProductVerifier.isCurrentlyInStockOfCompetitor(Matchers.startsWith("MacBook"))).thenReturn(false); PricingService classUnderTest = new PricingService(mockedProductVerifier, mockedProductReporter); assertEquals(new BigDecimal("149.99"), classUnderTest.calculatePrice("MacBook")); // changed to verifyNoInteractions with Mockito 3 verifyZeroInteractions(mockedProductReporter); } } |
The actual behavior that we verify with this test is secondary. What's important here is the usage of Mockito 1 APIs that either no longer exist or are deprecated as part of Mockito 3.
Examples for these migration spots are:
Matchers
is nowArgumentMatchers
verifyZeroInteractions
is nowverifyNoInteractions
- the package for
MockitoJUnitRunner
changes - etc.
Given that Mockito 1 is no longer maintained, we now want to migrate to Mockito 3 (and then preferably to Mockito 4) to benefit from recent Mockito features like mocking static methods or constructors (… to get rid of PowerMock).
We start our migration process by adding the OpenRewrite Maven plugin to our project:
1 2 3 4 5 | <plugin> <groupId>org.openrewrite.maven</groupId> <artifactId>rewrite-maven-plugin</artifactId> <version>4.20.0</version> </plugin> |
Next, we have to configure the recipe(s) we want to apply. The OpenRewrite documentation lists all available migration recipes including their coordinates.
We're going to use the org.openrewrite.java.testing.mockito.Mockito1to3Migration
recipe for this migration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <plugin> <!-- OpenRewrite Plugin --> <configuration> <!-- configure the desired migration recipe here --> <activeRecipes> <recipe>org.openrewrite.java.testing.mockito.Mockito1to3Migration</recipe> </activeRecipes> </configuration> <dependencies> <dependency> <groupId>org.openrewrite.recipe</groupId> <artifactId>rewrite-testing-frameworks</artifactId> <version>1.18.0</version> </dependency> </dependencies> </plugin> |
Each recipe has a dedicated overview page explaining both usages and changes OpenRewrite will perform (see the Definition section).
Our dependency on Mockito must still be version 1 before starting the migration. Throughout the migration process, OpenRewrite will bump the Mockito dependency.
Hint for Apple M1 (ARM64) users: Make sure to disable the POM cache as part of the plugin configuration as long as this issue is still ongoing (fixed with 4.22.0):
1 2 3 4 | <configuration> <!-- recipe config --> <pomCacheEnabled>false</pomCacheEnabled> </configuration> |
Performing the Mockito 1 to 3 Migration with OpenRewrite
We can now start the migration process. We should track our project by a version control system like git to ensure a safe rollback to the old project state. Furthermore, we should have no pending changes in our local workspace to quickly revert to a previous commit if we're facing migration issues.
As an additional layer of confidence, we can use the dry run option of OpenRewrite. This allows to run the active recipe(s) (Mockito1to3Migration
in our case) without making actual changes to the source code. Instead, the OpenRewrite plugin creates patch files that the plugin would touch as part of the actual migration.
We perform the dry run with ./mvnw rewrite:dryRun
:
1 2 3 4 5 6 7 8 9 10 11 12 13 | [INFO] --- rewrite-maven-plugin:4.20.0:dryRun (default-cli) @ open-rewrite-example --- [INFO] Using active recipe(s) [org.openrewrite.java.testing.mockito.Mockito1to3Migration] [INFO] Using active styles(s) [] [INFO] Validating active recipes... [INFO] Parsing Java main files... [INFO] Parsing Java test files... [INFO] Running recipe(s)... [WARNING] These recipes would make changes to src/test/java/de/rieckpil/blog/mockito1/PricingServiceTest.java: [WARNING] org.openrewrite.java.ChangeMethodName [WARNING] org.openrewrite.java.ChangeType [WARNING] org.openrewrite.java.ChangeType [WARNING] Patch file available: [WARNING] /Users/rieckpil/Development/git/blog-tutorials/open-rewrite-example/target/site/rewrite/rewrite.patch |
The log output of the dry run for our Mockito migration with OpenRewrite lists the various migration operations for each file as .patch
files. While we can investigate these files manually for a local project, that's not an option for a larger project.
The patch file for our single Mockito 1 Java test looks like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | diff --git a/src/test/java/de/rieckpil/blog/mockito1/PricingServiceTest.java b/src/test/java/de/rieckpil/blog/mockito1/PricingServiceTest.java index 603980a..b48d495 100644 --- a/src/test/java/de/rieckpil/blog/mockito1/PricingServiceTest.java +++ b/src/test/java/de/rieckpil/blog/mockito1/PricingServiceTest.java @@ -7,13 +7,13 @@ org.openrewrite.java.ChangeMethodName, org.openrewrite.java.ChangeType, org.openrewrite.java.ChangeType import de.rieckpil.blog.ProductVerifier; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Matchers; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.ArgumentMatchers; +import org.mockito.junit.MockitoJUnitRunner; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; |
Once we've verified the migration process using the dry run, we can perform the actual migration with ./mvnw rewrite:run
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | [INFO] --- rewrite-maven-plugin:4.20.0:run (default-cli) @ open-rewrite-example --- [INFO] Using active recipe(s) [org.openrewrite.java.testing.mockito.Mockito1to3Migration] [INFO] Using active styles(s) [] [INFO] Validating active recipes... [INFO] Parsing Java main files... [INFO] Parsing Java test files... [INFO] Running recipe(s)... [WARNING] Changes have been made to src/test/java/de/rieckpil/blog/mockito1/PricingServiceTest.java by: [WARNING] org.openrewrite.java.ChangeMethodName [WARNING] org.openrewrite.java.ChangeType [WARNING] org.openrewrite.java.ChangeType [WARNING] Please review and commit the results. [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 5.809 s [INFO] Finished at: 2022-03-08T07:23:17+01:00 [INFO] ------------------------------------------------------------------------ |
Depending on the project size and the involved test LOC (lines of code), this migration may run for a while. If there's an error throughout the migration process, we can safely rerun the migration by reverting to a pre-migration state.
After the migration succeeds, our test won't compile as they're using Mockito 3 classes and APIs while our project still depends on Mockito 1. That's an easy manual fix for us as we have to bump the Mockito dependency:
1 2 3 4 5 6 | <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>3.12.4</version> <scope>test</scope> </dependency> |
We can then rerun our test with ./mvnw test
and verify the migration outcome.
While our project should now compile and use Mockito 3, there can still be some (new) test runtime failures.
From a personal experience of using this migration for a 600k LOC project, I encountered the following parts that required further manual adjustments to get the tests passing:
- Custom Mockito 1
ArgumentMatcher
s needed a manual rewrite. The contract changed to a more typesafe approach (no moreObject
as a method parameter). Adjusting the non-compiling matchers took max. one minute for each. - The new strict stubbing mode fails some tests as we're stubbing method calls that we don't invoke throughout a test. Most of the time, this was due to an over-specification/redundant setup as part of the
BeforeEach
JUnit lifecycle. As a fix, we can either change the Mockito stubbing configuration to be lenient and allow this setup or refactor the test and remove the unnecessary stubbing. - (Optional): The Mockito 1 may still use JUnit 3 or 4. Good news, there's an OpenRewrite migration recipe for migrating from JUnit 4 to JUnit 5
Once our test suite is green, we can repeat this process and use another recipe to migrate to Mockito 4.
OpenRewrite Automatic Java Migration Conclusion
OpenRewrite gives us an automated solution to migrate and/or refactor our Java application with ease. The available migration recipes cover a wide range of useful migration options, including the migration to newer (test) dependency versions. The integration to our build tool (Maven or Gradle) makes the migration more convenient than applying multiple search and replace operations inside our IDEA while copy-pasting regex variants from the Internet.
Migrating to a recent Mockito may not be a technical debt that's on our team's radar. The tests are working – why should we invest time to migrate them? It's not even a production dependency that gets shipped (i.e., no CVEs to be fixed), why should we care? While that's a correct first impression, using an outdated dependency is still a technical debt that may block the migration to newer Java versions or result in capabilities with another testing library. Furthermore, using a ten-year-old library can drain productivity as the dev team won't have access to the latest features of Mockito.
Apart from the showcased OpenRewrite Java Mockito migration, there are further recipes worth looking at:
- migrating to newer Java versions
- doing general refactorings
- fixing static code analysis or checkstyle issues for our codebase.
- migrating from JUnit 4 to JUnit 5
- support Spring Boot 1 to 2 migration
On top of the default migration recipes, we can write our own Java refactoring recipes with OpenRewrite to support the transition, for example, for a company's internal dependency to the next major version. We can then share this recipe with the rest of the organization to accelerate a company-wide migration.
For a hands-on introduction to OpenRewrite, I've recorded a YouTube video. Furthermore, Tim te Beek's talk at the Spring I/O 2022 conference is also a great resource to get started with OpenRewrite.
The source code for this example is available on GitHub.
Joyful testing,
Philip