diff --git a/build.gradle.kts b/build.gradle.kts index e82431a7d..d3fc8b758 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,7 @@ recipeDependencies { parserClasspath("org.powermock:powermock-core:1.6.5") parserClasspath("org.springframework:spring-test:6.1.+") parserClasspath("org.testcontainers:testcontainers:1.20.6") + parserClasspath("org.testcontainers:junit-jupiter:1.20.6") parserClasspath("org.testng:testng:7.+") parserClasspath("pl.pragmatists:JUnitParams:1.+") parserClasspath("uk.org.webcompere:system-stubs-core:2.1.8") @@ -56,7 +57,6 @@ recipeDependencies { testParserClasspath("org.testcontainers:testcontainers-kafka:2.0.1") testParserClasspath("org.testcontainers:testcontainers-localstack:2.0.1") testParserClasspath("org.testcontainers:testcontainers-mysql:2.0.1") - } val rewriteVersion = rewriteRecipe.rewriteVersion.get() diff --git a/src/main/java/org/openrewrite/java/testing/testcontainers/AddTestcontainersAnnotations.java b/src/main/java/org/openrewrite/java/testing/testcontainers/AddTestcontainersAnnotations.java new file mode 100644 index 000000000..702cc8e07 --- /dev/null +++ b/src/main/java/org/openrewrite/java/testing/testcontainers/AddTestcontainersAnnotations.java @@ -0,0 +1,114 @@ +/* + * Copyright 2025 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.testcontainers; + +import org.openrewrite.ExecutionContext; +import org.openrewrite.Preconditions; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.service.AnnotationService; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.TypeUtils; + +import static java.util.Comparator.comparing; + +/** + * An OpenRewrite recipe that migrates JUnit 4 Testcontainers {@code @Rule} or {@code @ClassRule} + * fields to JUnit 5's {@code @Container} and adds the {@code @Testcontainers} annotation to the + * class if necessary. + */ +public class AddTestcontainersAnnotations extends Recipe { + private static final String CLASS_RULE_FQN = "org.junit.ClassRule"; + private static final String RULE_FQN = "org.junit.Rule"; + private static final String GENERIC_CONTAINER_FQN = "org.testcontainers.containers.GenericContainer"; + private static final String TESTCONTAINERS_FQN = "org.testcontainers.junit.jupiter.Testcontainers"; + private static final String CONTAINER_FQN = "org.testcontainers.junit.jupiter.Container"; + + @Override + public String getDisplayName() { + return "Adopt `@Container` and add `@Testcontainers`"; + } + + @Override + public String getDescription() { + return "Convert Testcontainers `@Rule`/`@ClassRule` to JUnit 5 `@Container` and add `@Testcontainers`."; + } + + @Override + public TreeVisitor getVisitor() { + TreeVisitor usesRule = Preconditions.or( + new UsesType<>(RULE_FQN, true), + new UsesType<>(CLASS_RULE_FQN, true) + ); + return Preconditions.check(usesRule, new JavaIsoVisitor() { + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDeclaration, ExecutionContext ctx) { + J.ClassDeclaration cd = super.visitClassDeclaration(classDeclaration, ctx); + if (classDeclaration == cd) { + return cd; + } + + maybeRemoveImport(RULE_FQN); + maybeRemoveImport(CLASS_RULE_FQN); + + if (service(AnnotationService.class).isAnnotatedWith(cd, TESTCONTAINERS_FQN)) { + return cd; + } + + // Add class level annotation + maybeAddImport(TESTCONTAINERS_FQN); + return JavaTemplate.builder("@Testcontainers") + .imports(TESTCONTAINERS_FQN) + .javaParser(JavaParser.fromJavaVersion().classpathFromResources(ctx, "testcontainers-1", "junit-jupiter-1")) + .build() + .apply(updateCursor(cd), cd.getCoordinates().addAnnotation(comparing(J.Annotation::getSimpleName))); + } + + @Override + public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations varDecls, ExecutionContext ctx) { + if (!TypeUtils.isAssignableTo(GENERIC_CONTAINER_FQN, varDecls.getType())) { + return varDecls; + } + if (!service(AnnotationService.class).isAnnotatedWith(varDecls, RULE_FQN) && + !service(AnnotationService.class).isAnnotatedWith(varDecls, CLASS_RULE_FQN)) { + return varDecls; + } + + // Remove ClassRule/Rule annotations + J.VariableDeclarations vd = varDecls.withLeadingAnnotations(ListUtils.filter(varDecls.getLeadingAnnotations(), + ann -> !TypeUtils.isAssignableTo(RULE_FQN, ann.getType()) && + !TypeUtils.isAssignableTo(CLASS_RULE_FQN, ann.getType()))); + if (vd == varDecls || service(AnnotationService.class).isAnnotatedWith(varDecls, CONTAINER_FQN)) { + return vd; + } + + // Add field level annotation + maybeAddImport(CONTAINER_FQN); + return JavaTemplate.builder("@Container") + .imports(CONTAINER_FQN) + .javaParser(JavaParser.fromJavaVersion().classpathFromResources(ctx, "testcontainers-1", "junit-jupiter-1")) + .build() + .apply(updateCursor(vd), vd.getCoordinates().addAnnotation(comparing(J.Annotation::getSimpleName))); + } + }); + } + +} diff --git a/src/main/resources/META-INF/rewrite/classpath.tsv.gz b/src/main/resources/META-INF/rewrite/classpath.tsv.gz index 6ba67002e..343e015d3 100644 Binary files a/src/main/resources/META-INF/rewrite/classpath.tsv.gz and b/src/main/resources/META-INF/rewrite/classpath.tsv.gz differ diff --git a/src/main/resources/META-INF/rewrite/testcontainers.yml b/src/main/resources/META-INF/rewrite/testcontainers.yml index bdcb55447..06a4b7212 100644 --- a/src/main/resources/META-INF/rewrite/testcontainers.yml +++ b/src/main/resources/META-INF/rewrite/testcontainers.yml @@ -27,6 +27,7 @@ name: org.openrewrite.java.testing.testcontainers.Testcontainers2Migration displayName: Migrate to testcontainers-java 2.x description: Change dependencies and types to migrate to testcontainers-java 2.x. recipeList: + - org.openrewrite.java.testing.testcontainers.AddTestcontainersAnnotations - org.openrewrite.java.testing.testcontainers.ExplicitContainerImages - org.openrewrite.java.testing.testcontainers.GetHostMigration - org.openrewrite.java.ChangeType: diff --git a/src/test/java/org/openrewrite/java/testing/testcontainers/AddTestcontainersAnnotationsTest.java b/src/test/java/org/openrewrite/java/testing/testcontainers/AddTestcontainersAnnotationsTest.java new file mode 100644 index 000000000..3c246a10a --- /dev/null +++ b/src/test/java/org/openrewrite/java/testing/testcontainers/AddTestcontainersAnnotationsTest.java @@ -0,0 +1,347 @@ +/* + * Copyright 2025 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.testcontainers; + +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 static org.openrewrite.java.Assertions.java; + +class AddTestcontainersAnnotationsTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AddTestcontainersAnnotations()) + .parser(JavaParser.fromJavaVersion().classpathFromResources(new InMemoryExecutionContext(), + "junit-4", "testcontainers-1", "junit-jupiter-1")); + } + + @DocumentExample + @Test + void convertsSingleGenericContainerRule() { + rewriteRun( + // language=java + java( + // before + """ + import org.junit.Rule; + import org.junit.Test; + import org.testcontainers.containers.GenericContainer; + + class MyTest { + @Rule + public GenericContainer myContainer = new GenericContainer<>("redis:latest"); + } + """, + // after + """ + import org.junit.Test; + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public GenericContainer myContainer = new GenericContainer<>("redis:latest"); + } + """ + ) + ); + } + + @Test + void convertsMultipleContainerRules() { + rewriteRun( + // language=java + java( + // before + """ + import org.junit.Rule; + import org.testcontainers.containers.GenericContainer; + + class MyTest { + @Rule + public GenericContainer redis = new GenericContainer<>("redis:latest"); + + @Rule + public GenericContainer postgres = new GenericContainer<>("postgres:latest"); + } + """, + // after + """ + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public GenericContainer redis = new GenericContainer<>("redis:latest"); + + @Container + public GenericContainer postgres = new GenericContainer<>("postgres:latest"); + } + """ + ) + ); + } + + @Test + void convertsMixedRuleAndClassRule() { + rewriteRun( + // language=java + java( + // before + """ + import org.junit.ClassRule; + import org.junit.Rule; + import org.testcontainers.containers.GenericContainer; + + class MyTest { + @ClassRule + public static GenericContainer redis = new GenericContainer<>("redis:latest"); + + @Rule + public GenericContainer postgres = new GenericContainer<>("postgres:latest"); + } + """, + // after + """ + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public static GenericContainer redis = new GenericContainer<>("redis:latest"); + + @Container + public GenericContainer postgres = new GenericContainer<>("postgres:latest"); + } + """ + ) + ); + } + + @Test + void convertsSubclassedContainerRule() { + rewriteRun( + // language=java + java( + """ + package com.uber.fievel.testing.redis; + + import org.testcontainers.containers.GenericContainer; + + public class UberRedisContainer> + extends GenericContainer { + } + """ + ), + // language=java + java( + // before + """ + import com.uber.fievel.testing.redis.UberRedisContainer; + import org.junit.ClassRule; + import org.junit.rules.TestRule; + + class MyTest { + @ClassRule + public static UberRedisContainer redisContainer = new UberRedisContainer(); + } + """, + // after + """ + import com.uber.fievel.testing.redis.UberRedisContainer; + import org.junit.rules.TestRule; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public static UberRedisContainer redisContainer = new UberRedisContainer(); + } + """ + ) + ); + } + + @Test + void ignoresNonGenericContainerRule() { + rewriteRun( + // language=java + java( + // before + """ + import org.junit.Rule; + import org.junit.rules.TemporaryFolder; + + class MyTest { + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + } + """ + ) + ); + } + + @Test + void ignoresNonRuleGenericContainer() { + rewriteRun( + java( + // language=java + """ + import org.testcontainers.containers.GenericContainer; + + class MyTest { + public static GenericContainer c_stat = new GenericContainer<>("redis:latest"); + public GenericContainer c = new GenericContainer<>("redis:latest"); + } + """ + ) + ); + } + + @Test + void isIdempotentForAlreadyMigratedClasses() { + rewriteRun( + // language=java + java( + // before + """ + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public GenericContainer myContainer = new GenericContainer<>("redis:latest"); + } + """ + ) + ); + } + + @Test + void handlesPartiallyMigratedClass() { + rewriteRun( + // language=java + java( + // before + """ + import org.junit.Rule; + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Rule + public GenericContainer redis = new GenericContainer<>("redis:latest"); + } + """, + // after + """ + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public GenericContainer redis = new GenericContainer<>("redis:latest"); + } + """ + ) + ); + } + + @Test + void replacesOnlyRuleOrClassRuleAnnotation() { + rewriteRun( + // language=java + java( + // before + """ + import org.junit.Rule; + import org.testcontainers.containers.GenericContainer; + + class MyTest { + @Rule + @Deprecated + public GenericContainer redis = new GenericContainer<>("redis:latest"); + } + """, + // after + """ + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + @Deprecated + public GenericContainer redis = new GenericContainer<>("redis:latest"); + } + """ + ) + ); + } + + @Test + void modifiesOnlyTestClass() { + rewriteRun( + // language=java + java( + """ + import org.testcontainers.containers.GenericContainer; + import org.junit.Rule; + + class MyTest { + @Rule + public GenericContainer myContainer = new GenericContainer<>("redis:latest"); + + class MyInnerClass { + } + } + """, + """ + import org.testcontainers.containers.GenericContainer; + import org.testcontainers.junit.jupiter.Container; + import org.testcontainers.junit.jupiter.Testcontainers; + + @Testcontainers + class MyTest { + @Container + public GenericContainer myContainer = new GenericContainer<>("redis:latest"); + + class MyInnerClass { + } + } + """ + ) + ); + } +}