Recipe testing

When developing new recipes, it's very important to test them to ensure that they not only make the expected changes but that they also don't make unnecessary changes.

To help you create tests that meet those standards, this guide will:

Prerequisites

This guide assumes that:

Adding dependencies

Before you can go about writing tests, you'll want to add some dependencies to your project's build file so that you have all of the tools needed to create said tests:

plugins {
    id("java-library")
}

// ...
dependencies {
    implementation(platform("org.openrewrite.recipe:rewrite-recipe-bom:2.9.0"))

    testImplementation("org.openrewrite:rewrite-test")
    testImplementation("org.junit.jupiter:junit-jupiter-api:latest.release")

    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:latest.release")

    // Optional dependency to make SLF4J logging work.
    // Any SLF4J implementation can work here.
    // Also requires a logback.xml file like:
    // https://gist.github.com/mike-solomon/dabcb2cbd9bca33e4ffeee8fc1c09454
    testRuntimeOnly("ch.qos.logback:logback-classic:1.2.+")

    // Optional dependency on assertJ to provide fluent assertions.
    testImplementation("org.assertj:assertj-core:latest.release")
}

While recipes should generally be compiled with Java 8 as the target for compatibility, tests can be compiled to target newer versions of the Java Runtime.

We recommend either using Java 17 or Kotlin so that you get access to multi-line strings in your tests.

This tutorial uses Java for tests, but which language you use is a matter of preference.

Sample recipe

With the dependencies set up, we now need a recipe that we can write tests for. For the sake of an example, let's assume we have the following recipe that ensures a class's package declaration is all lowercase:

package com.yourorg;

import org.openrewrite.ExecutionContext;
import org.openrewrite.Recipe;
import org.openrewrite.TreeVisitor;
import org.openrewrite.internal.lang.Nullable;
import org.openrewrite.java.ChangePackage;
import org.openrewrite.java.JavaIsoVisitor;
import org.openrewrite.java.tree.J;

import java.time.Duration;
import java.util.Collections;
import java.util.Set;

public class LowercasePackage extends Recipe {
    @Override
    public String getDisplayName() {
        return "Rename packages to lowercase";
    }

    @Override
    public String getDescription() {
        return "By convention, all Java package names should contain only lowercase letters, numbers, and dashes. " +
                "This recipe converts any uppercase letters in package names to be lowercase.";
    }

    @Override
    public Set<String> getTags() {
        return Collections.singleton("RSPEC-120");
    }

    @Override
    public @Nullable Duration getEstimatedEffortPerOccurrence() {
        return Duration.ofMinutes(5);
    }

    @Override
    public TreeVisitor<?, ExecutionContext> getVisitor() {
        return new JavaIsoVisitor<ExecutionContext>() {
            @Override
            public J.Package visitPackage(J.Package pkg, ExecutionContext executionContext) {
                // Grab the package name without spaces
                String packageText = pkg.getExpression().print(getCursor()).replaceAll("\\s", "");
                String lowerCase = packageText.toLowerCase();

                if(!packageText.equals(lowerCase)) {
                    doAfterVisit(new ChangePackage(packageText, lowerCase, true).getVisitor());
                }

                return pkg;
            }
        };
    }
}

Writing tests

Now that we have a recipe to test with, let's go over what every test class should have. At the very least, your tests should:

First, we'll provide an example of what these tests might look like. After that, we'll provide more context around a few key pieces.

Example tests

package com.yourorg;

import org.junit.jupiter.api.Test;
import org.openrewrite.PathUtils;
import org.openrewrite.java.JavaParser;
import org.openrewrite.test.RecipeSpec;
import org.openrewrite.test.RewriteTest;

import java.nio.file.Paths;

import static org.openrewrite.java.Assertions.java;
import static org.assertj.core.api.Assertions.assertThat;

class LowercasePackageTest implements RewriteTest {

    // Note, you can define defaults for the RecipeSpec and these defaults will be
    // used for all tests.
    @Override
    public void defaults(RecipeSpec spec) {
        spec.recipe(new LowercasePackage());
    }

    // A Java source file that already has a lowercase package name should be left
    // unchanged.
    @Test
    void packageIsAlreadyLowercase() {
        rewriteRun(
            java(
                """
                    package com.yourorg;

                    class A {}
                """
            )
        );
    }

    // Assert that a Java source file with uppercase letters in its package name
    // is correctly transformed by the recipe.
    @Test
    void lowerCasePackage() {
        // Each test can customize the RecipeSpec before the test is executed.
        // In this case, the recipe has already been defined in defaults(). We
        // can extend that and add a parser that logs warnings and errors for
        // just this test.
        rewriteRun(
            // You'll need to have an SLF4J logger configured to see
            // these warnings and errors.
            spec -> spec
                    .parser(JavaParser.fromJavaVersion()
                            .logCompilationWarningsAndErrors(true)),
            java(
                // The Java source file before the recipe is run:
                """
                    package com.UPPERCASE.CamelCase;
                    class FooBar {}
                """,
                // The expected Java source file after the recipe is run:
                """
                    package com.uppercase.camelcase;
                    class FooBar {}
                """,
                // An optional callback that can be used after the recipe has been
                // executed to assert additional conditions on the resulting source file:
                spec -> spec.afterRecipe(cu -> assertThat(PathUtils.equalIgnoringSeparators(cu.getSourcePath(), Paths.get("com/uppercase/camelcase/FooBar.java"))).isTrue()))
        );
    }

    // Demonstrates how you can do multiple checks in one test.
    //
    // You can also combine different types (such as `java` and `text`) in one test:
    // https://github.com/openrewrite/rewrite-spring/blob/main/src/testWithSpringBoot_2_7/java/org/openrewrite/java/spring/boot2/MoveAutoConfigurationToImportsFileTest.java#L177-L224
    @Test
    void combinedExample() {
        rewriteRun(
            // Assert the first source file is not modified.
            java(
                """
                    package com.lowercase;
                    class A {}
                """
            ),
            // Assert the second source file is modified.
            java(
                """
                    package com.UPPERCASE.CamelCase;
                    class FooBar {}
                """,
                """
                    package com.uppercase.camelcase;
                    class FooBar {}
                """
            )
        );
    }
}

RewriteTest interface

As mentioned above, the first thing all tests need to do is implement the RewriteTest interface. This interface not only acts as the entry point to testing your recipe, but it also provides a Fluent API for expressing recipe and source file configuration.

In the above tests, we utilize two main pieces of this interface:

  • The defaults method to set up the environment needed for each test (via RecipeSpecs)

  • The rewriteRun method to assert that the recipe has made the correct transformations on the code (via SourceSpecs)

RecipeSpec

Before you begin writing the core logic for your tests, you'll need to set up your environment. You can do this by utilizing the RecipeSpec class. This class serves a few main purposes:

  • It allows you to specify which recipe will be executed for any given test

  • It allows you to customize the environment in which the recipe runs (such as what parser(s) to use and whether or not you should log Java compilation errors)

  • It provides convenient callbacks that can be used to execute code before or after any given test

The RewriteTest.defaults() method can be used to define common RecipeSpec customizations that should be applied to all of the tests in the testing class. Additionally, there are overloaded versions of the RewriteTest.runRecipe() method that allow the RecipeSpec to be further customized for a specific test. For instance, you may want different tests to have different Spring properties or different Java versions.

SourceSpec

Once you've set up your environment, the next step is to write the tests themselves. The core component of each test is the SourceSpec class.

At a minimum, a SourceSpec will define the type of source file (such as java or text) and its initial "before" contents. If a test is defined with only these pieces, then the testing infrastructure will pass a test if the specified code has not changed at all after the given recipe has run.

In a majority of cases, though, a SourceSpec will also define an "after" state which defines what the source file contents will look like after it has been processed by the given recipe. In this case, the testing framework will pass the test if the source file has been transformed into the "after" state.

A developer can assert additional conditions on a source file by using the afterRecipe callback that is defined on the SourceSpec. This can be convenient when asserting conditions on the resulting semantic model that are not represented in the rendering of the source code after the recipe has transformed the source file. For instance, you may want to confirm that the path to a file has changed such as in the sample tests above. This file path is not visible in the "after" code and can only be tested via this callback.

Advanced recipe testing

Customizing source file paths and markers

Occasionally, it is desirable to modify a specific source file before it is processed by the recipe testing environment. This type of customization can be achieved by using the callback method provided as an optional parameter on the Fluent API.

As an example, let us assume a recipe has been built to manipulate properties within an application.properties source file, but only when its path is main/resources/application.properties. To correctly define the path for the source file, a developer can leverage the callback:

@Test
void propertiesChangeTest() {
    rewriteRun(
        properties(
            // Before
            """
                server.port=8080
                """,
            // After
            """
                server.port=80
            """,
            s -> s.path("src/main/resources/application.properties")
        )
    );
}

You'll need to add org.openrewrite:rewrite-properties as a test dependency for properties to be available in your tests.

Specifying Java versions

When writing recipes that change the version of Java (such as a migration from 11 to 17), you may want to have tests that run on a different version of Java. To do this, you can use the RecipeSpec.allSources() method to configure a default Java version for the sources:

@Override
public void defaults(RecipeSpec spec) {
    spec.recipe(new UseTextBlocks())
        .allSources(s -> s.markers(javaVersion(17)));
}

(Recipe example)

You can also specify the version on an individual test basis like so:

@Test
void noChangeOnJava11() {
    //language=java
    rewriteRun(version(java("""
        import java.util.zip.ZipFile;

        class FooBar extends ZipFile {
            FooBar(){
                super("");
            }
            public void test() {
                finalize();
            }
        }
        """), 11));
}

Specifying the classpath for dependencies

If your tests include code that is not part of the JDK itself, you will want to add a classpath or classpathFromResources to the test so that OpenRewrite can know where those dependencies are coming from.

For instance, if you want to test that your recipe removes a junit-jupiter-api method, you would need to let the parser know that there is a dependency on that library such as in the RemoveUnneededAssertionTest.

If you want to test multiple versions of the same dependency, you'll want to use classpathFromResources instead which you can find documentation for in the using multiple versions of a library guide.

Next steps

Now that you're familiar with writing tests, consider reading over the best practice guide for making recipes. You could also check out the guide that expands on JavaTemplates if you'd like to learn even more about creating recipes.

Last updated