By using this site, you agree to the Privacy Policy and Terms of Use.
Accept
World of SoftwareWorld of SoftwareWorld of Software
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Search
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
Reading: I Tested an OpenRewrite Recipe: The Mistakes I Made and How to Fix Them | HackerNoon
Share
Sign In
Notification Show More
Font ResizerAa
World of SoftwareWorld of Software
Font ResizerAa
  • Software
  • Mobile
  • Computing
  • Gadget
  • Gaming
  • Videos
Search
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Have an existing account? Sign In
Follow US
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
World of Software > Computing > I Tested an OpenRewrite Recipe: The Mistakes I Made and How to Fix Them | HackerNoon
Computing

I Tested an OpenRewrite Recipe: The Mistakes I Made and How to Fix Them | HackerNoon

News Room
Last updated: 2025/06/26 at 8:47 PM
News Room Published 26 June 2025
Share
SHARE

For the last two weeks, I’ve kicked the tires of OpenRewrite. At first, I created a recipe to move Kotlin source files as per the official recommendations with a set package name. I then improved the recipe to compute the root automatically. In both versions, I thoroughly tested the recipe. However, my testing approach was wrong. In this post, I want to describe my mistakes, and how I fixed them.

The Naive Approach

I originally approached the testing of the recipe in a very naive way, to say the least. As explained in the first post, I used OpenRewrite’s low-level APIs. Here’s what I wrote:

// Given
val parser = KotlinParser.builder().build()                                      //1
val cu = parser.parse(
    InMemoryExecutionContext(),                                                  //2
    sourceCode
).findFirst()                                                                    //3
 .orElseThrow { IllegalStateException("Failed to parse Kotlin file") }           //3
val originalPath = Paths.get(originalPath)
val modifiedCu = (cu as K.CompilationUnit).withSourcePath(originalPath)          //4

// When
val recipe = FlattenStructure(configuredRootPackage)
val result = recipe.visitor.visit(modifiedCu, InMemoryExecutionContext())        //5

// Then
val expectedPath = Paths.get(expectedPath)
assertEquals(expectedPath, (result as SourceFile).sourcePath)                    //6
  1. Build the Kotlin parser
  2. Set an execution context; I had to choose, and the in-memory one was the easiest.
  3. Boilerplate to get the single compilation unit from the stream
  4. Cast to a K.CompilationUnit because we know better
  5. Explicitly call the visitor to visit
  6. Assert the recipe moved the file

The above works, but requires a deep understanding of how OpenRewrite works. I didn’t have that understanding, but it was good enough. It came back to bite me when I improved the recipe to compute the root.

As explained in the last post, I switched from a regular recipe to a scanning recipe. I had to provide at least two source files to test the new capability. I came up with the following:

// When
val recipe = FlattenStructure()
val context = InMemoryExecutionContext()
val acc = AtomicReference//String?(null)
recipe.getScanner(acc).visit(modifiedCu1, context)                               //1
recipe.getScanner(acc).visit(modifiedCu2, context)                               //1
val result1 = recipe.getVisitor(acc).visit(modifiedCu1, context)                 //2
val result2 = recipe.getVisitor(acc).visit(modifiedCu2, context)                 //2
  1. Get the scanner and visit the source files to compute the root
  2. Get the visitor and visit the source files to move the file

It worked, but I admit it was a lucky guess. More involved recipes would require a deeper knowledge of how OpenRewrite works, with more potential bugs. Fortunately, OpenRewrite provides the means to keep the testing code at the right level of abstraction.

The Nominal Approach

The nominal approach involves a couple of out-of-the-box classes; it requires a new dependency. I didn’t do it before, so now is a good time: let’s introduce a <abbr title=”Bill Of Material”>BOM</abbr> to align all of OpenRewrite’s dependencies:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-recipe-bom</artifactId>
            <version>3.9.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

It’s now possible to add the dependency without a version, as Maven resolves it from the above BOM.

<dependency>
    <groupId>org.openrewrite</groupId>
    <artifactId>rewrite-test</artifactId>
    <scope>test</scope>
</dependency>

This brings a couple of new classes to the project:

OpenRewrite recipes test class diagramOpenRewrite recipes test class diagram

The documentation states that your test class should inherit from RewriteTest, which provides rewriteRun. The latter runs the recipe, without any need to know about its inner workings, e.g., the above in-memory execution context.

It’s the abstraction level that we want. Assertions offers static methods to assert. OpenRewrite also advises using Assertion4J, which I fully endorse. Yet, I didn’t do it to keep the comparison simpler.

We can rewrite the previous snippet to:

rewriteRun(                                                                     //1
    kotlin(sourceCode1) { spec ->                                               //2-3-4
        spec.path("src/main/kotlin/ch/frankel/blog/foo/Foo.kt")                 //5
        spec.afterRecipe {                                                      //6
            assertEquals(                                                       //7
                Paths.get("src/main/kotlin/foo/Foo.kt"),
                it.sourcePath
            )
        }
    },
    kotlin(sourceCode2) { spec ->                                               //2-3-4
        spec.path("src/main/kotlin/org/frankel/blog/bar/Bar.kt")                //5
        spec.afterRecipe {                                                      //6
            assertEquals(                                                       //7
                Paths.get("src/main/kotlin/bar/Bar.kt"),
                it.sourcePath
            )
        }
    },
)
  1. Run the recipe
  2. kotlin transform the string into a SourceSpecs
  3. I’m using Kotlin, but java does the same for regular Java projects
  4. Allow customizing the source specification
  5. Customize the path
  6. Hook after running the recipe
  7. Assert that the recipe updated the path according to the expectations

You may have noticed that the rewritten code doesn’t specify which recipe it’s testing. That’s the responsibility of the RewriteTest.defaults() method.

class FlattenStructureComputeRootPackageTest : RewriteTest {

    override fun defaults(spec: RecipeSpec) {
        spec.recipe(FlattenStructure())
    }

    // Rest of the class
}

Don’t Forget Cycles

If you followed the above instructions, there’s a high chance your test fails with this error message:

java.lang.AssertionError: Expected recipe to complete in 0 cycle, but took 1 cycle. This usually indicates the recipe is making changes after it should have stabilized.

We need to turn to the documentation to understand this cryptic message:

The recipes in the execution pipeline may produce changes that in turn cause another recipe to do further work. As a result, the pipeline may perform multiple passes (or cycles) over all the recipes in the pipeline again until either no changes are made in a pass or some maximum number of passes is reached (by default 3). This allows recipes to respond to changes made by other recipes which execute after them in the pipeline.

— Execution Cycles

Because the recipe doesn’t rely on any other and no other recipe depends on it, we can set the cycle to 1.

override fun defaults(spec: RecipeSpec) {
    spec.recipe(FlattenStructure())
        .cycles(1)                                                              //1
        .expectedCyclesThatMakeChanges(1)                                       //2
}
  1. Set how many cycles the recipe should run
  2. Set to 0 if the recipe isn’t expected to make changes

Criticisms

I like what the OpenRewrite testing classes bring, but I have two criticisms.

First and foremost, why does OpenRewrite assert the number of cycles by default? It bit me in the back for no good reason. I had to dig into the documentation and understand how OpenRewrite works, although the testing API is supposed to shield users from its inner workings. I also can’t help but wonder about the defaults.

public class RecipeSpec {

    @Nullable
    Integer cycles;

    int getCycles() {
        return cycles == null ? 2 : cycles;                                     //1
    }

    int getExpectedCyclesThatMakeChanges(int cycles) {
        return expectedCyclesThatMakeChanges == null ? cycles - 1 :             //2
                expectedCyclesThatMakeChanges;
    }

    // Rest of the class body
}
  1. Why two cycles by default? Shouldn’t one be enough in most cases?
  2. Why cycles - 1 by default?

My second criticism is about how the provided testing classes make you structure your tests. I like to structure them into three parts:

  1. Given: describe the initial state
  2. When: execute the to-be-tested code
  3. Then: assert the final state conforms to what I expect.

With OpenRewrite’s abstractions, the structure is widely different from the above.

Conclusion

In this post, I migrated my ad hoc test code to rely on OpenRewrite’s provided classes. Even though they are not exempt from criticism, they offer a solid abstraction layer and make tests more maintainable.

The complete source code for this post can be found on GitHub:

To go further:


Originally published at A Java Geek on June 22, 2025

Sign Up For Daily Newsletter

Be keep up! Get the latest breaking news delivered straight to your inbox.
By signing up, you agree to our Terms of Use and acknowledge the data practices in our Privacy Policy. You may unsubscribe at any time.
Share This Article
Facebook Twitter Email Print
Share
What do you think?
Love0
Sad0
Happy0
Sleepy0
Angry0
Dead0
Wink0
Previous Article Ember Mug 2 smart mug plummets to new record-low price!
Next Article White House, senators eye September deadline for crypto framework
Leave a comment

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Stay Connected

248.1k Like
69.1k Follow
134k Pin
54.3k Follow

Latest News

This hidden Gemini trick has completely changed how I use Google Calendar
Gadget
Microsoft Officially Retires 'Blue Screen of Death' After 40 Years of Frowny-Faces
News
MOVEit Transfer Faces Increased Threats as Scanning Surges and CVE Flaws Are Targeted
Computing
Microsoft to Retire the Blue Screen of Death (Again) for a Black Void
News

You Might also Like

Computing

MOVEit Transfer Faces Increased Threats as Scanning Surges and CVE Flaws Are Targeted

3 Min Read
Computing

China’s EV makers drive strong Nov sales, look to hit yearly targets · TechNode

5 Min Read
Computing

How to Use Time Blocking in Outlook Calendar I

21 Min Read
Computing

Unlocking the future tech in China! “NextChina”: stories on China’s most promising tech stars · TechNode

2 Min Read
//

World of Software is your one-stop website for the latest tech news and updates, follow us now to get the news that matters to you.

Quick Link

  • Privacy Policy
  • Terms of use
  • Advertise
  • Contact

Topics

  • Computing
  • Software
  • Press Release
  • Trending

Sign Up for Our Newsletter

Subscribe to our newsletter to get our newest articles instantly!

World of SoftwareWorld of Software
Follow US
Copyright © All Rights Reserved. World of Software.
Welcome Back!

Sign in to your account

Lost your password?