Skip to content

Commit

Permalink
Migrate Hamcrest to JUnit 5 (#343)
Browse files Browse the repository at this point in the history
* WIP: Add recipe for migration from Hamcrest

Work in progress implementation of a recipe which migrates Hamcrest test matchers to JUnit5 test assertions.

Signed-off-by: matus.matok <[email protected]>

* Add missing license headers

* Resolve some of the test issues

* Fix test import

* Add proto implementation for assertEquals

Added a prototype-y implementation of translation from hamcrest's equalTo to JUnit5's assertEquals. Should be easy to add more of the simple hamcrest matchers to this implementation.

\TODO the import is not being added

Signed-off-by: matus.matok <[email protected]>

* Use static import and #{any(java.lang.Object)} to fix test

* Adapt to main

Another iteration of the prototype. Adapted it to be
based on the up-to-date main.
Added a proposal of how similar simple matchers could be
translated to junit assertion methods.

Signed-off-by: matus.matok <[email protected]>

* Add more simple matcher-to-method translations

Added translations for matchers closeTo, containsString, empty, emptyArray, emptyIterable, emptyCollectionOf, emptyIterableOf, endsWith.
Tests need to be added for each matcher.

Signed-off-by: matus.matok <[email protected]>

* Add more simple matcher-to-method translations

Added all the simple matcher-to-method translations.
Tests need to be added for each matcher.

Signed-off-by: matus.matok <[email protected]>

* Add tests

Added a bunch of tests, to verify the correctness of each matcher-to-assertion case.

Signed-off-by: matus.matok <[email protected]>

* Finalise the pull request

Added all the necessary unit tests and polished out the implementation.

Signed-off-by: matus.matok <[email protected]>

* Add required license header

* Move classes to align with the Hamcrest to AssertJ implementation

* Consistently use `class Test` to avoid conflicts with `@Test`

* Refactored and split HamcrestMatcherToJUnit5 recipe

Refactored HamcrestMatcherToJUnit5 recipe, so now the whole translation is stored in one place, not scatter amongst three methods. Given the existence of RemoveIsMatcher Recipe, this recipe relies that it will never encounter is() matcher.
In similar fashion as RemoveIsMatcher, RemoveNotMatcher was added, which does exactly the same as RemoveIsMatcher, but it also stores the logical context for the nested matcher (so that it knows it was negated) in execution context.
Matchers instanceOf and isA were difficult to handle within the HamcrestMatcherToJUnit5 recipe, therefore these cases were moved to a newly added HamcrestInstanceOfToJUnit5 recipe.

Signed-off-by: matus.matok <[email protected]>

* Add license headers

Forgot, added now

Signed-off-by: matus.matok <[email protected]>

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Fix compilation

* Update description to use JUnit

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Format tests

* Drop RemoveNotMatcher recipe; retain visitor only

To avoid misuse

* Drop AssertThatBooleanToJUnit5; replace with declarative recipes

* Apply suggestions from code review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Limit execution through preconditions

* Add preconditions to HamcrestMatcherToJUnit5

* Minor polish

* Extract and reuse `ConsistentHamcrestMatcherImports`

* Polish recipe display name and description

---------

Signed-off-by: matus.matok <[email protected]>
Co-authored-by: Tim te Beek <[email protected]>
Co-authored-by: Tim te Beek <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
4 people authored Dec 10, 2024
1 parent 1d1b915 commit f0d765a
Show file tree
Hide file tree
Showing 8 changed files with 1,757 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* 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.hamcrest;

import org.openrewrite.ExecutionContext;
import org.openrewrite.Preconditions;
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.search.UsesMethod;
import org.openrewrite.java.tree.Expression;
import org.openrewrite.java.tree.J;

import java.util.ArrayList;
import java.util.List;

public class HamcrestInstanceOfToJUnit5 extends Recipe {
@Override
public String getDisplayName() {
return "Migrate from Hamcrest `instanceOf` matcher to JUnit 5";
}

@Override
public String getDescription() {
return "Migrate from Hamcrest `instanceOf` and `isA` matcher to JUnit5 `assertInstanceOf` assertion.";
}

private static final MethodMatcher INSTANCE_OF_MATCHER = new MethodMatcher("org.hamcrest.Matchers instanceOf(..)");
private static final MethodMatcher IS_A_MATCHER = new MethodMatcher("org.hamcrest.Matchers isA(..)");
private static final MethodMatcher ASSERT_THAT_MATCHER = new MethodMatcher("org.hamcrest.MatcherAssert assertThat(.., org.hamcrest.Matcher)");

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
TreeVisitor<?, ExecutionContext> preconditions = Preconditions.and(
new UsesMethod<>(ASSERT_THAT_MATCHER),
Preconditions.or(
new UsesMethod<>(INSTANCE_OF_MATCHER),
new UsesMethod<>(IS_A_MATCHER)));
return Preconditions.check(preconditions, new JavaIsoVisitor<ExecutionContext>() {
@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation mi, ExecutionContext ctx) {
if (ASSERT_THAT_MATCHER.matches(mi)) {
Expression reason;
Expression examinedObject;
Expression hamcrestMatcher;

if (mi.getArguments().size() == 2) {
reason = null;
examinedObject = mi.getArguments().get(0);
hamcrestMatcher = mi.getArguments().get(1);
} else if (mi.getArguments().size() == 3) {
reason = mi.getArguments().get(0);
examinedObject = mi.getArguments().get(1);
hamcrestMatcher = mi.getArguments().get(2);
} else {
return mi;
}

J.MethodInvocation matcherInvocation = (J.MethodInvocation) hamcrestMatcher;
while ("not".equals(matcherInvocation.getSimpleName())) {
maybeRemoveImport("org.hamcrest.Matchers.not");
maybeRemoveImport("org.hamcrest.CoreMatchers.not");
matcherInvocation = (J.MethodInvocation) new RemoveNotMatcherVisitor().visit(matcherInvocation, ctx);
}

if (INSTANCE_OF_MATCHER.matches(matcherInvocation) || IS_A_MATCHER.matches(matcherInvocation)) {
boolean logicalContext = RemoveNotMatcherVisitor.getLogicalContext(matcherInvocation, ctx);

String templateString = (logicalContext ?
"assertInstanceOf(#{any(java.lang.Class)}, #{any(java.lang.Object)}" :
"assertFalse(#{any(java.lang.Class)}.isAssignableFrom(#{any(java.lang.Object)}.getClass())") +
(reason == null ? ")" : ", #{any(java.lang.String)})");

JavaTemplate template = JavaTemplate.builder(templateString)
.javaParser(JavaParser.fromJavaVersion().classpathFromResources(ctx, "junit-jupiter-api-5.9"))
.staticImports("org.junit.jupiter.api.Assertions." + (logicalContext ? "assertInstanceOf" : "assertFalse"))
.build();

maybeRemoveImport("org.hamcrest.MatcherAssert.assertThat");
maybeRemoveImport("org.hamcrest.Matchers.instanceOf");
maybeRemoveImport("org.hamcrest.CoreMatchers.instanceOf");
maybeRemoveImport("org.hamcrest.Matchers.isA");
maybeRemoveImport("org.hamcrest.CoreMatchers.isA");
maybeAddImport("org.junit.jupiter.api.Assertions", logicalContext ? "assertInstanceOf" : "assertFalse");

List<Expression> arguments = new ArrayList<>();
arguments.add(matcherInvocation.getArguments().get(0));
arguments.add(examinedObject);
if (reason != null) {
arguments.add(reason);
}

return template.apply(getCursor(), mi.getCoordinates().replace(), arguments.toArray());
}
}
return super.visitMethodInvocation(mi, ctx);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* 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.hamcrest;

import org.openrewrite.ExecutionContext;
import org.openrewrite.Preconditions;
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.search.UsesMethod;
import org.openrewrite.java.tree.Expression;
import org.openrewrite.java.tree.J;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;

public class HamcrestMatcherToJUnit5 extends Recipe {

private static final MethodMatcher MATCHER_ASSERT_MATCHER = new MethodMatcher("org.hamcrest.MatcherAssert assertThat(.., org.hamcrest.Matcher)");

@Override
public String getDisplayName() {
return "Migrate from Hamcrest `Matcher` to JUnit 5";
}

@Override
public String getDescription() {
return "Migrate from Hamcrest `Matcher` to JUnit 5 assertions.";
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return Preconditions.check(
new UsesMethod<>(MATCHER_ASSERT_MATCHER),
new MigrationFromHamcrestVisitor());
}

enum Replacement {
EQUALTO("equalTo", "assertEquals", "assertNotEquals", "#{any(java.lang.Object)}, #{any(java.lang.Object)}", "examinedObjThenMatcherArgs"),
EMPTYARRAY("emptyArray", "assertEquals", "assertNotEquals", "0, #{anyArray(java.lang.Object)}.length", "examinedObjOnly"),
HASENTRY("hasEntry", "assertEquals", "assertNotEquals", "#{any(java.lang.Object)}, #{any(java.util.Map)}.get(#{any(java.lang.Object)})", "matcher1ExaminedObjMatcher0"),
HASSIZE("hasSize", "assertEquals", "assertNotEquals", "#{any(java.util.Collection)}.size(), #{any(double)}", "examinedObjThenMatcherArgs"),
HASTOSTRING("hasToString", "assertEquals", "assertNotEquals", "#{any(java.lang.Object)}.toString(), #{any(java.lang.String)}", "examinedObjThenMatcherArgs"),
CLOSETO("closeTo", "assertTrue", "assertFalse", "Math.abs(#{any(double)} - #{any(double)}) < #{any(double)}", "examinedObjThenMatcherArgs"),
CONTAINSSTRING("containsString", "assertTrue", "assertFalse", "#{any(java.lang.String)}.contains(#{any(java.lang.String)}", "examinedObjThenMatcherArgs"),
EMPTY("empty", "assertTrue", "assertFalse", "#{any(java.util.Collection)}.isEmpty()", "examinedObjOnly"),
ENDSWITH("endsWith", "assertTrue", "assertFalse", "#{any(java.lang.String)}.endsWith(#{any(java.lang.String)})", "examinedObjThenMatcherArgs"),
EQUALTOIGNORINGCASE("equalToIgnoringCase", "assertTrue", "assertFalse", "#{any(java.lang.String)}.equalsIgnoreCase(#{any(java.lang.String)})", "examinedObjThenMatcherArgs"),
GREATERTHAN("greaterThan", "assertTrue", "assertFalse", "#{any(double)} > #{any(double)}", "examinedObjThenMatcherArgs"),
GREATERTHANOREQUALTO("greaterThanOrEqualTo", "assertTrue", "assertFalse", "#{any(double)} >= #{any(double)}", "examinedObjThenMatcherArgs"),
HASKEY("hasKey", "assertTrue", "assertFalse", "#{any(java.util.Map)}.containsKey(#{any(java.lang.Object)})", "examinedObjThenMatcherArgs"),
HASVALUE("hasValue", "assertTrue", "assertFalse", "#{any(java.util.Map)}.containsValue(#{any(java.lang.Object)})", "examinedObjThenMatcherArgs"),
LESSTHAN("lessThan", "assertTrue", "assertFalse", "#{any(double)} < #{any(double)}", "examinedObjThenMatcherArgs"),
LESSTHANOREQUALTO("lessThanOrEqualTo", "assertTrue", "assertFalse", "#{any(double)} <= #{any(double)}", "examinedObjThenMatcherArgs"),
STARTSWITH("startsWith", "assertTrue", "assertFalse", "#{any(java.lang.String)}.startsWith(#{any(java.lang.String)})", "examinedObjThenMatcherArgs"),
TYPECOMPATIBLEWITH("typeCompatibleWith", "assertTrue", "assertFalse", "#{any(java.lang.Class)}.isAssignableFrom(#{any(java.lang.Class)})", "matcherArgsThenExaminedObj"),
NOTNULLVALUE("notNullValue", "assertNotNull", "assertNull", "#{any(java.lang.Object)}", "examinedObjOnly"),
NULLVALUE("nullValue", "assertNull", "assertNotNull", "#{any(java.lang.Object)}", "examinedObjOnly"),
SAMEINSTANCE("sameInstance", "assertSame", "assertNotSame", "#{any(java.lang.Object)}, #{any(java.lang.Object)}", "examinedObjThenMatcherArgs"),
THEINSTANCE("theInstance", "assertSame", "assertNotSame", "#{any(java.lang.Object)}, #{any(java.lang.Object)}", "examinedObjThenMatcherArgs"),
EMPTYITERABLE("emptyIterable", "assertFalse", "assertTrue", "#{any(java.lang.Iterable)}.iterator().hasNext()", "examinedObjOnly");

final String hamcrest, junitPositive, junitNegative, template;
final String argumentsMethod;

private static final Map<String, BiFunction<Expression, J.MethodInvocation, List<Expression>>> methods = new HashMap<>();

static {
methods.put("examinedObjThenMatcherArgs", (ex, matcher) -> {
List<Expression> arguments = matcher.getArguments();
arguments.add(0, ex);
return arguments;
});
methods.put("matcherArgsThenExaminedObj", (ex, matcher) -> {
List<Expression> arguments = matcher.getArguments();
arguments.add(ex);
return arguments;
});
methods.put("examinedObjOnly", (ex, matcher) -> {
List<Expression> arguments = new ArrayList<>();
arguments.add(ex);
return arguments;
});
methods.put("matcher1ExaminedObjMatcher0", (ex, matcher) -> {
List<Expression> arguments = new ArrayList<>();
arguments.add(matcher.getArguments().get(1));
arguments.add(ex);
arguments.add(matcher.getArguments().get(0));
return arguments;
});
}

Replacement(String hamcrest, String junitPositive, String junitNegative, String template, String argumentsMethod) {
this.hamcrest = hamcrest;
this.junitPositive = junitPositive;
this.junitNegative = junitNegative;
this.template = template;
this.argumentsMethod = argumentsMethod;
}
}

private static class MigrationFromHamcrestVisitor extends JavaIsoVisitor<ExecutionContext> {

@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
J.MethodInvocation mi = super.visitMethodInvocation(method, ctx);

if (MATCHER_ASSERT_MATCHER.matches(mi)) {
Expression reason;
Expression examinedObject;
Expression hamcrestMatcher;

if (mi.getArguments().size() == 2) {
reason = null;
examinedObject = mi.getArguments().get(0);
hamcrestMatcher = mi.getArguments().get(1);
} else if (mi.getArguments().size() == 3) {
reason = mi.getArguments().get(0);
examinedObject = mi.getArguments().get(1);
hamcrestMatcher = mi.getArguments().get(2);
} else {
return mi;
}

if (hamcrestMatcher instanceof J.MethodInvocation) {
J.MethodInvocation matcherInvocation = (J.MethodInvocation) hamcrestMatcher;
maybeRemoveImport("org.hamcrest.MatcherAssert.assertThat");

while ("not".equals(matcherInvocation.getSimpleName())) {
maybeRemoveImport("org.hamcrest.Matchers.not");
maybeRemoveImport("org.hamcrest.CoreMatchers.not");
matcherInvocation = (J.MethodInvocation) new RemoveNotMatcherVisitor().visit(matcherInvocation, ctx);
}

//we do not handle nested matchers
if (!(matcherInvocation.getArguments().get(0) instanceof J.Empty)) {
if ((matcherInvocation.getArguments().get(0).getType()).toString().startsWith("org.hamcrest")) {
return mi;
}
}

boolean logicalContext = RemoveNotMatcherVisitor.getLogicalContext(matcherInvocation, ctx);

Replacement replacement;
try {
replacement = Replacement.valueOf(matcherInvocation.getSimpleName().toUpperCase());
} catch (IllegalArgumentException e) {
return mi;
}
String assertion = logicalContext ? replacement.junitPositive : replacement.junitNegative;
String templateString = assertion + "(" + replacement.template + (reason == null ? ")" : ", #{any(java.lang.String)})");
JavaTemplate template = JavaTemplate.builder(templateString)
.javaParser(JavaParser.fromJavaVersion().classpathFromResources(ctx, "junit-jupiter-api-5.9"))
.staticImports("org.junit.jupiter.api.Assertions." + assertion)
.build();

maybeRemoveImport("org.hamcrest.Matchers." + replacement.hamcrest);
maybeRemoveImport("org.hamcrest.CoreMatchers." + replacement.hamcrest);
maybeAddImport("org.junit.jupiter.api.Assertions", assertion);

List<Expression> arguments = Replacement.methods.get(replacement.argumentsMethod).apply(examinedObject, matcherInvocation);
if (reason != null) {
arguments.add(reason);
}

return template.apply(getCursor(), method.getCoordinates().replace(), arguments.toArray());
}
}
return mi;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright 2024 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.hamcrest;

import org.openrewrite.ExecutionContext;
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.J;

import java.security.InvalidParameterException;
import java.util.Objects;

class RemoveNotMatcherVisitor extends JavaIsoVisitor<ExecutionContext> {
static final MethodMatcher NOT_MATCHER = new MethodMatcher("org.hamcrest.Matchers not(..)");

public static boolean getLogicalContext(J.MethodInvocation mi, ExecutionContext ctx) throws InvalidParameterException {
Object msg = ctx.getMessage(mi.toString());
if (msg == null) {
return true;
} else if (msg instanceof Boolean) {
return (Boolean) msg;
} else {
throw new InvalidParameterException();
}
}

@Override
@SuppressWarnings("ConstantConditions")
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation mi, ExecutionContext ctx) {
if (NOT_MATCHER.matches(mi)) {
boolean logicalContext;
if (ctx.pollMessage(mi.toString()) != null) {
logicalContext = ctx.getMessage(mi.toString());
} else {
logicalContext = true;
}

maybeRemoveImport("org.hamcrest.Matchers.not");

J.MethodInvocation result;
if (Objects.requireNonNull(mi.getArguments().get(0).getType()).toString().startsWith("org.hamcrest")) {
result = mi.getArguments().get(0).withPrefix(mi.getPrefix());
} else {
JavaTemplate template = JavaTemplate.builder("equalTo(#{any(java.lang.Object)})")
.javaParser(JavaParser.fromJavaVersion().classpathFromResources(ctx, "hamcrest-2.2"))
.staticImports("org.hamcrest.Matchers.equalTo")
.build();
maybeAddImport("org.hamcrest.Matchers", "equalTo");
result = template.apply(getCursor(), mi.getCoordinates().replace(), mi.getArguments().get(0));
}

ctx.putMessage(result.toString(), !logicalContext);

return result;
} else {
if (ctx.pollMessage(mi.toString()) == null) {
ctx.putMessage(mi.toString(), true);
}
}
return super.visitMethodInvocation(mi, ctx);
}
}
Loading

0 comments on commit f0d765a

Please sign in to comment.