Automatic Java Code Migration with OpenRewrite (Mockito Example)

Last Updated:  April 9, 2023 | Published: March 14, 2022

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:

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:

After running the migration recipe (use static imports and ArgumentMatchers instead of Matchers) with OpenRewrite, our source code has been modified to the following:

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:

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:

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 now ArgumentMatchers
  • verifyZeroInteractions is now verifyNoInteractions
  • 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:

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:

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):

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:

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:

Once we've verified the migration process using the dry run, we can perform the actual migration with ./mvnw rewrite:run:

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:

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 ArgumentMatchers needed a manual rewrite. The contract changed to a more typesafe approach (no more Object 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

>