diff --git a/src/main/java/org/openrewrite/java/testing/junit5/JUnit4ToJunit5Precondition.java b/src/main/java/org/openrewrite/java/testing/junit5/JUnit4ToJunit5Precondition.java new file mode 100644 index 000000000..ca3565be6 --- /dev/null +++ b/src/main/java/org/openrewrite/java/testing/junit5/JUnit4ToJunit5Precondition.java @@ -0,0 +1,294 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.java.testing.junit5; + +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.java.AnnotationMatcher; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.service.AnnotationService; +import org.openrewrite.java.trait.Annotated; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.marker.SearchResult; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import static java.util.Collections.emptySet; +import static java.util.stream.Collectors.toMap; + +/** + * A search recipe that identifies JUnit 4 test classes migratable to JUnit 5 and marks them for + * inclusion in the JUnit 4 to JUnit 5 migration recipe. + * + *

It marks classes that: + * + *

+ */ +@EqualsAndHashCode(callSuper = false) +@Value +public class JUnit4ToJunit5Precondition extends ScanningRecipe { + + private static final AnnotationMatcher ANY_RULE_ANNOTATION_MATCHER = new AnnotationMatcher("@org.junit.*Rule", true); + + private static final String HAS_UNSUPPORTED_RULE = "hasUnsupportedRule"; + private static final String HAS_UNSUPPORTED_RUNNER = "hasUnsupportedRunner"; + private static final String HAS_CLASS_TYPE_SOURCE_ATTRIBUTE = "hasClassTypeSourceAttribute"; + + @Option(displayName = "Known migratable classes", + description = "A list of classes which are migratable. These are the classes for which recipes already exist. " + + "In practical scenarios, these are parent test classes for which we already have JUnit 5 versions.", + example = "org.example.MigratableBaseTestClass") + @Nullable Set knownMigratableClasses; + + @Option(displayName = "Supported rules", + description = "Rules for which migration recipes exist.", + example = "org.junit.rules.TemporaryFolder") + @Nullable Set supportedRules; + + @Option(displayName = "Supported rule types", + description = "Recipe exist for rule types and all their inheriting rules (e.g., ExternalRules).", + example = "org.junit.rules.ExternalResource") + @Nullable Set supportedRuleTypes; + + @Option(displayName = "Supported runners", + description = "Runners for which migration recipes exist.", + example = "org.junit.runners.Parameterized") + @Nullable Set supportedRunners; + + @Override + public String getDisplayName() { + return "JUnit 4 to 5 Precondition"; + } + + @Override + public String getDescription() { + return "Marks JUnit 4 test classes that can be migrated to JUnit 5 with current recipe capabilities, " + + "including detection of unsupported rules, runners, and `@Parameters` annotations with class-type " + + "source attributes."; + } + + @Override + public MigratabilityAccumulator getInitialValue(ExecutionContext ctx) { + return new MigratabilityAccumulator(); + } + + @Override + public TreeVisitor getScanner(MigratabilityAccumulator acc) { + return new JUnit4ToJunit5PreconditionScanner(acc); + } + + @Override + public TreeVisitor getVisitor(MigratabilityAccumulator acc) { + return new JavaIsoVisitor() { + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) { + if (classDecl.getType() == null) { // missing type attribution, possibly parsing error. + return classDecl; + } + String fullQualifiedClassName = classDecl.getType().getFullyQualifiedName(); + return acc.isMigratable(fullQualifiedClassName) ? + SearchResult.found(classDecl) : + classDecl; + } + }; + } + + /** + * A visitor that implements the scanning logic for identifying JUnit 4 test classes that can be + * migrated to JUnit 5. + */ + @RequiredArgsConstructor + private class JUnit4ToJunit5PreconditionScanner extends JavaIsoVisitor { + + private final MigratabilityAccumulator accumulator; + + @Override + public J.ClassDeclaration visitClassDeclaration( + J.ClassDeclaration classDecl, ExecutionContext ctx) { + J.ClassDeclaration cd = super.visitClassDeclaration(classDecl, ctx); + markSupportedRunner(cd, ctx); + + if (classDecl.getType() != null) { + this.accumulator.registerClass( + classDecl.getType().getFullyQualifiedName(), + getParentClassName(classDecl), + !hasUnsupportedFeatures()); + } + return classDecl; + } + + @Override + public J.MethodDeclaration visitMethodDeclaration( + J.MethodDeclaration methodDecl, ExecutionContext ctx) { + // Flag @Parameters annotations with class-type source attributes + flagParametersAnnotationWithClassTypeSourceAttribute(methodDecl, ctx); + if (service(AnnotationService.class).matches(getCursor(), ANY_RULE_ANNOTATION_MATCHER) && + methodDecl.getMethodType() != null) { + flagUnsupportedRule(methodDecl.getMethodType().getReturnType()); + } + return methodDecl; + } + + @Override + public J.VariableDeclarations visitVariableDeclarations( + J.VariableDeclarations variableDeclarations, ExecutionContext ctx) { + if (service(AnnotationService.class).matches(getCursor(), ANY_RULE_ANNOTATION_MATCHER)) { + return super.visitVariableDeclarations(variableDeclarations, ctx); + } + return variableDeclarations; + } + + @Override + public J.VariableDeclarations.NamedVariable visitVariable( + J.VariableDeclarations.NamedVariable variable, ExecutionContext ctx) { + if (variable.getInitializer() != null) { + flagUnsupportedRule(variable.getInitializer().getType()); + } + return variable; + } + + // Flag @Parameters annotations with class-type source attributes + private void flagParametersAnnotationWithClassTypeSourceAttribute( + J.MethodDeclaration methodDecl, ExecutionContext ctx) { + Cursor methodCursor = getCursor(); + new Annotated.Matcher("junitparams.Parameters").asVisitor(a -> + (new JavaIsoVisitor() { + @Override + public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) { + Expression variable = assignment.getVariable(); + if (variable instanceof J.Identifier) { + J.Identifier identifier = (J.Identifier) variable; + if ("source".equals(identifier.getSimpleName()) && + assignment.getAssignment() instanceof J.FieldAccess && + "class".equals(((J.FieldAccess) assignment.getAssignment()).getSimpleName())) { + methodCursor.dropParentUntil(J.ClassDeclaration.class::isInstance) + .putMessage(HAS_CLASS_TYPE_SOURCE_ATTRIBUTE, true); + } + } + return assignment; + } + }).visit(a.getTree(), ctx)) + .visit(methodDecl, ctx); + } + + private void flagUnsupportedRule(JavaType javaType) { + JavaType.FullyQualified ruleType = TypeUtils.asFullyQualified(javaType); + if (ruleType != null && + !emptySetIfNull(supportedRules).contains(ruleType.getFullyQualifiedName()) && + emptySetIfNull(supportedRuleTypes).stream().noneMatch(s -> TypeUtils.isAssignableTo(s, javaType))) { + getCursor() + .dropParentUntil(J.ClassDeclaration.class::isInstance) + .putMessage(HAS_UNSUPPORTED_RULE, true); + } + } + + private void markSupportedRunner(J.ClassDeclaration classDecl, ExecutionContext ctx) { + Cursor classCursor = getCursor(); + new Annotated.Matcher("@org.junit.runner.RunWith").asVisitor(a -> + (new JavaIsoVisitor() { + @Override + public J.FieldAccess visitFieldAccess(J.FieldAccess fieldAccess, ExecutionContext ctx) { + JavaType.FullyQualified type = TypeUtils.asFullyQualified(fieldAccess.getTarget().getType()); + if (type != null && !emptySetIfNull(supportedRunners).contains(type.getFullyQualifiedName())) { + classCursor.putMessage(HAS_UNSUPPORTED_RUNNER, true); + } + // missing type attribution, possibly parsing error + return fieldAccess; + } + }).visit(a.getTree(), ctx)) + .visit(classDecl, ctx); + } + + private boolean hasUnsupportedFeatures() { + return Boolean.TRUE.equals(getCursor().getMessage(HAS_UNSUPPORTED_RULE)) || + Boolean.TRUE.equals(getCursor().getMessage(HAS_UNSUPPORTED_RUNNER)) || + Boolean.TRUE.equals(getCursor().getMessage(HAS_CLASS_TYPE_SOURCE_ATTRIBUTE)); + } + + private @Nullable String getParentClassName(J.ClassDeclaration classDecl) { + if (classDecl.getExtends() != null) { + JavaType.FullyQualified parentClass = TypeUtils.asFullyQualified(classDecl.getExtends().getType()); + if (parentClass != null) { + return parentClass.getFullyQualifiedName(); + } + } + return null; + } + } + + /** + * Accumulator to store migratability information of classes. + */ + public class MigratabilityAccumulator { + + private final Map classToParentMap = new HashMap<>(); + private final Map declaredMigratable = emptySetIfNull(knownMigratableClasses).stream() + .collect(toMap(Function.identity(), key -> Boolean.TRUE)); + + /** + * Registers a class with its parent and whether it is migratable. + */ + public void registerClass(String className, @Nullable String parentClassName, boolean isMigratable) { + classToParentMap.put(className, parentClassName); + if (isMigratable) { + declaredMigratable.put(className, true); + if (parentClassName != null) { + declaredMigratable.putIfAbsent(parentClassName, false); + } + } else { + // Mark this class and all its ancestors as non-migratable + String currentClass = className; + while (currentClass != null && !emptySetIfNull(knownMigratableClasses).contains(currentClass)) { + declaredMigratable.put(currentClass, false); + currentClass = classToParentMap.get(currentClass); + } + } + } + + /** + * Returns true if the class and all of its ancestors are declared migratable. + */ + public boolean isMigratable(String className) { + String currentClass = className; + while (currentClass != null) { + Boolean isMigratable = declaredMigratable.get(currentClass); + if (isMigratable == null || !isMigratable) { + return false; + } + currentClass = classToParentMap.get(currentClass); + } + return true; + } + } + + private static Set emptySetIfNull(@Nullable Set set) { + return set == null ? emptySet() : set; + } +} diff --git a/src/test/java/org/openrewrite/java/testing/junit5/JUnit4ToJunit5PreconditionTest.java b/src/test/java/org/openrewrite/java/testing/junit5/JUnit4ToJunit5PreconditionTest.java new file mode 100644 index 000000000..4c2147ca1 --- /dev/null +++ b/src/test/java/org/openrewrite/java/testing/junit5/JUnit4ToJunit5PreconditionTest.java @@ -0,0 +1,529 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.java.testing.junit5; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.InMemoryExecutionContext; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import java.util.Set; + +import static org.openrewrite.java.Assertions.java; + +class JUnit4ToJunit5PreconditionTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new JUnit4ToJunit5Precondition( + Set.of("MigratableBaseTestClass"), + Set.of("org.junit.rules.TemporaryFolder"), + Set.of("org.junit.rules.ExternalResource"), + Set.of("org.junit.runners.Parameterized"))) + .parser( + JavaParser.fromJavaVersion() + .classpathFromResources(new InMemoryExecutionContext(),"junit-4", "junit-jupiter-api-5") + // language=java + .dependsOn( + """ + public class MigratableBaseTestClass { + } + """, + """ + public class NonMigratableBaseTestClass { + } + """, + """ + package io.grpc.testing; + + import org.junit.rules.ExternalResource; + + public class GrpcCleanupRule extends ExternalResource { + } + """, + // Stubbing for junitparams.Parameters + """ + package junitparams; + + import java.lang.annotation.*; + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD}) + public @interface Parameters { + Class source(); + } + """ + ) + ); + } + + @DocumentExample + @Test + void extendsMigratableBaseTestClass() { + rewriteRun( + // language=java + java( + """ + import com.uber.fievel.testing.base.FievelTestBase; + import org.junit.Test; + + public class Junit4Test extends MigratableBaseTestClass { + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """, + """ + import com.uber.fievel.testing.base.FievelTestBase; + import org.junit.Test; + + /*~~>*/public class Junit4Test extends MigratableBaseTestClass { + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void hasSupportedRule() { + rewriteRun( + // language=java + java( + """ + import org.junit.Rule; + import org.junit.Test; + import org.junit.rules.TemporaryFolder; + + public class Junit4Test { + @Rule TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """, + """ + import org.junit.Rule; + import org.junit.Test; + import org.junit.rules.TemporaryFolder; + + /*~~>*/public class Junit4Test { + @Rule TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void hasSupportedRunner() { + rewriteRun( + // language=java + java( + """ + import org.junit.Test; + import org.junit.runner.RunWith; + import org.junit.runners.Parameterized; + + @RunWith(Parameterized.class) + public class Junit4Test { + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """, + """ + import org.junit.Test; + import org.junit.runner.RunWith; + import org.junit.runners.Parameterized; + + /*~~>*/@RunWith(Parameterized.class) + public class Junit4Test { + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void extendsNonMigratableBaseTestClass() { + rewriteRun( + // language=java + java( + """ + import org.junit.Test; + + public class Junit4Test extends NonMigratableBaseTestClass { + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void abstractTestBaseClass() { + rewriteRun( + // language=java + java( + """ + import org.junit.Before; + + public abstract class AbstractTest { + @Before + public void setup() { + System.out.println("Hello, world!"); + } + } + """, + """ + import org.junit.Before; + + /*~~>*/public abstract class AbstractTest { + @Before + public void setup() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void unsupportedRunner() { + rewriteRun( + // language=java + java( + """ + import org.junit.experimental.theories.Theories; + import org.junit.runner.RunWith; + import org.junit.Test; + + @RunWith(Theories.class) + public class Junit4Test { + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void unsupportedRule() { + rewriteRun( + // language=java + java( + """ + import org.junit.Rule; + import org.junit.rules.ErrorCollector; + import org.junit.Test; + + public class Junit4Test { + @Rule public ErrorCollector rule = new ErrorCollector(); + @Test + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void abstractTestAndSupportedRunnerTest() { + // language=java + rewriteRun( + java( + """ + import org.junit.Before; + + public abstract class AbstractTest { + @Before + public void setup() { + System.out.println("Setup method"); + } + } + """, + """ + import org.junit.Before; + + /*~~>*/public abstract class AbstractTest { + @Before + public void setup() { + System.out.println("Setup method"); + } + } + """ + ), + java( + """ + import org.junit.Test; + import org.junit.runner.RunWith; + import org.junit.runners.Parameterized; + + @RunWith(Parameterized.class) + public class SupportedRunnerTest extends AbstractTest { + @Test + public void test() { + System.out.println("Test method"); + } + } + """, + """ + import org.junit.Test; + import org.junit.runner.RunWith; + import org.junit.runners.Parameterized; + + /*~~>*/@RunWith(Parameterized.class) + public class SupportedRunnerTest extends AbstractTest { + @Test + public void test() { + System.out.println("Test method"); + } + } + """ + )); + } + + @Test + void abstractTestAndUnsupportedRuleTest() { + // language=java + rewriteRun( + java( + // no change since extended by an unmigratable class + """ + import org.junit.Before; + + public abstract class AbstractTest { + @Before + public void setup() { + System.out.println("Setup method"); + } + } + """ + ), + java( + """ + import org.junit.Rule; + import org.junit.rules.ErrorCollector; + import org.junit.Test; + + public class UnsupportedRuleTest extends AbstractTest { + @Rule public ErrorCollector rule = new ErrorCollector(); + @Test + public void test() { + System.out.println("Test method"); + } + } + """ + )); + } + + @Test + void abstractTestSupportedRuleTestAndUnsupportedRunnerTest() { + // language=java + rewriteRun( + java( + // no change since one of the test class (UnSupportedRunnerTest) in group can't be migrated. + """ + import org.junit.Before; + + public abstract class AbstractTest { + @Before + public void setup() { + System.out.println("Setup method"); + } + } + """ + ), + java( + """ + import org.junit.Rule; + import org.junit.rules.TemporaryFolder; + import org.junit.Test; + + public class SupportedRuleTest extends AbstractTest { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void test() { + System.out.println("Test method"); + } + } + """ + ), + java( + """ + import org.junit.experimental.theories.Theories; + import org.junit.runner.RunWith; + import org.junit.Test; + + @RunWith(Theories.class) + public class UnSupportedRunnerTest extends AbstractTest { + @Test + public void test() { + System.out.println("Test method"); + } + } + """ + )); + } + + @Test + void twoClassesWithSupportedAndUnsupportedRules() { + // language=java + rewriteRun( + java( + """ + import org.junit.Rule; + import org.junit.Test; + import org.junit.rules.TemporaryFolder; + + public class SupportedRulesClass { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void test() { + System.out.println("Test with supported rule"); + } + } + """, + """ + import org.junit.Rule; + import org.junit.Test; + import org.junit.rules.TemporaryFolder; + + /*~~>*/public class SupportedRulesClass { + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void test() { + System.out.println("Test with supported rule"); + } + } + """ + ), + java( + """ + import org.junit.Rule; + import org.junit.Test; + import org.junit.rules.ErrorCollector; + + public class UnsupportedRulesClass { + @Rule public ErrorCollector errorCollector = new ErrorCollector(); + @Test + public void test() { + System.out.println("Test with unsupported rule"); + } + } + """ + )); + } + + @Test + void supportedRunnerSubclassesTest() { + rewriteRun( + // language=java + java( + """ + import org.junit.Test; + import org.junit.runner.RunWith; + import org.mockito.junit.MockitoJUnitRunner; + @RunWith(MockitoJUnitRunner.StrictStubs.class) + public class SupportedRunnerSubclassTest { + @Test + public void test() { + System.out.println("Test with another supported runner"); + } + } + """, + """ + import org.junit.Test; + import org.junit.runner.RunWith; + import org.mockito.junit.MockitoJUnitRunner; + /*~~>*/@RunWith(MockitoJUnitRunner.StrictStubs.class) + public class SupportedRunnerSubclassTest { + @Test + public void test() { + System.out.println("Test with another supported runner"); + } + } + """ + )); + } + + @Test + void unsupportedParametersAnnotationWithClassTypeSourceAttribute() { + rewriteRun( + // language=java + java( + // no change since the @Parameters annotation has a class-type for its source attribute + """ + import org.junit.Rule; + import org.junit.Test; + import org.junit.rules.TemporaryFolder; + import junitparams.Parameters; + + public class Junit4Test{ + @Rule TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + @Parameters(source = String.class) + public void test() { + System.out.println("Hello, world!"); + } + } + """ + )); + } + + @Test + void supportedRuleTypes() { + rewriteRun( + // language=java + java( + """ + import org.junit.Rule; + import io.grpc.testing.GrpcCleanupRule; + + public class ExternalResourceRule { + @Rule public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + } + """, + """ + import org.junit.Rule; + import io.grpc.testing.GrpcCleanupRule; + + /*~~>*/public class ExternalResourceRule { + @Rule public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + } + """ + )); + } +}