Custom Expecta Matchers for Test Legibility

Article summary

On my current project we’re using the Expecta matcher framework in our tests, allowing us to write clean, legible test expectations:

expect(foo).to.equal(bar);
expect(dwarves).to.haveCountOf(13);
expect(result).willNot.beNil();

What We Have

We frequently find ourselves checking that the contents of one array match those of another, but often don’t care about the order:

NSArray *expected = @[@"foo", @"bar", @"baz"];
NSArray *actual = @[@"bar", @"baz", @"foo"];

// this will mismatch:
expect(expected).to.equal(actual);

Here’s one approach to get the desired behavior:

NSCountedSet *expectedCountedSet = [NSCountedSet setWithArray:expected];
NSCountedSet *actualCountedSet = [NSCountedSet setWithArray:actual];
expect(expectedCountedSet).to.equal(actualCountedSet);

([NSCountedSet][apple_nscountedset] is a *[multiset][wiki_multiset]*, also known as a *bag*.)
 

This does what we need, but it’s indirect and our intent isn’t clear at a glance.

##What We Want
What we really want is something like this:

expect(expected).to.equalInAnyOrder(actual);

It turns out that Expecta is extensible to allow us to do exactly that! Here’s what I came up with:

// based on EXPMatchers+beKindOf example from https://github.com/specta/expecta

#import "EXPMatchers+equalInAnyOrder.h"

EXPMatcherImplementationBegin(equalInAnyOrder, (id expected)) {
    BOOL actualIsNil = (actual == nil);
    BOOL expectedIsNil = (expected == nil);

    //TODO: handle NSSet, NSCountedSet, ...
    BOOL actualIsNSArray = [actual isKindOfClass:[NSArray class]];
    BOOL expectedIsNSArray = [expected isKindOfClass:[NSArray class]];

    prerequisite(^BOOL {
        // Return `NO` if matcher should fail whether or not the result is inverted
        // using `.Not`.

        if(actualIsNil || expectedIsNil){
            return NO;
        }

        if(!(actualIsNSArray && expectedIsNSArray)){
            return NO;
        }

        return YES;
    });

    match(^BOOL {
        NSCountedSet *expectedCountedSet = [NSCountedSet setWithArray:expected];
        NSCountedSet *actualCountedSet = [NSCountedSet setWithArray:actual];

        // Return `YES` if the matcher should pass, `NO` if it should not.
        return [expectedCountedSet isEqual:actualCountedSet];
    });

    failureMessageForTo(^NSString * {
        if (actualIsNil) {
            return @"the actual value is nil/null";
        }
        if (expectedIsNil) {
            return @"the expected value is nil/null";
        }
        if (!actualIsNSArray) {
            return @"the actual value is not an NSArray";
        }
        if (!expectedIsNSArray) {
            return @"the expected value is not an NSArray";
        }

        // Return the message to be displayed when the match function returns `YES`.
        return [NSString stringWithFormat:@"expected: %@, got: %@", EXPDescribeObject(expected), EXPDescribeObject(actual)];

    });

    failureMessageForNotTo(^NSString * {
        if (actualIsNil) {
            return @"the actual value is nil/null";
        }
        if (expectedIsNil) {
            return @"the expected value is nil/null";
        }
        if (!actualIsNSArray) {
            return @"the actual value is not an NSArray";
        }
        if (!expectedIsNSArray) {
            return @"the expected value is not an NSArray";
        }

        // Return the message to be displayed when the match function returns `NO`.
        return [NSString stringWithFormat:@"expected: %@, got: %@", EXPDescribeObject(expected), EXPDescribeObject(actual)];

    });
}
EXPMatcherImplementationEnd

## Conclusion
[Tests are documentation][tests_are_documentation], so we want them to be easy to read. Tests drive our development of production code, so we want them to be easy to write. Every now and then it’s worth a little effort to keep them that way.

[wiki_array]: http://en.wikipedia.org/wiki/Array_data_structure
[wiki_set]: http://en.wikipedia.org/wiki/Set_(abstract_data_type)
[wiki_multiset]: http://en.wikipedia.org/wiki/Set_(abstract_data_type)#Multiset
[apple_nscountedset]: https://developer.apple.com/library/mac/documentation/Cocoa/Reference/Foundation/Classes/NSCountedSet_Class/index.html
: https://gist.github.com/jrr/e75902242cc1422662a3
[tests_are_documentation]: http://ernststhoughts.blogspot.com/2010/03/automated-unit-tests-as-documentation.html

Conversation
  • Ken Fox Ken Fox says:

    Nice. Can you write a custom matcher that takes another matcher? Something like the everyItem from Hamcrest, e.g. expect(x).everyItem.to.beIn(y), reads pretty well and is very general.

  • Comments are closed.