Wednesday, 17 April 2013

Assert That, Assert This

When writing test assertions with JUnit I tend to favour using assertThat over assertEquals. I've been asked a couple of times now what the benefits and why I use it. I thought it would be nice to write a short post on why I personally use it. The feature has been in available since 2008 so I may skip a couple of details but there are many excellent tutorials.

Correctness

I first used assertThat when I wanted to check for inequality of objects. JUnit provides assertEquals but how does one test that two objects are not equal? There is an assertNotEquals method which you can use but this was only recently added in release 4.11 (Nov 2012).

Previously I may have asserted on a Object.equals predicate.

String a = "a string";
String b = "b string";
 
assertFalse(a.equals(b));

However, if this test fails we'll have no clue what went wrong. You could add an optional failure message but when there is thousands of assertions this get tedious.

Alternatively, you may have been tempted to use assertNotSame which, although it has uses, is probably mostly used incorrectly. This assertion actually does reference inequality. In the example below both assertions pass. Although a and c have the same content, they are different instances.

String a = "a string";
String b = "b string";
 
// assert "a string" and "b string" are not the same 
assertNotSame(a, b); 
 
String c = "a string";
// assert "a string" and "a string" are not the same
assertNotSame(a, c); // whoops! 

Due to string interning and integer caching the assertion could work some of the time. However, to paraphrase Joshua Bloch - any program which depends on string interning for correctness is barely working. A quick look at the Chemistry Development Kit reveals there are at least 121 usages. I wonder how many of these are really meant to be doing reference equality?

We can avoid this confusion by using assertThat. It is now clear when we actually do reference equality.

// assert the content is not equal
assertThat("A", is(not("B"));
 
// assert the two strings are not the same instance
assertThat("A", is(not(sameInstance(new String("A")));

Okay, so our assertions are clearer, but with JUnit 4.11 and assertNotEquals I can avoid this issue. Well, arrays don't override Object.equals, which is why there is assertArrayEquals. Trying to check that two arrays are not equal with assertNotEquals will also fail.

int[] a = new int[]{0, 1, 2};
int[] b = new int[]{0, 1, 2};
 
assertNotEquals(a, b); // nope!

Using assertThat the code is no different from checking non-array objects.

int[] a = new int[]{0, 1, 2};
int[] b = new int[]{0, 1, 2};
 
assertThat(a, is(not(b)));

Expressiveness

I'm also a huge fan of how expressive the assertions can be (check out ScalaTest for really cool readability).  Lets say a value is returned from a method under test which may have multiple equally correct values. With assertThat we can get a much better default failure message if something when wrong.

int x = 6;
 
assertTrue(x < 5);
// no message            
 
assertThat(x, is(lessThan(5)));
// Expected: is a value less than <5>
//      but: <6> was greater than <5>

We can combine matchers logically with not, anyOf and allOf.

int x = 4;
 
assertTrue(x == 5 || x == 6 || x == 7);
// no message
 
assertThat(x, anyOf(is(5), is(6), is(7)));
// Expected: (is <5> or is <6> or is <7>)
//      but: was <4>

We can also check the content of collections without worrying about the implementation. The assertEquals here will only work if xs is a HashSet. If xs is latter changed to a different implementation (TreeSet, ArrayList, etc) the test will now fail, and of course with no failure message. Using assertThat we can check the actual content and get a better failure message if the content does change. I also think it is more readable.

Collection xs = ...
 
// whoops, i forgot the method returns a TreeSet
assertEquals(xs, new HashSet(Arrays.asList(42)));
 
// doesn't depend on implementation
assertThat(xs, hasSize(1));
assertThat(xs, hasItem(42));

Custom Matchers

We can even write custom matchers for common assertions. In the Chemistry Development Kit I may want to check that a returned atom is a carbon. You'd normally do this by checking the symbol is C.

assertThat(atom.getSymbol(), is("C"));

We can write a custom matcher to check an atom is a carbon and provide a default failure message when the assertion fails. The matcher could also handle null and case insensitivity but I've omitted these for clarity.

public class SymbolMatcher extends TypeSafeMatcher<IAtom> {
 
    private final String expected;  
    
    private SymbolMatcher(String expected) {
        this.expected = expected;
    }
 
    @Override protected boolean matchesSafely(IAtom atom) {
        String actual = atom.getSymbol();
        return expected.equals(actual);
    }
 
    @Override public void describeTo(Description description) {
        // symbolToName, map 'C' -> 'carbon'
        description.appendText(symbolToName.get(expected));
    }
 
    public static Matcher<IAtom> carbon() {
        return SymbolMatcher("C");
    }
 
    public static Matcher<IAtom> oxygen() {
        return SymbolMatcher("O");
    }
}

With our matcher written we can import the static method carbon() and use it with assertThat.

Atom atom = new Atom();
atom.setSymbol("O");
        
assertThat(atom, is(carbon()));
 
// Expected: is carbon
//      but: was oxygen

As before the logical matchers can build up a more complex assertion.

assertNotNull(atom);
assertEquals("C", atom.getSymbol());
assertTrue(atom.getFlag(CDKConstants.ISAROMATIC));
assertEquals(IAtomType.Hybridization.SP2, atom.getHybridization());
 
// combining matchers
assertThat(atom, is(allOf(notNullValue(),
                          carbon(),
                          aromatic(),
                          sp2())));

I still think using assertTrue and assertNotNull for isolated assertions is the best way but for many cases assertThat provides better flexibility and correctness.

No comments:

Post a Comment

Note: only a member of this blog may post a comment.