Testing asynchronous network code with Kiwi

Nov 23, 2012 00:00 · 839 words · 4 minute read

I’m working on an iOS app that pulls data down from the TheyWorkForYou API, and because it’s the Right Thing To Do, I’m attempting to build it test-first. That’s not as straight-forward as it perhaps should be with Objective-C, and nor is it made any easier by the asynchronous nature of a lot of the iOS SDK. So this post is by way of an outboard brain explanation for a) me, when I’ve forgotten what I did in a couple of weeks and b) anyone else who stumbles across this through the magic of Google.

The app is communicating with the API through a standalone class which can be used anywhere within the app. I’m using the AFNetworking library, so I’ve created a singleton as a subclass of AFHTTPclient.

This client calls the API using a block-based syntax and has a success block and a failure block. These blocks are executed asynchronously on a background thread, so in order for the comms class to pass data back to the rest of the application, it does so through a delegate protocol. The API client declares a protocol with a method called apiRepliedWithResponse that takes a single parameter (either the data that’s been received from the API, or nil in the case of errors).

This method is implemented in the API client’s delegate, and gets called from within the success and failure blocks. Any object that wishes to use the API client needs to implement the delegate protocol, and handle the responses from the API client. Testing this ****Testing this throws up some interesting problems - I’m using the Kiwi testing framework which has a nice RSpec-like syntax as well as all the testing bells and whistles that you might need.

The first issue is that the API client is making asynchronous calls - therefore you need to add a delay inbetween calling the method under test, and making any assertions about the results. If the assertion is made too quickly, the asynchronous method won’t have had time to complete, and the assertion will fail.

This problem gets dealt with by Kiwi’s shouldEventually assertion, of which more shortly. The second issue is what to test? There are two things that need to be checked - firstly that the delegate method gets called at all; and secondly that the correct parameter is passed back. The process here is to create a mock object that pretends to conform to the API client’s protocol, then set this mock object as the API client’s delegate.

We can then set an assertion that the delegate method gets called; and check that the data which is returned is in fact what we were expecting. So here’s the process in detail:

  • create a mock that “conforms” to the API client’s delegate protocol
id delegateMock = [KWMock mockForProtocol:@protocol(TWFYClientDelegate)];
  • create an object containing the data that will be sent back

e.g. if it’s an NSString of value “foo” that’s expected back, you’ll need to create an NSString of value “foo”. In my case, I’m asserting that I’m getting back an NSData representation of a JSON file:

NSString *filePath = [[NSBundle bundleForClass:[self class]] pathForResource:@"getPerson" ofType:@"json"];
NSData *response = [NSData dataWithContentsOfFile:filePath];
  • set the mock as the API client’s delegate:
[theClient setDelegate:delegateMock];
  • set the assertion that the mock should *eventually* be called with the method and response object (using Kiwi, this is shouldEventually):
[[[delegateMock shouldEventually] receive] apiRepliedWithResponse:response];
  • call the method under test:
[client getDataForPerson:theMP];

To remove the dependency on the API itself, I’m using the OHHTTPStubs library - this intercepts any calls to the network inside the API client, and returns a canned response:

[OHHTTPStubs addRequestHandler:^OHHTTPStubsResponse*(NSURLRequest *request, BOOL onlyCheck) {
    return [OHHTTPStubsResponse responseWithFile:@"getPerson.json"
                                         contentType:@"text/json"
                                        responseTime:OHHTTPStubsDownloadSpeedEDGE];
        }];

Here’s the full test:

it(@"should receive some data if passed a valid MP object", ^{

    // Create the dummy MP object to send through to the TWFYClient
    MP *theMP = [MP createEntity];
    [theMP setPerson_id:@10900];

    // Create the expected response object as an NSData representation of the getPerson.json file
    NSString *filePath = [[NSBundle bundleForClass:[self class]] pathForResource:@"getPerson" ofType:@"json"];
    NSData *response = [NSData dataWithContentsOfFile:filePath];

    // Create a mock object to act as the TWFY delegate, and make it 'conform' to
    // the TWFYClientDelegate protocol
    id delegateMock = [KWMock mockForProtocol:@protocol(TWFYClientDelegate)];

    // Set the client's delegate property
    [client setDelegate:delegateMock];

    // Set the assertion that eventually there should an 'apiRepliedWithResponse' message,
    // and it will have the response object as a parameter
    [[[delegateMock shouldEventually] receive] apiRepliedWithResponse:response];

    // Call the method under test
    [client getDataForPerson:theMP];

});

And the method that it’s testing:

-(void)getDataForPerson:(id)person {

    [self stubNetworkCall];

    // Convert to person
    MP *theMP = nil;
    if ([person isKindOfClass:[MP class]]) {
        theMP = (MP *)person;
    } else {
        [self.delegate apiRepliedWithResponse:nil];
        return;
    }

    // Build API call
    // getPerson?key=ABCD&id=12345
    NSString *personID = [NSString stringWithFormat:@"%@", [theMP person_id]];
    NSString *call = [NSString stringWithFormat:@"%@getPerson?key=%@&id=%@", kAPIEndpointURL, kAPIKey, personID];

    // Call TWFY API
    [self getPath:call parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {

        [self.delegate apiRepliedWithResponse:responseObject];

    } failure:^(AFHTTPRequestOperation *operation, NSError *error) {

        [self.delegate apiRepliedWithResponse:nil];

    }];

}

No doubt there are far more elegant ways of achieving the same results, but this is now working nicely.