Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate Hamcrest to JUnit 5 #343

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4b2e427
WIP: Add recipe for migration from Hamcrest
matusmatokpt May 22, 2023
e8ffbae
Merge branch 'openrewrite:main' into main
matusmatokpt May 22, 2023
0bb2ffa
Merge branch 'openrewrite:main' into main
matusmatokpt May 23, 2023
0947005
Add missing license headers
timtebeek May 23, 2023
11d9fd2
Resolve some of the test issues
timtebeek May 23, 2023
cc4a929
Fix test import
timtebeek May 23, 2023
bc1d5c8
Add proto implementation for assertEquals
matusmatokpt May 24, 2023
e17fc29
Use static import and #{any(java.lang.Object)} to fix test
timtebeek May 24, 2023
76724ca
Merge branch 'main' into main
matusmatokpt Jun 13, 2023
e360972
Adapt to main
matusmatokpt Jun 13, 2023
66473e8
Add more simple matcher-to-method translations
matusmatokpt Jun 15, 2023
cc581eb
Add more simple matcher-to-method translations
matusmatokpt Jun 22, 2023
0eb3887
Add tests
matusmatokpt Jun 27, 2023
7d11579
Finalise the pull request
matusmatokpt Jun 29, 2023
1730fbf
Merge branch 'main' into main
matusmatokpt Jun 29, 2023
3525302
Merge branch 'main' into main
timtebeek Jun 30, 2023
9357f7a
Add required license header
timtebeek Jun 30, 2023
9e23d71
Move classes to align with the Hamcrest to AssertJ implementation
timtebeek Jun 30, 2023
d75e50f
Consistently use `class Test` to avoid conflicts with `@Test`
timtebeek Jun 30, 2023
21d4728
Refactored and split HamcrestMatcherToJUnit5 recipe
matusmatokpt Aug 2, 2023
5395d63
Merge branch 'main' into main
matusmatokpt Aug 2, 2023
ff0e775
Add license headers
matusmatokpt Aug 2, 2023
483fcb4
Merge branch 'main' into main
timtebeek Nov 21, 2023
26a8646
Merge branch 'main' into main
timtebeek Feb 5, 2024
93448e3
Apply suggestions from code review
timtebeek Jun 15, 2024
be54077
Merge branch 'main' into main
timtebeek Jun 15, 2024
be57cfe
Apply suggestions from code review
timtebeek Jun 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2023 the original author or authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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;
timtebeek marked this conversation as resolved.
Show resolved Hide resolved

import org.openrewrite.ExecutionContext;
import org.openrewrite.Recipe;
import org.openrewrite.TreeVisitor;
import org.openrewrite.java.JavaIsoVisitor;
import org.openrewrite.java.JavaParser;
import org.openrewrite.java.JavaTemplate;
import org.openrewrite.java.MethodMatcher;
import org.openrewrite.java.tree.Expression;
import org.openrewrite.java.tree.J;

import java.util.List;

public class MigrateFromHamcrest extends Recipe {
@Override
public String getDisplayName() {
return "Migrate from Hamcrest Matchers to JUnit5";
}

@Override
public String getDescription() {
return "This recipe will migrate all Hamcrest Matchers to JUnit5 assertions.";
}

@Override
protected TreeVisitor<?, ExecutionContext> getVisitor() {
return new MigrationFromHamcrestVisitor();
}

private static class MigrationFromHamcrestVisitor extends JavaIsoVisitor<ExecutionContext> {

@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext executionContext) {
J.MethodInvocation mi = super.visitMethodInvocation(method, executionContext);
MethodMatcher matcherAssertTrue = new MethodMatcher("org.hamcrest.MatchAssert assertThat(String, boolean)");
MethodMatcher matcherAssertMatcher = new MethodMatcher("org.hamcrest.MatcherAssert assertThat(*, org.hamcrest.Matcher)");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why this matcher is failing, but you can work around it by for now adopting:

Suggested change
MethodMatcher matcherAssertMatcher = new MethodMatcher("org.hamcrest.MatcherAssert assertThat(*, org.hamcrest.Matcher)");
MethodMatcher matcherAssertMatcher = new MethodMatcher("org.hamcrest.MatcherAssert assertThat(..)");

You might need to match the three different MethodMatcher assertThat methods in a specific order with this .. variant last, but at least that will get you going.

Copy link
Contributor Author

@matusmatokpt matusmatokpt Jun 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seemingly has something to do with matcherMethod being called on primitives, for example equalTo(5).

MethodMatcher matcherAssertMatcherWithReason = new MethodMatcher("org.hamcrest.MatcherAssert assertThat(String,*,org.hamcrest.Matcher)");

if (matcherAssertTrue.matches(mi)) {
//TODO simple
} else if (matcherAssertMatcher.matches(mi)) {
Expression hamcrestMatcher = mi.getArguments().get(1);
if (hamcrestMatcher instanceof J.MethodInvocation) {
J.MethodInvocation matcherInvocation = (J.MethodInvocation)hamcrestMatcher;
maybeRemoveImport("org.hamcrest.Matchers." + matcherInvocation.getSimpleName());
maybeRemoveImport("org.hamcrest.MatcherAssert.assertThat");
String targetAssertion = getTranslatedAssert(matcherInvocation);
JavaTemplate template = JavaTemplate.builder(this::getCursor, getTemplateForTranslatedAssertion(targetAssertion))
.javaParser(JavaParser.fromJavaVersion().classpathFromResources(executionContext, "junit-jupiter-api-5.9"))
.staticImports("org.junit.jupiter.api.Assertions." + targetAssertion)
.build();
mi = withTemplate(mi, template, mi.getArguments().get(0), stripMatcherInvocation(mi.getArguments().get(1)));
maybeAddImport("org.junit.jupiter.api.Assertions", targetAssertion);
}
else throw new IllegalArgumentException("Parameter mismatch for " + mi + ".");
}
return mi;
}

private J.MethodInvocation withTemplate(J.MethodInvocation method, JavaTemplate template, Expression firstArg, List<Expression> matcherArgs) {
switch (matcherArgs.size()) {
case 0:
return method.withTemplate(template, method.getCoordinates().replace(), firstArg);
case 1:
return method.withTemplate(template, method.getCoordinates().replace(), firstArg, matcherArgs.get(0));
case 2 :
return method.withTemplate(template, method.getCoordinates().replace(), firstArg, matcherArgs.get(0), matcherArgs.get(1));
case 3 :
return method.withTemplate(template, method.getCoordinates().replace(), firstArg, matcherArgs.get(0), matcherArgs.get(1), matcherArgs.get(2));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last argument in withTemplate is actually a varargs. So you could reduce this when opting for a Object[] args as seen here.

Object[] args;
if (mi.getArguments().size() == 2) {
args = new Object[]{s.getSelect(), s.getArguments().get(0), mi.getArguments().get(1)};
sb.append(", #{any()}");
} else {
args = new Object[]{s.getSelect(), s.getArguments().get(0)};

default:
throw new IllegalArgumentException("List of matcher arguments is too long for " + method + ".");
}
}

private List<Expression> stripMatcherInvocation(Expression e) {
if (e instanceof J.MethodInvocation) {
MethodMatcher matchesMatcher = new MethodMatcher("org.hamcrest.Matchers *(..)");
if (matchesMatcher.matches(e)) {
return ((J.MethodInvocation) e).getArguments();
}
}
throw new IllegalArgumentException("Trying to strip an expression which is not a matcher invocation:\n" + e);
}

private String getTranslatedAssert(J.MethodInvocation methodInvocation) {
//to be replaced with a static map
switch (methodInvocation.getSimpleName()) {
case "equalTo":
return "assertEquals";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than statically defining the various options here, we could also look at turning the hamcrestMatcher and targetAssertion into @Option arguments to the recipe, such that we can configure this repeatedly from a declarative Yaml description file. We already do something similar for Mockito, except in thise case you'd be configuring this new recipe rather than ChangeMethodName. Such an approach pull those replacements out of code and into configuration, which is typically easier to expand on, even locally, for our users.

}
throw new IllegalArgumentException("Translation of matcher " + methodInvocation.getSimpleName() + " not yet supported.");
}

private String getTemplateForTranslatedAssertion(String translatedAssertion) {
//to be replaced with a static map
switch (translatedAssertion) {
case "assertEquals":
return "assertEquals(#{any(java.lang.Object)}, #{any(java.lang.Object)})";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the recipe will fail currently with any 3/4 method arguments, as the template hardcodes the expected number of arguments to two; Might be best to add tests for various amounts of arguments and work from there to repeatedly append , #{any(java.lang.Object)} as needed, as also seen here.

sb.append("assertEquals(#{any(java.lang.Object)},#{any(java.lang.Object)}");
Object[] args;
if (mi.getArguments().size() == 2) {
args = new Object[]{s.getSelect(), s.getArguments().get(0), mi.getArguments().get(1)};
sb.append(", #{any()}");
} else {
args = new Object[]{s.getSelect(), s.getArguments().get(0)};
}
sb.append(")");

}
throw new IllegalArgumentException("There is no template defined for assertion " + translatedAssertion);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2023 the original author or authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.InMemoryExecutionContext;
import org.openrewrite.java.JavaParser;
import org.openrewrite.test.RecipeSpec;
import org.openrewrite.test.RewriteTest;

import static org.openrewrite.java.Assertions.java;

class MigrateFromHamcrestTest implements RewriteTest {
@Override
public void defaults(RecipeSpec spec) {
spec
.parser(JavaParser.fromJavaVersion()
.classpathFromResources(new InMemoryExecutionContext(), "junit-jupiter-api-5.9", "hamcrest-2.2"))
.recipe(new MigrateFromHamcrest());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do end up going for declarative yaml recipes for the various matcher-to-method replacements, then we will likely want to import yaml recipes and run those instead of this direct invocation. Here's an example.

.recipe(Environment.builder()
.scanRuntimeClasspath("org.openrewrite.java.testing.mockito")
.build()
.activateRecipes("org.openrewrite.java.testing.mockito.AnyToNullable"));

}

@Test
void equalToObject() {
//language=java
rewriteRun(
java("""
class Biscuit {
String name;
Biscuit(String name) {
this.name = name;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might also want to verify our use of equalTo with Strings and other primitives, just to be sure we don't inadvertently produce errors there.

"""),
java(
"""
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

class BiscuitTest {
@Test
void testEquals() {
Biscuit theBiscuit = new Biscuit("Ginger");
Biscuit myBiscuit = new Biscuit("Ginger");
assertThat(theBiscuit, equalTo(myBiscuit));
}
}
""",
"""
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class BiscuitTest {
@Test
void testEquals() {
Biscuit theBiscuit = new Biscuit("Ginger");
Biscuit myBiscuit = new Biscuit("Ginger");
assertEquals(theBiscuit, myBiscuit);
}
}
"""
));
}
}