This week I found myself implementing the Google Places’ API in an iOS application, what better occasion to write a post about my favourite iOS stub framework, OHHTTPStubs?
Stubs replace a method with code that returns a specified result, mocks are stubs with an assertion that the method gets called.
That being said, when testing network code, stubs are really useful tools to avoid hitting a network resource each time our tests run. This prevents unwanted traffic (and that’s nice when you are dealing with API limits) and speeds up our test suite quite a bit.
Google Places API
I won’t go further in detail with the Google Places’ API, what we need to know is the API’s URL and the format of returned JSON object.
Great, we have a lot of info, but I really need just name and id in this project.
Writing the test
Ok, we figured out how to ask Google for directions and we have our JSON response, next up is our test.
In the last blog post I wrote about my iOS test configuration with Specta. In this project though I’ll be using Kiwi, nothing mayor really changes, just the syntax.
describe(@"fetchSuggestionsForLocation:onSuccess:onFailure",^{context(@"with valid data",^{it(@"returns an array",^{__blockNSArray*result;[[AMPlacesHelpersharedHelper]fetchSuggestionsForLocation:(CLLocationCoordinate2D){37.7749295,-122.4194155}onSuccess:^(NSArray*data){result=data;}onFailure:^(NSError*error){}];[[expectFutureValue(result)shouldEventually]beKindOfClass:[NSArrayclass]];[[expectFutureValue(theValue(result.count))shouldEventually]beGreaterThan:theValue(0)];});it(@"returns a JSON array of places with a name and id",^{__blockNSArray*result;[[AMPlacesHelpersharedHelper]fetchSuggestionsForLocation:(CLLocationCoordinate2D){37.7749295,-122.4194155}onSuccess:^(NSArray*data){result=data;}onFailure:^(NSError*error){}];[[expectFutureValue(result[0][@"name"])shouldEventually]beNonNil];[[expectFutureValue(result[0][@"id"])shouldEventually]beNonNil];});});context(@"with invalid data",^{it(@"returns an error in the failure block",^{__blockNSError*resultError;[[AMPlacesHelpersharedHelper]fetchSuggestionsForLocation:(CLLocationCoordinate2D){37.7749295,-122.4194155}onSuccess:^(NSArray*data){}onFailure:^(NSError*error){resultError=error;}];[[expectFutureValue(resultError)shouldEventually]beNonNil];});});});
Pretty basic stuff, the first test checks that the returned object is an NSArray, and that its content’s lenght is greater than 0. The second test checks for the content itself, making sure that id and name are present. The last spec just checks against invalid data, making sure that an error is raised. expectFutureValue waits 1 seconds (by default) before raising the expectation. This is key when dealing with asyncronous calls.
You may point out that it’s always a good practice to limit expectations to one per spec, but since these are pretty basic, I figured I could get away with squeezing two of them in the same spec.
Running the test with my trusty xctool script, I see 3 red specs, yay!
Stubbing the network
Now we could implement our code and run the test again, hoping for green, but once we manage to make the network call, we’ll be hitting the Google Places’ API once for every test run. That’s bad, so here enters OHHTTPStubs.
OHHTTPStubs is pretty cool, it lets you register a stub that will listen for any network request and respond with a preset response body and response code. This means that we can easily emulate the network API’s behaviour and use it to our likings.
The basic structure of a stub is this one:
1234567
[OHHTTPStubsstubRequestsPassingTest:^BOOL(NSURLRequest*request){// Here you can decide whether to stub the request or not, based for example on the request URLreturnYES;}withStubResponse:^OHHTTPStubsResponse*(NSURLRequest*request){// Here you return the fake data from your stubbed network callreturn[OHHTTPStubsResponseresponseWithJSONObject:@[@"hello"]statusCode:200headers:@{@"Content-Type":@"application/json"}];}];
Pretty nifty. Once we described our test we can then tear down the stubs in an afterAll block:
123
afterAll(^{[OHHTTPStubsremoveAllStubs];});
Our fixture
Since I want to stub the Google Places’ API I need to provide a sort of fixture throught OHHTTPStubs. Let’s curl the result, and save it to a JSON file that will be served by the stub.
We should be set, let’s make sure that our test fail in a meaningful way:
12345
> xctool -workspace Project.xcworkspace -scheme 'Project' -sdk iphonesimulator test
...
'AMPlacesHelper, fetchSuggestionsForLocation:onSuccess:onFailure, with valid data, returns an array' [FAILED], expected subject to be kind of NSArray, got (null)
'AMPlacesHelper, fetchSuggestionsForLocation:onSuccess:onFailure, with valid data, returns a JSON array of places with a name and id' [FAILED], expected subject not to be nil
'AMPlacesHelper, fetchSuggestionsForLocation:onSuccess:onFailure, with invalid data, returns an error in the failure block' [FAILED], expected subject not to be nil
Nice! We can now implement the code that will let the test pass, but won’t hit the network.
From red to green
Let’s implement the code that will pass our test. As always, I used AFNetworking to do a quick GET request to the aforementioned API.
123456789101112131415161718192021222324
-(void)fetchSuggestionsForLocation:(CLLocationCoordinate2D)coordinatesonSuccess:(void(^)(NSArray*data))successonFailure:(void(^)(NSError*error))failure{[[UIApplicationsharedApplication]setNetworkActivityIndicatorVisible:YES];AFHTTPRequestOperationManager*manager=[AFHTTPRequestOperationManagermanager];NSDictionary*params=@{@"key":kGooglePlacesKey,@"location":[NSStringstringWithFormat:@"%f,%f",coordinates.latitude,coordinates.longitude],@"sensor":@"true",@"radius":@"500"};[managerGET:kGooglePlacesURLparameters:paramssuccess:^(AFHTTPRequestOperation*operation,idresponseObject){[[UIApplicationsharedApplication]setNetworkActivityIndicatorVisible:NO];if(success){success(responseObject[@"results"]);}}failure:^(AFHTTPRequestOperation*operation,NSError*error){[[UIApplicationsharedApplication]setNetworkActivityIndicatorVisible:NO];if(failure){if(error){failure(error);}else{failure([[NSErroralloc]initWithDomain:@"googleapi.com"code:500userInfo:@{@"message":@"unable to retrieve places"}]);}}}];}
We run our suite again:
1
** TEST PASSED: 3 passed, 0 failed, 0 errored, 3 total **
We’re green. We can turn off Wifi and unplug the ethernet cable, the test will pass anyway.
If we want to test the code with the live feed, we can easily switch the return value for stubRequestsPassingTest in our stubs to NO.
Debugging stubs
When using OHHTTPStubs there’s one caveat…
While writing the stub, I did manage to sneak a typo in my stub code, so I was trying to load google.places.json instead of google_places.json. Usually you’d find this error pretty easilly, but this time I only noticed that every stubbed spec that was previously green, now was failing with this generic error:
1
Test did not run: the test bundle stopped running or crashed in AMPlacesHelper_FetchSuggestionsForLocationonSuccessonFailure_WithValidData_ReturnsAnArray
This can be annoying to debug if you have complex stub and I didn’t find any quick solution, beside being careful when writing the stub implementation. I guess that a good rule of thumb here is:
keep your stubs as simple as possible
This should really apply to every good stub and mock.