Home
subscribe

L'etat, c'est moi

Mere Complexities sells the consulting and development services of me, Paul Wilson.

Conferences

Organising Scotland on Rails
Speaker, RailsConf Europe '08

Archive

Unit Testing iPhone Development with Rbiphonetest

I’ve been doing Phone development recently, and have a little application ready to send to the App Store. Although it’s not been long since the NDA was lifted, there is quite a lot of information out there. I’ve been mostly using:-

  • Apple’s own bundled documentation, which is very good.
  • the Pragmatic iPhone SDK Development, which is in Beta, and the associated forum.
  • StackOverflow, which has turned into an excellent source of information.
  • The official Apple iPhone Development forums (registration needed), but only a little for some reason.

One area not so well covered is test driving iPhone development; the guys writing the Prag book seem very old-school, and TDD is not mentioned. Dr Nic to the rescue. The great thing about Dr Nic is that he doesn’t mind getting his hands dirty, just trying things out and coming up with partial solutions for the community to work with. I think that’s brilliant. One of Dr Nic’s little brain children is Rbiphonetest, which enables (currently limited) unit testing of iPhone Objective-C in ruby. It’s work-in-progress, but quite nifty.

Here’s a little getting started tutorial. I’m not going to run through creating a whole app – just testing a bit of it. If you’re coding along I’m assuming you set up for iPhone development (Mac OS 10.5 Leopard with the latest iPhone SDK). I’m also assuming your Ruby gem version is up to date (sudo gem update—system), so the first thing you need to do is install the gem


    sudo gem install rbiphontest

Here I ran into a few problems installing on a new Mac: the gem dependencies weren’t properly sorted out, so I needed to install some other gems before successfully installing rbiphonetest; unfortunately I didn’t take a note, but I think they included hoe, and rubigen.

Were also going to want to run autotest. If you don’t already have it,


    sudo gem install ZenTest

Let’s do some TDD. Twitter clients are the new Flashlight apps, so let’s start one of those. Fire up XCode and create an iPhone OS project. (For the purposes of this example it doesn’t matter what type; a Utility app is as good as any). From the terminal go to your new application directory and initialise rbiphontest.


    rbiphontest .

This creates a few directories and scripts. It might also be a point where you encounter some dependency problems. If you do let me know and I’ll update this post; probably worthwhile dropping Dr Nic a line too.

Rbiphonetest only supports testing models just now, so let’s do that. We’ll need to store the user’s twitter account details, so let’s create a model for that. From the command line:


    script/generate model TwitterAccount

That’s created an Objective-C header and implementation file in the Classes directory, and a Ruby test in test. Flip into XCode and add TwitterAccount.h and TwitterAccount.m to the project. (Menu Project > New Group “Classes”. Right-click on “Classes” and choose Add > Existing Files. )

We can compile and run the test using


    rake

We get


1 tests, 0 assertions, 0 failures, 0 errors

Let’s do some TDD. XCode sucks, so I like to do most of the development in TextMate. Your mileage may vary, so open up “test/test_twitter_account.rb” in your own way.


    mate .

This is what we have:


require File.dirname(__FILE__) + '/test_helper'

require "TwitterAccount.bundle" 
OSX::ns_import :TwitterAccount

class TestTwitterAccount < Test::Unit::TestCase
  def test_twitter_account_class_exists
    OSX::TwitterAccount
  end
end 

The generated test just tests the compilation – fair enough. Let’s take a baby step: an account is going to need a username.


  def test_twitter_account_has_username
    testee = OSX::TwitterAccount.alloc
    testee.username = 'marvin'
    assert_equal "marvin", testee.username
  end

Run rake and we get:


SX::OCMessageSendException: Can't get Objective-C method 
signature for selector 'setUsername:'...
....
1 tests, 0 assertions, 0 failures, 1 errors

Yay, we have a failing test – we can write some production code.

TwitterAccount.h:


@interface TwitterAccount : NSObject {
@private
    NSString *username;
}
@property(retain, nonatomic) NSString *username;
@end

TwitterAccount.m:


@implementation TwitterAccount
@synthesize username;
@end

Run rake and


1 tests, 1 assertions, 0 failures, 0 errors

Notice that we don’t have an easily affordable way of testing the “retain” and “nonatomic” attributes of the property; I really don’t think that matters.

We need a password as well. We don’t want to have to keep on running rake, though. Open up a new command shell(or tab) and change into the project directory.


    autotest

Now everytime the a change is made to test, or code under test, any compilations required are performed and all the tests are run. With growl setup to give notifications, this is a very fluid way of TDDing Cocoa-Touch.

Ok, let’s add the test for the password.


def test_twitter_account_has_username_and_password
  testee = OSX::TwitterAccount.alloc
  testee.username = 'marvin'
  testee.password = 'secret'
  assert_equal "marvin", testee.username
  assert_equal "secret", testee.password
end

It fails – let’s make it pass.

TwitterAccount.h:


@interface TwitterAccount : NSObject {
@private
    NSString *username;
    NSString *password;
}
@property(retain, nonatomic) NSString *username;
@property(retain, nonatomic) NSString *password;
@end

TwitterAccount.m:


@implementation TwitterAccount
@synthesize username;
@synthesize password;
@end

That was easy the easy stuff. Let’s try something a bit more challenging. We want to be able to save and reload the account details.


def test_account_details_may_be_saved_and_reloaded
  testee = OSX::TwitterAccount.alloc
  testee.username = 'victoria'
  testee.password = 'secret'
  testee.save

  reloaded = OSX::TwitterAccount.alloc
  reloaded.load

  assert_equal "victoria", testee.username
  assert_equal "secret", testee.password       
end

Big step, but there’s a lot of boilerplate example for this kind of thing knocking around so let’s give it a go. We could add the following to TwitterAccount.m:


-(NSString*) accountPlistPath{
    NSString *userDir = 
    [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) 
            objectAtIndex:0];
    return [userDir stringByAppendingPathComponent:@"account.plist"];
}

-(void) save{
    NSString *path = [self accountPlistPath];
    NSDictionary *accountDetails = 
    [NSDictionary dictionaryWithObjectsAndKeys:self.username, 
        @"username", self.password, @"password", nil];
    BOOL written = [accountDetails writeToFile:path atomically:YES];
    NSLog(@"written: %d", written);

}

-(void) load{
    NSDictionary *accountDetails = 
   [[NSDictionary alloc] initWithContentsOfFile:[self accountPlistPath]];    
    self.username = [accountDetails objectForKey:@"username"];
    self.password = [accountDetails objectForKey:@"password"];
    [accountDetails release];
}

That passes but there are issues. For one thing it violates one of Mike Feathers’ unit testing rules: it touches the file system. In these cases, I tend to side with Terry Pratchett’s Lu-Tze:

Rules are there to make you think before you break them.

I have never had problems caused by the occasional unit test that hits the file system, so I don’t worry too much especially if the alternative is lots more code. On the other hand, this test is writing to the ~/Documents directory, which is too messy. Let’s trick that out.


class TestTwitterAccount < Test::Unit::TestCase
  def self.trick_account_plist_path
    @@account_plist_path||= Tempfile.new("accountPlistPath").path
  end 
  class OSX::TwitterAccount
    def accountPlistPath
      TestTwitterAccount.trick_account_plist_path
    end    
  end

  .....

end

Now the accountPlistPath method is not at all under test; let’s fix that by aliasing the original and showing that it saves to the user documents directory.


class OSX::TwitterAccount
  objc_alias_method :original_accountPlistPath, :accountPlistPath
  def accountPlistPath
    TestTwitterAccount.trick_account_plist_path
  end    
end

......

def test_account_plist_is_saved_to_user_document_directory
   assert_equal File.expand_path("~/Documents/account.plist"), 
   @testee.original_accountPlistPath
end

There is another test to write: let’s make sure we can handle having no previously saved preferences file.


def test_username_and_password_are_initially_nil_if_not_previously_saved
 File.delete TestTwitterAccount.trick_account_plist_path
 testee = OSX::TwitterAccount.alloc
 testee.load
 assert_equal nil, testee.username
 assert_equal nil, testee.password
end

The alloc and load is looking a bit cumbersome. Let’s add a static factory method.


  def setup
    FileUtils.rm_f TestTwitterAccount.trick_account_plist_path 
    @testee = OSX::TwitterAccount.loadFromPlist
  end

and


+(id)loadFromPlist{
    TwitterAccount *result = [[TwitterAccount alloc]autorelease];
    [result load];
    return result;
}

Now let’s tidy up the test a bit.


require File.dirname(__FILE__) + '/test_helper'
require 'tempfile'
require 'fileutils'

require "TwitterAccount.bundle" 
OSX::ns_import :TwitterAccount

class OSX::TwitterAccount
  objc_alias_method :original_accountPlistPath, :accountPlistPath
  def accountPlistPath
    TestTwitterAccount.trick_account_plist_path
  end    
end

class TestTwitterAccount < Test::Unit::TestCase
  def self.trick_account_plist_path
     @@account_plist_path||= Tempfile.new("accountPlistPath").path
  end 

  def setup
    FileUtils.rm_f TestTwitterAccount.trick_account_plist_path 
    @testee = OSX::TwitterAccount.loadFromPlist
  end

  def test_account_plist_is_saved_to_user_document_directory
     assert_equal File.expand_path("~/Documents/account.plist"), 
    @testee.original_accountPlistPath
  end

  def test_twitter_account_has_username_and_password
    @testee.username = 'marvin'
    @testee.password = 'tellnot'
    assert_equal "marvin", @testee.username
    assert_equal "tellnot", @testee.password
  end

  def test_account_details_may_be_saved_and_reloaded
    @testee.username = 'victoria'
    @testee.password = 'secret'
    @testee.save

    reloaded = OSX::TwitterAccount.alloc
    reloaded.load

    assert_equal "victoria", reloaded.username
    assert_equal "secret", reloaded.password       
  end

  def test_username_and_password_are_initially_nil_if_not_previously_saved
    assert_equal nil, @testee.username
    assert_equal nil, @testee.password
  end
end

That’s good enough for now. I’m beginning to wonder whether the persistence logic should be in the same place as the domain logic: for now I think it should; it’s under test so we can refactor with confidence should things get more complex later.

Coming soon:

  • unit testing sending a tweet to Twitter
  • stretching Rbiphonetest to test interaction between our presentation code and UIKit classes.
  • some Rbiphonetest gotchas
All