How to Make Your Own OCHamcrest Matcher for Powerful Asserts

Shares

OCHamcrest matchers are predicates on steroids:

  • You can combine them to create expressive assertions.
  • They describe mismatches in detail, reducing the need to debug.

But OCHamcrest isn’t just a library of powerful matchers. It’s a framework for creating your own.

Dr. Seuss Matching Game

By creating a custom matcher, you create a small Domain-Specific Language. This will make your unit tests easier to write, easier to read, and self-diagnosing!

In this post, we’ll walk through the 9 steps of creating a custom OCHamcrest matcher.

[This post is part of the series TDD Sample App: The Complete Collection …So Far]

1. Design the arguments

For our example, we’ll make a matcher that tests whether a URL contains a particular query. We’ve already looked at the challenges of examining URL queries with longhand assertions. (See How to Avoid Problems with Assertion Test Helpers.) Life will be easier with an OCHamcrest matcher.

Let’s say we have a URL like

http://dummy.com/dummy?key1=value1

The query, signaled by the question mark, is key1=value1. We need to specify a name, and a value. A naive matcher factory might look like this:

id hasQuery(NSString *name, NSString *value);

But this goes against the spirit of OCHamcrest. The matcher shouldn’t lock us into testing the value for equality. Instead, we want the ability to compose hasQuery out of other matchers. So let’s keep the name as a string (for now), but supply another matcher to test the value:

id hasQuery(NSString *name, id valueMatcher);

To test the value for equality, we can supply the equalTo matcher:

assertThat(url, hasQuery(@"key1", equalTo(@"value1")));

By specifying value-checking as a matcher, we open the door to checks other than equality. For example, we could use text matchers such as startsWith or endsWith instead! Cool, huh? This promotes writing assertions that specify just enough but not too much. The result is unit tests that are less fragile.

2. Use id for matcher types

Return type

hasQuery is a factory method that returns a matcher. Why make the return type id, instead of the more precise id <HCMatcher>? Because it makes it much simpler to use matchers in OCMockito.

Let’s say you want to test that -[UIApplication openURL:] receives a URL containing a query. The argument type is NSURL *. To pass in a matcher, we need to defeat type checking. We can do this with an (id) typecast.

But it’s easier just to use plain id as the factory method return type. Mocked methods will receive such matchers without complaint.

Argument type

hasQuery is an example of a matcher that takes another matcher as an argument. Why define valueMatcher as type id, instead of id <HCMatcher>? Because we want to allow non-matcher arguments.

Consider our use case

assertThat(url, hasQuery(@"key1", equalTo(@"value1")));

Because equalTo is the most common matcher, it’s handy to be able to omit it:

assertThat(url, hasQuery(@"key1", @"value1"));

What we’ll do is test if the valueMatcher argument is a matcher or not. If it’s already a matcher, use it as-is. If it’s not, we’ll wrap it in an implied equalTo matcher.

This makes nested matchers easier to write, and easier to read.

(Note: I initially kept the name argument as an NSString because I thought I might need it as a dictionary key. That turned out not to be the case.)

3. Create the skeleton

Let’s turn the use case into our first test:

- (void)testHasQuery_WithURLContainingMatchingKeyAndValue_ShouldMatch
{
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?key1=value1"];

    assertThat(url, hasQuery(@"key1", equalTo(@"value1")));
}

The simplest way to build on the OCHamcrest infrastructure is by subclassing HCBaseMatcher.

@interface QCOURLQueryMatcher : HCBaseMatcher
@end

FOUNDATION_EXPORT id hasQuery(NSString *name, id valueMatcher);

The factory method hasQuery will return an instance of QCOURLQueryMatcher. Note that I use FOUNDATION_EXPORT instead of extern. This is a good practice for any exposed C functions: it prevents C++ name mangling when imported by .mm files.

Any subclass of HCBaseMatcher must define two methods. Here they are in do-nothing form:

@implementation QCOURLQueryMatcher

- (BOOL)matches:(id)item
{
    return NO;
}

- (void)describeTo:(id <HCDescription>)description
{
}

@end

The last piece we need is the factory method. We’ll start by not using the arguments yet:

id hasQuery(NSString *name, id valueMatcher)
{
    return [[QCOURLQueryMatcher alloc] init];
}

This is enough infrastructure for the test to build, run and fail. The simplest code that passes the test is to change -matches: to return YES. In other words, it will match any object.

4. Narrow the predicate

Of course, matching any object isn’t what we want for hasQuery. (But it is the basis of the surprisingly useful anything matcher.)

There’s an old saying: To make a statue of an elephant, start with a block of stone that’s large enough. Then carve away anything that’s not an elephant.

TDD is like that. You use tests to narrow the solution space.

Let’s start by testing the name. If the name doesn’t match, the predicate should reject the object:

- (void)testHasQuery_WithURLNotContainingMatchingKey_ShouldNotMatch
{
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?WRONGKEY=value1"];

    assertThat(url, isNot(hasQuery(@"key1", equalTo(@"value1"))));
}

I use NSURLComponents to parse the URL. Can you write the simplest code that passes this test? I did so by getting the queryItems of the NSURLComponents. Remember, don’t write anything that the test doesn’t demand.

Here’s the second test, this time checking that the value matches:

- (void)testHasQuery_WithURLContainingMatchingKeyButWrongValue_ShouldNotMatch
{
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?key1=WRONGVALUE"];

    assertThat(url, isNot(hasQuery(@"key1", equalTo(@"value1"))));
}

I leave it to you to see if you can write the simplest code that passes.

The queryItems property of NSURLComponents is an array. So far, I’ve only been examining the first element. Let’s write a test to drive us to iterate through the array of query items:

- (void)testHasQuery_WithURLContainingMatchingKeyAndValueInSecondPosition_ShouldMatch
{
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?key1=value1&key2=value2"];

    assertThat(url, hasQuery(@"key2", equalTo(@"value2")));
}

Again, this is an exercise for you. But here are the important pieces so far:

  • The factory method should pass its arguments to the matcher’s initializer.
  • The matcher’s initializer should hold on to those arguments, probably as properties.
  • The -matches: method should use those properties to determine if the examined item is a match.
  • Call any embedded matcher by sending it the -matches: message. Pass along the subset of data it needs.

5. Support implicit equalTo for embedded matchers

So far, the valueMatcher used in our tests has been an explicit matcher. Let’s support implicit equalTo if the argument isn’t a matcher. Here’s a test to drive this behavior:

- (void)testHasQuery_ShouldProvideConvenientShortcutForMatchingValueWithEqualTo
{
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?key1=value1"];

    assertThat(url, hasQuery(@"key1", @"value1"));
}

This is easily implemented by using the OCHamcrest helper function HCWrapInMatcher. Do this in the factory method:

id hasQuery(NSString *name, id valueMatcher)
{
    return [[QCOURLQueryMatcher alloc] initWithName:name
                                       valueMatcher:HCWrapInMatcher(valueMatcher)];
}

6. Provide the matcher with a description

An OCHamcrest matcher also needs to be able to describe its expectations. This description should be a noun-phrase. Here’s a test to drive what we want for the hasQuery matcher:

- (void)testMatcherShouldHaveReadableDescription
{
    id <HCMatcher> matcher = hasQuery(@"key1", @"value1");
    HCStringDescription *description = [HCStringDescription stringDescription];

    [description appendDescriptionOf:matcher];

    assertThat(description.description, is(@"a URL with "key1" = "value1""));
}

We implement this in -describeTo: by chaining HCDescription methods:

- (void)describeTo:(id <HCDescription>)description
{
    [[[[description
            appendText:@"a URL with "]
            appendDescriptionOf:self.name]
            appendText:@" = "]
            appendDescriptionOf:self.valueMatcher];
}

7. Handle matching of arbitrary objects, including nil

An OCHamcrest matcher should be able to examine any item. It should handle arbitrary objects. It should handle nil.

If a method is called on the examined item, check -respondsToSelector: first. If it doesn’t respond, have -matches: return NO.

If the examined item is passed as an argument to another method, make sure the item has the right type. For hasQuery, we need to check that the item is an NSURL.

Here are two tests to drive the behavior we want. One asserts against a plain NSObject, the other asserts against nil:

- (void)testShouldNotMatchNonURL
{
    assertThat([[NSObject alloc] init], isNot(hasQuery(@"DUMMY", @"DUMMY")));
}

- (void)testShouldNotMatchNil
{
    assertThat(nil, isNot(hasQuery(@"DUMMY", @"DUMMY")));
}

Both tests are satisfied by adding this guard clause to the beginning of -matches:

    if (![item isKindOfClass:[NSURL class]])
        return NO;

8. Guard against nil arguments when creating the matcher

For matchers that take object arguments: what if an argument is nil? That depends on the matcher.

If a nil argument just doesn’t make sense, we should stop right away. Objective-C avoids raising exceptions for uncontrolled errors. But for programmer errors, exceptions are fine.

Let’s add tests to drive the behavior we want:

- (void)testCreatingMatcherWithNilName_ShouldThrowException
{
    assertThat(^{ hasQuery(nil, @"value1"); }, throwsException(anything()));
}

- (void)testCreatingMatcherWithNilValue_ShouldThrowException
{
    assertThat(^{ hasQuery(@"key1", nil); }, throwsException(anything()));
}

Both tests are satisfied by using the OCHamcrest helper function HCRequireNonNilObject. We’ll add these lines to the beginning of the factory method:

    HCRequireNonNilObject(name);
    HCRequireNonNilObject(valueMatcher);

9. For multiple “no match” scenarios, add diagnostic messages

Here’s what the -matches: method looks like so far:

- (BOOL)matches:(id)item
{
    if (![item isKindOfClass:[NSURL class]])
        return NO;

    NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:item
                                                resolvingAgainstBaseURL:NO];
    NSArray<NSURLQueryItem *> *queryItems = urlComponents.queryItems;
    for (NSURLQueryItem *queryItem in queryItems)
        if ([queryItem.name isEqualToString:self.name])
            return [self.valueMatcher matches:queryItem.value];
    return NO;
}

This is a fine matcher. Simple matchers can just implement -matches: and -describeTo:.

But note the three return statements. There are multiple paths, which means there are multiple ways to have “no match” scenarios. Let’s examine each in turn.

The first return NO happens if the examined item is anything other than an NSURL. Let’s not worry about that one for now. (The problem is easier to understand with the tests below.)

The second return is in the forif clause. It’ll return NO if the value doesn’t match. What do we get for an assertion message?

Expected a URL with "key1" = "value1", but was <http://dummy.com/dummy?key1=WRONGVALUE>

That’s not bad, but we have to examine the URL to see what went wrong. Similarly, we get to the last return NO if the name is not found:

Expected a URL with "key1" = "value1", but was <http://dummy.com/dummy?WRONGKEY=value1>

It’s not hard to find the problems with these examples, because the URLs are short, and the bad information is in all caps. But for a long URL with many queries, we’d we’d have to examine the URL ourselves to try to see what went wrong. Was the value incorrect? Or was the name not found?

We can add diagnostic messages for such results. To do so:

  1. Change the base class from HCBaseMatcher to HCDiagnosingMatcher.
  2. Change the signature of the matching method to - (BOOL)matches:(id)item describingMismatchTo:(id <HCDescription> )mismatchDescription.
  3. Call the mismatchDescription.

Let’s start with the last return statement. Here’s a test to drive the report that the name wasn’t found:

- (void)testMismatchDescriptionOfURLNotContainingMatchingKey
{
    id <HCMatcher> matcher = hasQuery(@"key1", @"value1");
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?WRONGKEY=value1"];
    HCStringDescription *description = [HCStringDescription stringDescription];

    [matcher describeMismatchOf:url to:description];

    assertThat(description.description, is(@"no "key1" name in WRONGKEY=value1"));
}

To implement this, we add this right before the last line:

    [[[[mismatchDescription
            appendText:@"no "]
            appendDescriptionOf:self.name]
            appendText:@" name in "]
            appendText:urlComponents.query];

Here’s an example of the resulting assertion message for this case:

Expected a URL with "key1" = "value1", but no "key1" name in WRONGKEY=value1

Likewise, here’s a test to drive the report that the value didn’t match:

- (void)testMismatchDescriptionOfURLContainingMatchingKeyWithWrongValue
{
    id <HCMatcher> matcher = hasQuery(@"key1", @"value1");
    NSURL *url = [NSURL URLWithString:@"http://dummy.com/dummy?key1=WRONGVALUE"];
    HCStringDescription *description = [HCStringDescription stringDescription];

    [matcher describeMismatchOf:url to:description];

    assertThat(description.description, is(@""key1" had value "WRONGVALUE" in key1=WRONGVALUE"));
}

I leave the implementation as an exercise.

Conclusion

Making a custom OCHamcrest matcher may seem like a lot of work, but think of what it yields:

  • This matcher is ready for use.
  • What it does is fully tested.
  • It eliminates a lot of test code.
  • It makes tests easier to write, and easier to read.
  • It makes test failures self-diagnosing.
  • It makes it easy to confirm matching method arguments with OCMockito.

I’m providing a cheatsheet you can use when you create your custom OCHamcrest matchers. It also includes links to my sample code commits. The commits serve as a reference, and also as answers to the TDD exercises. If you have any questions, please leave a comment.

[This post is part of the series TDD Sample App: The Complete Collection …So Far]

About the Author Jon Reid

Jon is a coach and consultant on iOS Clean Code (Test Driven Development, unit testing, refactoring, design). He's been practicing TDD since 2001. You can learn more about his background, or see what services he can bring to your organization.

follow me on: