Sometimes I feel like I write more test code than real code. For unit tests on iOS our stack is OCHamcrest, OCMock, and GHUnit. For functional tests, there’s nothing better than FoneMonkey. For this post, I’m going to focus on OCHamcrest.
Hamcrest was born in the Java world as the matcher framework in jMock. It was quickly extracted into its own framework and has become somewhat of a monster in the testing world. It’s now included directly in JUnit (since v4.4), and has been ported to many languages (OCHamcrest in Objective-C, Hamcrest-AS3 in Actionscript, PyHamcrest in Python, etc.). Additionally, the matcher concept is generally useful, and Hamcrest is used is lots of different places (my favorite is collection filtering with Hamcrest in LambdaJ).
When writing unit tests, OCHamcrest offers lots of advantages over the vanilla SenTest assertions. First, there’s a ton of matchers that really make life easy, especially when testing collections like NSArray
. Second, OCHamcrest matchers are very readable in code, almost self-documenting. Lastly, OCHamcrest automatically provides excellent failure messages when actual is not equal to expected.
Matching Strings
Some string matching examples:
- is – match the complete string
- startsWith – match the beginning of a string
- endsWith – match the end of a string
- containsString – match part of the string
- equalTo – match the complete string
- equalToIgnoringCase – match the complete string but ignore case
- equalToIgnoringWhiteSpace – match the complete string but ignore extra whitespace (new line, tab, or double spaces)
NSString *s = @"FooBar";
assertThat(s, is(@"FooBar"));
assertThat(s, startsWith(@"Foo"));
assertThat(s, endsWith(@"Bar"));
assertThat(s, containsString(@"oo"));
assertThat(s, equalToIgnoringCase(@"foobar"));
assertThat(@" X \n Y \t\t Z \n", equalToIgnoringWhiteSpace(@"X Y Z"));
NOTE: Technically, is
isn’t really a matcher, it’s a matcher decorator that implicity converts to the equalTo
matcher. [thanks Jon!]
Combining Matchers
You can combine multiple matchers with:
- allOf – AND together all matchers
- anyOf – OR togehter all matches
NSString *s = @"FooBar";
assertThat(s, allOf(startsWith(@"Foo"), endsWith(@"Bar"), nil));
assertThat(s, anyOf(startsWith(@"Foo"), startsWith(@"Bar"), nil));
assertThat(s, anyOf(endsWith(@"Foo"), endsWith(@"Bar"), nil));
NOTE: The list of matchers must be nil
terminated.
You can invert a matcher, or multiple matchers, with:
- isNot – negate the matcher
NSString *s = @"FooBar";
assertThat(s, isNot(@"foo"));
assertThat(s, isNot(endsWith(@"Baz")));
assertThat(s, isNot(allOf(startsWith(@"Baz"), endsWith(@"Baz"), nil)));
assertThat(s, isNot(anyOf(startsWith(@"Baz"), startsWith(@"Baz"), nil)));
Matching nil
You can match nil
with:
- nilValue() – stands in for
nil
- notNilValue() – stands in for
!nil
NSObject *o = nil;
assertThat(o, nilValue());
NSString *s = @"FooBar";
assertThat(s, notNilValue());
Matching Classes
You can match an instance’s class with:
- instanceOf – match the class
NSString *s = @"FooBar";
assertThat(s, instanceOf([NSString class]));
Matching Numbers
One of the great pains of Objective-C is typing numbers from primitive types to objects and back again. OCHamcrest has a variety of matchers the help make life easy.
- assertThatInt – typed assert that expects an
int
(other types too: assertThatFloat
, assertThatDouble
, etc.)
- equalToInt – typed equals that takes an
int
(other types too: equalToFloat
, equalToDouble
, equalToBool
, etc.)
- closeTo – match a number with a target number plus or minus a delta (both params are
double
)
- lessThan – match a number less than the given number (param is
NSNumber
), also lessThanOrEqualTo
- greaterThan – match a number greater than the given number (param is
NSNumber
), also greaterThanOrEqualTo
assertThatInt(5, equalToInt(5));
assertThatFloat(3.14, equalToFloat(3.14f));
assertThatBool( false, equalToBool(NO) );
NSNumber *i = [NSNumber numberWithInt:5];
assertThat(i, equalToInt(5));
assertThat(i, is([NSNumber numberWithInt:5]));
NSNumber *f = [NSNumber numberWithFloat:3.14f];
assertThat(f, equalToFloat(3.14f));
assertThat(f, is([NSNumber numberWithFloat:3.14f]));
The easiest cleanest approach is to use assertThatInt
with equalToInt
, the next best option is to use the vanilla assertThat
with equalToInt
, the most verbose option is to use NSNumber
everywhere.
It’s easy to make rough number comparisons too:
NSNumber *f = [NSNumber numberWithFloat:3.14f];
assertThat(f, closeTo(3.0f, 0.25f));
assertThat(f, lessThan([NSNumber numberWithInt:4]));
assertThat(f, greaterThan([NSNumber numberWithInt:3]));
NOTE: It is a little weird, but closeTo
takes double
params, but everything else expects NSNumber
params.
Numeric comparisons also work great on dates too:
NSDate *now = [NSDate date];
//now minus 1000 seconds
NSDate *beforeNow = [NSDate dateWithTimeIntervalSinceNow:-1000];
assertThat(now, greaterThan(beforeNow));
Matching Arrays
Easily the best part of OCHamcrest is its ability to match lists of objects. Array matchers are every powerful, but don’t forget to add the terminating nil
to all lists.
- hasItem – match if given item appears in the list
- hasItems – match if all given items appear in the list (in any order)
- contains – match exactly the entire array
- containsInAnyOrder – match entire array, but in any order
- hasCountOf – match the size of the array
- empty – match an empty array
Here some basic array examples:
NSArray *a = [NSArray array];
assertThat(a, is(empty()));
assertThat(a, hasCountOf(0));
Here some hasItem
examples:
NSArray *a = [NSArray arrayWithObjects:@"a", @"b", @"c", nil];
assertThat(a, hasItem(@"a"));
assertThat(a, isNot(hasItem(@"X")));
assertThat(a, hasItem(equalToIgnoringCase(@"A")));
The last matcher may look a little weird, but remember matchers expect a matcher as their input param, and only default to equalTo
if none is given. Thus, the first matcher hasItem(@"a")
can be rewritten as hasItem(equalTo(@"a"))
.
We repeat the above example, but this time using numbers in our NSArray
. As you can see below, all the number matchers require us to explicitly use equalToInt
everywhere:
NSArray *a = [NSArray arrayWithObjects:
[NSNumber numberWithInt:2],
[NSNumber numberWithInt:3],
[NSNumber numberWithInt:5],
nil];
assertThat(a, hasItem(equalToInt(2)));
assertThat(a, isNot(hasItem(equalToInt(13))));
assertThat(a, contains(equalToInt(2), equalToInt(3), equalToInt(5), nil));
Here are some more complex array matchers:
NSArray *a = [NSArray arrayWithObjects:@"a", @"b", @"c", nil];
assertThat(a, hasItems(@"b", @"a", nil));
assertThat(a, contains(@"a", @"b", @"c", nil));
assertThat(a, containsInAnyOrder(@"c", @"b", @"a", nil));
assertThat([a componentsJoinedByString:@","], is(@"a,b,c"));
And as I show in the last matcher, you can always dump to a string, and just match strings.
Matching Dictionaries
The dictionary matchers build on the array matchers:
- hasKey – match a key
- hasValue – match a value
- hasEntry – match a key-value pair
- hasEntries – match a list of k-v pairs
NSDictionary *d = [NSDictionary dictionaryWithObjectsAndKeys:
@"valA", @"keyA", @"valB", @"keyB", @"valC", @"keyC", nil];
assertThat(d, hasCountOf(3));
assertThat(d, isNot(empty()));
assertThat(d, hasKey(@"keyA"));
assertThat(d, isNot(hasKey(@"keyX")));
assertThat(d, hasValue(@"valA"));
assertThat(d, hasEntry(@"keyA", @"valA"));
assertThat(d, hasEntries(@"keyA", @"valA", @"keyC", @"valC", nil));
Matcher Error Messages
When a matcher fails, you get a standardize error message of: Expected "foo", but was "bar"
. This default message is easy to modify by using the describedAs()
matcher in place of the typical is()
matcher.
NSString *s = @"bar";
assertThat(s, is(@"foo"));
//Expected "foo", but was "bar"
assertThat(s, describedAs(@"doh! this should be 'foo'", equalTo(@"foo"), nil));
//Expected doh! this should be 'foo', but was "bar"
assertThat(s, describedAs(@"doh! this should be foo, %0, %1", equalTo(@"foo"), @"baz", [NSNumber numberWithInt:42], nil));
//Expected doh! this should be foo, "baz", <42>, but was "bar"
NOTE: The argument list for describedAs()
MUST end with nil
or your tests will crash instantly with no useful error message.
Building a Custom Matcher
Writing your own custom matchers is relatively easy. Here’s an example of a matcher that matches the value of some property on an object:
#import <OCHamcrestIOS/OCHamcrestIOS.h>
#import <objc/objc-api.h>
@interface HasProperty : HCBaseMatcher {
NSString *property;
id<HCMatcher> valueMatcher;
}
+ (id) hasProperty:(NSString *)aProperty value:(id<HCMatcher>)aValueMatcher;
- (id) initWithProperty:(NSString *)aProperty value:(id<HCMatcher>)aValueMatcher;
@end
OBJC_EXPORT id<HCMatcher> hasProperty(NSString *property, id valueMatcher);
We extends HCBaseMatcher
with our custom HasProperty
class. We store the name of the property and a value matcher.
And the implementation:
#import "HasProperty.h"
#import <OCHamcrestIOS/HCDescription.h>
#import <OCHamcrestIOS/HCWrapInMatcher.h>
@implementation HasProperty
+ (id) hasProperty:(NSString *)aProperty value:(id<HCMatcher>)aValueMatcher {
return [[[self alloc] initWithProperty:aProperty value:aValueMatcher] autorelease];
}
- (id) initWithProperty:(NSString *)aProperty value:(id<HCMatcher>)aValueMatcher {
self = [super init];
if (self != nil) {
property = [aProperty copy];
valueMatcher = [aValueMatcher retain];
}
return self;
}
- (void) dealloc {
[property release];
[valueMatcher release];
[super dealloc];
}
- (BOOL)matches:(id)item {
SEL propertyGetter = NSSelectorFromString(property);
if ([item respondsToSelector:propertyGetter]) {
if ([valueMatcher matches:[item performSelector:propertyGetter]])
return YES;
}
return NO;
}
- (void) describeTo:(id<HCDescription>)description {
[[[description appendText:[NSString stringWithFormat:@"an object with a property named '%@' with a value of {", property]]
appendDescriptionOf:valueMatcher]
appendText:@"}"];
}
@end
OBJC_EXPORT id<HCMatcher> hasProperty(NSString *property, id valueMatcher) {
return [HasProperty hasProperty:property value:HCWrapInMatcher(valueMatcher)];
}
When we write a custom matcher, we must implement two methods, matches:
to do the matching and describeTo:
to provide feedback in case of match failure. In the above code, we first construct a selector from the given property name, then call the selector to get the actual property value, and finally check if actual matches the expected value (given by the valueMatcher
).
Usage looks like this:
Person *p = [Person personWithFirstName:@"Joe" andLastname:@"Doe"];
assertThat(p, hasProperty(@"firstName", @"Joe"));
Or more importantly, we can now use our custom hasProperty
matcher to match an arrays of objects:
NSArray *a = [NSArray arrayWithObjects:
[Person personWithFirstName:@"Joe" andLastname:@"Doe"],
[Person personWithFirstName:@"Joe" andLastname:@"Smith"],
[Person personWithFirstName:@"Jane" andLastname:@"Allen"],
nil];
assertThat(a, contains(
hasProperty(@"firstName", @"Joe"),
hasProperty(@"firstName", @"Joe"),
hasProperty(@"firstName", @"Jane"),
nil));
That’s it. Go forth and match.
UPDATE: I put the above hasProperty
matcher into a pull request, and Jon Reid accepted it into OCHamcrest v1.6. He even wrote a nice post about it. Get the lastest OCHamcrest from github.
Links