Custom Expecta Matchers for Test Legibility

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 is a 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, 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.

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.