Hamcrest: How to instanceOf and cast for a matcher?
Asked Answered
U

4

22

Question

Assume the following simple test:

@Test
public void test() throws Exception {
    Object value = 1;
    assertThat(value, greaterThan(0));
}

The test won't compile, because "greaterThan" can only be applied to instances of type Comparable. But I want to assert that value is an Integer which is greater than zero. How can I express that using Hamcrest?

What I tried so far:

The simple solution would be to simply remove the generics by casting the matcher like that:

assertThat(value, (Matcher)greaterThan(0));

Possible, but generates a compiler warning and feels wrong.

A lengthy alternative is:

@Test
public void testName() throws Exception {
    Object value = 1;

    assertThat(value, instanceOfAnd(Integer.class, greaterThan(0)));
}

private static<T> Matcher<Object> instanceOfAnd(final Class<T> clazz, final Matcher<? extends T> submatcher) {
    return new BaseMatcher<Object>() {
        @Override
        public boolean matches(final Object item) {
            return clazz.isInstance(item) && submatcher.matches(clazz.cast(item));
        }

        @Override
        public void describeTo(final Description description) {
            description
                .appendText("is instanceof ")
                .appendValue(clazz)
                .appendText(" and ")
                .appendDescriptionOf(submatcher);
        }

        @Override
        public void describeMismatch(final Object item, final Description description) {
            if (clazz.isInstance(item)) {
                submatcher.describeMismatch(item, description);
            } else {
                description
                    .appendText("instanceof ")
                    .appendValue(item == null ? null : item.getClass());
            }
        }
    };
}

Feels "tidy" and "correct", but it is really a lot of code for something that seems simple. I attempted to find something like that built-in in hamcrest, but I was not successful, but maybe I missed something?

Background

In my actual test case the code is like this:

Map<String, Object> map = executeMethodUnderTest();
assertThat(map, hasEntry(equalTo("the number"), greaterThan(0)));

In my simplified case in the question I could also write assertThat((Integer)value, greaterThan(0)). In my actual case I could write assertThat((Integer)map.get("the number"), greaterThan(0)));, but that would of course worsen the error message if something is wrong.

Umbilical answered 20/7, 2017 at 20:1 Comment(0)
A
14

This answer will not show how to do this using Hamcrest, I do not know if there is a better way than the proposed.

However, if you have the possibility to include another test library, AssertJ supports exactly this:

import org.junit.Test;

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

public class TestClass {

  @Test
  public void test() throws Exception {
    Object value = 1;
    assertThat(value).isInstanceOfSatisfying(Integer.class, integer -> assertThat(integer).isGreaterThan(0));
  }

}

No need for any casting, AssertJ does this for you.

Also, it prints a pretty error message if the assertion fails, with value being too small:

java.lang.AssertionError:
Expecting:
 <0>
to be greater than:
 <0> 

Or if value is not of the correct type:

java.lang.AssertionError: 
Expecting:
 <"not an integer">
to be an instance of:
 <java.lang.Integer>
but was instance of:
 <java.lang.String>

The Javadoc for isInstanceOfSatisfying(Class<T> type, Consumer<T> requirements) can be found here, which also contains some examples of sligthly more complicated assertions:

// second constructor parameter is the light saber color
Object yoda = new Jedi("Yoda", "Green");
Object luke = new Jedi("Luke Skywalker", "Green");

Consumer<Jedi> jediRequirements = jedi -> {
  assertThat(jedi.getLightSaberColor()).isEqualTo("Green");
  assertThat(jedi.getName()).doesNotContain("Dark");
};

// assertions succeed:
assertThat(yoda).isInstanceOfSatisfying(Jedi.class, jediRequirements);
assertThat(luke).isInstanceOfSatisfying(Jedi.class, jediRequirements);

// assertions fail:
Jedi vader = new Jedi("Vader", "Red");
assertThat(vader).isInstanceOfSatisfying(Jedi.class, jediRequirements);
// not a Jedi !
assertThat("foo").isInstanceOfSatisfying(Jedi.class, jediRequirements);
Azygous answered 6/1, 2018 at 11:55 Comment(0)
P
4

The problem is that you lose the type information here:

 Object value = 1;

This is an insanely weird line, if you think about it. Here value is the most generic thing possible, nothing can be reasonably told about it, except maybe checking if it's null or checking its string representation if it's not. I'm sort of at loss trying to imagine a legitimate use case for the above line in modern Java.

The obvious fix would be assertThat((Comparable)value, greaterThan(0));

A better fix would be casting to Integer, because you're comparing to an integer constant; strings are also comparable but only between themselves.

If you can't assume that your value is even Comparable, comparing it to anything is pointless. If your test fails on the cast to Comparable, it's a meaningful report that you dynamic casting to Object from something else failed.

Phocis answered 21/7, 2017 at 15:6 Comment(1)
The minimal compilable example was obviously not the best ;-). I expanded my question with the section "Background" to explain the usecase.Umbilical
P
4

How about a slighly modified version of your original attempt:

@Test
public void testName() throws Exception {
    Map<String, Object> map = executeMethodUnderTest();

    assertThat(map, hasEntry(equalTo("the number"),
            allOf(instanceOf(Integer.class), integerValue(greaterThan(0)))));
}

private static<T> Matcher<Object> integerValue(final Matcher<T> subMatcher) {
    return new BaseMatcher<Object>() {
        @Override
        public boolean matches(Object item) {
            return subMatcher.matches(Integer.class.cast(item));
        }

        @Override
        public void describeTo(Description description) {
            description.appendDescriptionOf(subMatcher);
        }

        @Override
        public void describeMismatch(Object item, Description description) {
            subMatcher.describeMismatch(item, description);
        }
    };
}

Now the custom matcher is a little less verbose and you still achieve what you want.

If the value is too small:

java.lang.AssertionError: 
Expected: map containing ["the number"->(an instance of java.lang.Integer and a value greater than <0>)]
     but: map was [<the number=0>]

If the value is wrong type:

java.lang.AssertionError: 
Expected: map containing ["the number"->(an instance of java.lang.Integer and a value greater than <0>)]
     but: map was [<the number=something>]
Pomona answered 12/1, 2018 at 16:48 Comment(0)
D
3

The problem with map containing Object values is that you have to assume the specific class to compare.

What hamcrest is lacking is a way to transform a matcher from a given type to another, such as the one in this gist: https://gist.github.com/dmcg/8ddf275688fd450e977e

public class TransformingMatcher<U, T> extends TypeSafeMatcher<U> {
    private final Matcher<T> base;
    private final Function<? super U, ? extends T> function;

    public TransformingMatcher(Matcher<T> base, Function<? super U, ? extends T> function) {
        this.base = base;
        this.function = function;
    }

    @Override
    public void describeTo(Description description) {
        description.appendText("transformed version of ");
        base.describeTo(description);
    }

    @Override
    protected boolean matchesSafely(U item) {
        return base.matches(function.apply(item));
    }
}

With that, you could write your asserts this way:

@Test
public void testSomething() {
    Map<String, Object> map = new HashMap<>();
    map.put("greater", 5);

    assertThat(map, hasEntry(equalTo("greater"), allOf(instanceOf(Number.class),
            new TransformingMatcher<>(greaterThan((Comparable)0), new Function<Object, Comparable>(){
                @Override
                public Comparable apply(Object input) {
                    return Integer.valueOf(input.toString());
                }
            }))));
}

But the problem, again, is that you need to specify a given Comparable numeric class (Integer in this case).

In case of assertion error the message would be:

java.lang.AssertionError
Expected: map containing ["string"->(an instance of java.lang.Number and transformed version of a value greater than <0>)]
     but: map was [<string=NaN>]
Driest answered 9/1, 2018 at 19:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.