TDD and iPhone—NSTimer

TDD and iPhone—NSTimer

Eric Smith
Eric Smith

May 11, 2009

TDD on the iPhone is a challenging experience, especially when you’ve been spoiled by Ruby like I have been, but it can be done. I have a tutorial in the works for getting started, but in the meantime I’ll be writing bits and pieces about my experience.

The first was here and this is the second. Test cases are written using the Google Toolbox for Mac. This particular example is about the NSTimer.

It keeps going and going

The app to your right is a simple app for experimentation purposes that runs Conway’s Game of Life. It has two buttons: advance and start.

Advance moves one generation ahead each time you press it, whereas Start keeps advancing generations until you touch Stop. To implement that we use an NSTimer object which is programmed to call advance on the game object. The design is simple, shown here:

posts/2009-05-11-tdd-and-iphone-nstimer/nstimer.jpg
NSTimer object which is programmed to call advance on the game object.

The GameRunner creates an NSTimer, the NSTimer sends the message to Game. Great but how do we test it? The less I know about something the more I tend to test it, which means I tend to write smaller and smaller tests. Let’s look at my first few tests of the GameRunner object:

-(void)testSetsAndGetsTimeInterval {
				runner.interval = 0.30;
				STAssertEquals(0.30, runner.interval, nil);
}

-(void)testCreatesNSTimerObject {
				[runner start];
				STAssertNotNil(runner.timer, @"The timer was not created");
				STAssertTrue([runner.timer isValid], nil);
}

-(void)testNSTimerFields {
				runner.interval = (NSTimeInterval)0.26;
				[runner start];
				STAssertEquals([runner.timer timeInterval], 0.26, nil);
}

We’ve got some basic tests of creation. At this point I was stumped. I couldn’t check the other parameters of the timer, because they weren’t public variables, so at this point the code that actually created the timer looked like this:

-(void)start {
				timer = [[NSTimer timerWithTimeInterval:interval
																																							target:nil
																																					selector:nil
																																					userInfo:nil
																																						repeats:true] retain];
}

That doesn’t really help me does it? I have a timer with an interval, but it won’t call anything when it’s triggered. This is about the time I got to pair with Jake Scruggs during the Craftsman Swap, which he’s written about here.

It went badly, somewhat embarrassingly for me since I’m supposed to be the expert, however we learn from failure. Let’s look at some of the steps Jake and I took that didn’t quite get us there.

The first pass on the test was simple. We needed a mock game, and it needed to have its advanceGeneration method called (Ed. note: I realize they aren’t called methods in Obj-C, to which I say: bite me.) each turn. Let’s try the brute force approach:

-(void)testTimerCallsGameAdvance {
				MockGame *mockGame = [[MockGame alloc] init];
				runner.game = mockGame;

				[runner start];
				sleep(.30);

				STAssertTrue([mockGame advanceGenerationCalled], nil);
}

We’ve got some basic tests of creation. At this point I was stumped. I couldn’t check the other parameters of the timer, because they weren’t public variables, so at this point the code that actually created the timer looked like this:

-(void)start {
				timer = [[NSTimer timerWithTimeInterval:interval
																																						target:nil
																																				selector:nil
																																				userInfo:nil
																																					repeats:true] retain];
}

I’m writing the original tests from memory, so I make the occasional error here, but it doesn’t matter since we got this wrong. This test here seemed the most logical. We ran the test and it failed, yea! Then we updated the code:

-(void)start {
				timer = [[NSTimer timerWithTimeInterval:interval
																																						target:game
																																				selector:@selector(advanceGeneration)
																																				userInfo:nil
																																					repeats:true] retain];
}

Experienced Cocoa developer’s have already discovered our error, please don’t ruin it for the rest of the readers. So after changing the code we run the test and… we fail. So from here Jake and I figured it out.

Sleep won’t work because it stops the entire application, including the run loop that calls our timer, so we’ll just have to get the info out of the timer another way. We’re interested in testing that we’ve setup the timer correctly, and can trust that the timer just works. After a long time browsing documentation we figured it out. We could call this:

-(void)testTimerCallsGameAdvance {
				MockGame *mockGame = [[MockGame alloc] init];
				runner.game = mockGame;
				[runner start];
				[runner.timer fire];
				STAssertTrue([mockGame advanceGenerationCalled], nil);
}

This will fire the timer and validate that it is setup correctly. So we did this, our test passed, we ran the app and it…failed. We knew the timer was executing because our test was passing, but when we ran the real app it just didn’t do it. Stumped and frustrated we stopped for the day. Then time passed…

Yesterday I had a candidate in for an apprenticeship position, and we attempted to solve the same problem. We re-evaluated the test and decided calling fire directly was cheating. We looked into a half-dozen other ways to test this and learned about the NSRunLoop.

Too much coding in Windows had rotted my brain, the run loop in Objective-C is not the same as the one in Windows. Specifically the current run loop is accessible as an object. We wrote a new test:


-(void)testTimerCallsGameAdvance {
				MockGame *mockGame = [[MockGame alloc] init];
				runner.game = mockGame;

				[runner start];
				[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.30]];

				STAssertTrue([mockGame advanceGenerationCalled], nil);
}

It’s not perfect—the 0.30 relies on the hard coded default interval of 0.25, and that is too long, but it failed! In the process of learning about NSRunLoop we also realized I used the wrong method. The working code is this:

-(void)start {
				timer = [[NSTimer scheduledTimerWithTimeInterval:interval
																																															target:game
																																													selector:@selector(advanceGeneration)
																																													userInfo:nil
																																														repeats:true] retain];
}

See the word “scheduled” in front of Timer? That actually adds the timer to the default run loop. Now we ran our spec, ran our actual code, and it works! The moral of the story? Well if you’re persistent enough you can get your code under test, and if your test is correct then your code will work.