Remote Controlling your iOS Device with KIF

I lead iOS dev here at Silicon Valley Pre-IPO company #85934. Trying to level up our functional testing powers we adopted KIF (along side several other tools). Our success with KIF is for another time but as part of investigating it we tried to envision the most complex functional tests we could want to do with it:

Multi-Device Functional Testing on Real iOS Devices

It’s not the hardest thing but there’s no built-in out-of-the-box solutions either. Anything that used instruments was essentially limited to one device per [build]machine. Pratically that’s not the biggest deal but theoretically it’s a little limiting.

Also, controlling tests with an Instruments run is bit against the grain as well. Subliminal does a great job of getting over Instruments’s weaknesses but you still needed device hooked to a master machine. Neo has taught us better than to accept that.

BIY — Build It Yourself

Since we already had experience with KIF and had built up some infrastructure around it like helper functions, basic experiences, documented gotchas, we decided to see if we couldn’t remote-control KIF with some well known buzzwords. We threw some into a hat and came out with, wait for it, HTTP and JSON.

The basic idea is this:

  • There will be a “master” test runner on some device somewhere. It will run tests (we use XCTest).
  • n slaves will be spawned on n devices nywhere (hyuk hyuk). Each will have an HTTP server running.
  • The master will send JSON to the slaves (via bonjour names, IP, whatever) and each slave will use those parameters to do things like run pre-defined assertion or navigate in the app.

If you’re thinking “this is just a JSON-RPC server on an iPhone” you are a logical human being.

Kroker

Kif Kroker. Get it?

You can use any server you like but we(I) chose GCDWebServer because we(I) chose(found) it(it). Put it in a test case like so:

- (void)test_run_agent {
self.gcdWebServer = [[GCDWebServer alloc] init];
  // Ash POST durbatulûk, ash POST gimbatul,
// Ash POST thrakatulûk agh burzum-ishi krimpatul.
[self.gcdWebServer
addDefaultHandlerForMethod:@”POST”
requestClass:[GCDWebServerDataRequest class]
asyncProcessBlock:^(GCDWebServerRequest *request, GCDWebServerCompletionBlock completionBlock) {
       GCDWebServerDataRequest *dataRequest = (GCDWebServerDataRequest*)request;
NSDictionary *data = [dataRequest jsonObject];
// Do something here!
 }];
 [self.gcdWebServer startWithPort:8080 bonjourName:@”kroker1"];
NSLog(@”Visit %@ in your web browser”, self.gcdWebServer.serverURL);
[tester waitForTimeInterval:999999.0];
}

To make things simple we’re basically using a single POST handler for everything. The rest is invoking KIF. You can do that right from the POST handler technically but a few things make it easier.

Make sure that you dispatch everything KIF-related on the main(UI) thread

We do this with a helper function and a holder object. Here’s the helper:

// NSInvocation is nasty and var args not worth it.  Just figure out how many args we need and call it
- (void)runmethod:(InvocationParameters*)params {
@try {
// Get number of arguments
NSMethodSignature *signature = [[params.instance class] instanceMethodSignatureForSelector:params.selector];
int numOfArgs = signature.numberOfArguments;
if (numOfArgs == 2) {
objc_msgSend(params.instance, params.selector);
} else if (numOfArgs == 3) {
objc_msgSend(params.instance, params.selector, params.args[0]);
} else if (numOfArgs == 4) {
objc_msgSend(params.instance, params.selector, params.args[0], params.args[1]);
} else if (numOfArgs == 5) {
objc_msgSend(params.instance, params.selector, params.args[0], params.args[1], params.args[2]);
} else if (numOfArgs == 6) {
objc_msgSend(params.instance, params.selector, params.args[0], params.args[1], params.args[2], params.args[3]);
}
params.completionBlock([GCDWebServerResponse responseWithStatusCode:200]);
}
@catch (NSException *exception) {
GCDWebServerResponse *response = [[GCDWebServerDataResponse alloc] initWithText:[exception description]];
[response setStatusCode:500];
params.completionBlock(response);
}
}

InvocationParameters is just our own object we made up and looks like this:

@interface InvocationParameters : NSObject
@property (nonatomic, assign) SEL selector;
@property (nonatomic, strong) NSDictionary *postData;
@property (nonatomic, strong) NSArray *args;
@property (nonatomic, strong) GCDWebServerCompletionBlock completionBlock;
@property (nonatomic, strong) id instance;
-(instancetype)initWithPostData:(NSDictionary*)postData instance:(id)instance andCompletionBlock:(GCDWebServerCompletionBlock)completionBlock;
@end
@implementation InvocationParameters
-(instancetype)initWithPostData:(NSDictionary*)postData instance☹id)instance andCompletionBlock:(GCDWebServerCompletionBlock)completionBlock {
self = [super init];
if (self) {
self.postData = postData;
self.args = postData[@”args”];
self.instance = instance;
self.completionBlock = completionBlock;
self.selector = NSSelectorFromString(postData[@”selector”]);
}
return self;
}
@end

Figuring out what the JSON should look like is left as an exercise for the reader because this is one of those annoying math text books. Also you can use whatever format you want, obvs.

Putting it all together, you can call it with something like:

GCDWebServerDataRequest *dataRequest = (GCDWebServerDataRequest*)request;
NSDictionary *data = [dataRequest jsonObject];
InvocationParameters *params = [[InvocationParameters alloc] initWithPostData:data instance:self andCompletionBlock:completionBlock];
[self performSelectorOnMainThread:@selector(runmethod:) withObject:params waitUntilDone:NO];

Exit using HTTP

Once you’re done with the tests you want to shut down the app. Since Kroker runs as a test, you can just exit(0) it. Like so:

completionBlock([GCDWebServerResponse responseWithStatusCode:200]);
exit(0);

Always return a response obviously since it’s nice not to leave the client posting here hanging.

The Rest of the Story

There are other things that can make your life easier. Static IPs or bonjour bindings. A single dev-overridable config file that specifies for both devs and CI servers what configs they should run. And cetera.

Although we spent a little bit of time building this, the moral of the story was to use Kroker as little as possible. It’s attractive to say you can run your inter-device cases on real devices but distinct disadvantages are speed and flakiness. Inter-device communication is also often brokered by some other server/service/protocol like REST APIs or an XMPP server so for the most part you can test just as effectively with a single iOS app by mocking out relevant components.

But if you absolutely need to control multiple iOS devices for an automated functional test involving the UI, then this is certainly one way to do that thing that isn’t an outright lie.

Side note: Medium isn’t the best platform for writing about code is it?

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.