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

iPhone TDD with Rbiphonetest II - Posting to Twitter

Earlier I wrote a brief tutorial about getting started with Rbiphonetest we test-drove saving and reloading Twitter account details. Now let’s go for something a bit more ambitious: sending a tweet.

We’ll start by checking a request is sent to the Twitter api url for tweeting.


def test_tweets_are_sent_to_the_correct_twitter_api_url
  @testee.tweet "hello matey" 
  assert_equal "http://twitter.com/statuses/update.xml",OSX::NSURLConnection.last_request_url
end

We’ll need to trick-up NSURLConnection to record the last request url.


class OSX::NSURLConnection
  class << self
    attr_reader :last_request_url;
    define_method 'connectionWithRequest:delegate:' do |request, delegate|
      @last_request_url = request.URL.absoluteString
    end
  end
end

Something to note here we used define_method with the actual Objective-C method name, colons and all. There seems to be a RubyCocoa glitch that means that you need to use the exact method name, when monkey-patching methods. Usually RubyCocoa supports replacing colons in Objective-C method names with underscores: we would expect to be able to use connectionWithRequest_delegate as a Ruby equivalent, as indeed we can when calling the method. As ’:’ is not legal to use in a Ruby def statement, we are using define_method which is much more liberal.

Now for the minimum implementation to make the test pass.


-(void) tweet:(NSString*)message{
    NSURLRequest *request = [NSURLRequest requestWithURL:
        [NSURL URLWithString:@"http://twitter.com/statuses/update.xml"]];
    [NSURLConnection connectionWithRequest:request delegate:nil];
}

That was easy, and we’re getting somewhere. Ok what else do we need to post a Tweet? Well it has to be a HTTP POST and contain the message in the parameter status.


 def test_tweets_are_post_with_message_the_status_parameter
   @testee.tweet "hello matey" 
   assert_equal "POST", OSX::NSURLConnection.last_request_method
   assert_equal "status=hello matey", OSX::NSURLConnection.last_request_body    
 end

Let’s open up bit more of a window into the soul of NSURLConnection.


class OSX::NSData
  def from_nil_terminated_str
    i = 0
    loop do
      return bytes.bytestr(i) if bytes.int8_at(i) == 0
      i+=1
    end
  end
end

class OSX::NSURLConnection
  class << self
    attr_reader :last_request_url, :last_request_method, 
            :last_request_body;
    define_method 'connectionWithRequest:delegate:' do |request, delegate|
      @last_request_url = request.URL.absoluteString
      @last_request_method = request.HTTPMethod
      @last_request_body = request.HTTPBody.from_nil_terminated_str if request.HTTPBody
    end
  end
end

We needed to do a bit of work converting the HTTPBody back to a string. Our solution isn’t bullet-proof as it’s UTF8 encoded, but as long as we stick to single byte UTF8 characters in our test data we’ll be fine.

In the implementation we need to use to a NSMutableURLRequest so we can set the HTTPMethod and HTTPBody properties.


-(void) tweet:(NSString*)message{
    NSMutableURLRequest *request = 
        [NSMutableURLRequest requestWithURL:
        [NSURL URLWithString:@"http://twitter.com/statuses/update.xml"]];
    request.HTTPMethod = @"POST";
    request.HTTPBody = [[NSString stringWithFormat:@"status=%@",message] 
        dataUsingEncoding:NSUTF8StringEncoding];
    [NSURLConnection connectionWithRequest:request delegate:nil];
}

So who’s sending this Tweet? We need to authenticate ourselves; this is going to be done by an asynchronous callback. We’re going to need to do a bit more tricking things out: we’ll record the last delegate used by NSURLConnection.


class OSX::NSURLConnection
  class << self
    attr_reader :last_request_url, :last_request_method, :last_request_body, :delegate;
    define_method 'connectionWithRequest:delegate:' do |request, delegate|
      @last_request_url = request.URL.absoluteString
      @last_request_method = request.HTTPMethod
      @last_request_body = request.HTTPBody.from_nil_terminated_str if request.HTTPBody
      @last_delegate = delegate
    end
  end
end

And we’ll need to use our own special authentication challenge to check what gets called.


class StubAuthenticationChallenge < OSX::NSURLAuthenticationChallenge 
  attr_reader :previousFailureCount, :sender

  class StubSender
    attr_reader :credential, :challenge, :cancelled_challenge
    def useCredential_forAuthenticationChallenge credential, challenge
      @credential = credential.retain.autorelease if credential
      @challenge = challenge
    end

    def cancelAuthenticationChallenge challenge
      @cancelled_challenge = challenge
    end
  end

  def initialize
    @previousFailureCount = 0
    @sender = StubSender.new
  end

  def credential_used
    @sender.credential
  end

  def challenge_used_with_credential
    @sender.challenge
  end

  def cancelled?
    @sender.cancelled_challenge
  end
end

Note that our stub actually extends NSURLAuthenticationChallenge. We don’t need to do that our stub only returns pointers to Objective-C objects. If we have a method that returns a C structures – such as NSInteger, BOOL, and NSRange – we need to provide RubyCocoa a hint to what we are returning by overriding the class that defines that method: in this case previousFailureCount, which we will be using later, returns NSInteger.

Also worth noting is that we call retain on the credential in the StubSender_ method useCredential_forAuthenticationChallenge: this is to keep it around long enough for us to check its values.


def test_username_and_password_used_in_response_to_authentication_challenge
  @testee.username = 'rita'
  @testee.password = 'mysecret'

  @testee.tweet "hi" 

  challenge = StubAuthenticationChallenge.alloc
  assert  OSX::NSURLConnection.last_delegate
  OSX::NSURLConnection.last_delegate.connection_didReceiveAuthenticationChallenge nil, 
        challenge

  assert_equal challenge, challenge.challenge_used_with_credential
  assert challenge.credential_used
  assert_equal "rita", challenge.credential_used.user
  assert_equal "mysecret", challenge.credential_used.password
end

Confused? Maybe. Let’s walk through that a little.

  1. When our Objective-C code creates the connection asynchronously, we now need it to pass a ‘delegate’ to the connectionWithRequest:delegate: method on NSURLConnection; this delegate receives the asynchronous callbacks relaying the chaninging state of the connection.
  2. In our test code we are now monkey patching to, amongst other things, record the delegate passed to connectionWithRequest:delegate:.
  3. Our test can then call methods on the delegate to check the reaction of our code to callbacks: in this case the connection:didReceiveAuthenticationChallenge callback.
  4. We’ve stubbed out the StubAuthenticationChallenge, and record the challenge response to its sender.

(Note: as StubAuthenticationChallenge extends an Objective-C class we create using alloc instead of new: another RubyCocoa oddity.)

Let’s make that pass: we need a delegate to respond to the challenge.


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

@end

@implementation TweetDelegate
@synthesize username;
@synthesize password;

+(id)tweetDelegateWithUsername:(NSString*) username andPassword:(NSString*) password{
    TweetDelegate *result = [[TweetDelegate alloc] autorelease];
    result.username = username;
    result.password = password;
    return result;
}

- (void)connection:(NSURLConnection *)connection 
        didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
    NSURLCredential *credential = [[NSURLCredential alloc] 
        initWithUser:self.username 
        password:self.password
        persistence:NSURLCredentialPersistenceForSession]; 

    [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
    [credential release];
}
@end

And we’ll pass that delegate into the NSURLConnection creation.


-(void) tweet:(NSString*)message{
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:
        [NSURL URLWithString:@"http://twitter.com/statuses/update.xml"]];
    request.HTTPMethod = @"POST";
    request.HTTPBody = [[NSString stringWithFormat:@"status=%@",message] 
        dataUsingEncoding:NSUTF8StringEncoding];
    [NSURLConnection connectionWithRequest:request delegate:
        [TweetDelegate tweetDelegateWithUsername:self.username andPassword:self.password]];
}

Now we need to make sure that we only attempt the authentication once: if we have the wrong credentials the first time they are going to stay wrong no matter how many times we try to authenticate against them.


def test_only_attempts_to_authenticate_once_against_an_authentication_challenge

  challenge = StubAuthenticationChallenge.alloc
  challenge.previousFailureCount = 1

  @testee.username = 'rita'
  @testee.password = 'mysecret'
  @testee.tweet "yo" 
  OSX::NSURLConnection.last_delegate.connection_didReceiveAuthenticationChallenge nil, challenge

  assert challenge.credential_used.nil?
  assert challenge.cancelled?
end

-(void)connection:(NSURLConnection *)connection 
    didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge {
    if ([challenge previousFailureCount] == 0){
        NSURLCredential *credential = [[NSURLCredential alloc] 
            initWithUser:self.username 
            password:self.password
            persistence:NSURLCredentialPersistenceForSession]; 

        [[challenge sender] useCredential:credential forAuthenticationChallenge:challenge];
        [credential release];
    }
    else{
        [[challenge sender] cancelAuthenticationChallenge:challenge];
    }
}

Now we’re tweeting!

Or are we? We don’t know as we’re using NSURLConnection asynchronously and we’re not hanging around long enough to find out what happened. Let’s get some feedback. We’ll start with an Objective-C protocol to be notified.


@protocol TweetObserver
-(void)tweetSucceeded;
-(void)tweetFailedWithError:(NSError*) error;
-(void)tweetFailedAuthentication;
@end

Ok, ok. I know that idiomatically we ought to call the protocaol TweetDelegate, but I just can’t bring myself to do that. Observer, Listener, even Callback – yes. But in what way is this a delegate? That’s right: in no way.

Now I’ve got that off my chest let’s stub that out and write the test to show us being notified of a successful tweet: we should get a connectionDidFinishLoading callback on success.


def test_should_be_notified_of_a_successful_tweet
  @testee.tweet_andNotifyObserver "hey", @tweet_observer

  OSX::NSURLConnection.last_delegate.connectionDidFinishLoading nil
  assert @tweet_observer.tweet_succeeded
end

Ok, failing: we’ll change the tweet method to take the observer and pass it to the connection delegate.


-(void) tweet:(NSString*)message andNotifyObserver:(id<TweetObserver>) observer{
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL 
        URLWithString:@"http://twitter.com/statuses/update.xml"]];
    request.HTTPMethod = @"POST";
    request.HTTPBody = [[NSString stringWithFormat:@"status=%@",message] 
        dataUsingEncoding:NSUTF8StringEncoding];
    [NSURLConnection connectionWithRequest:request delegate:
    [TweetDelegate tweetDelegateWithUsername:self.username 
        password:self.password andTweetObserver:observer]];
}

Now let’s wire up the observer to the delegate.


@interface TweetDelegate : NSObject{
    NSString *username;
    NSString *password;
    id<TweetObserver> tweetObserver;
}
@property(nonatomic, retain) NSString* username;
@property(nonatomic, retain) NSString* password;
@property(nonatomic, retain) id<TweetObserver> tweetObserver;

@end

@implementation TweetDelegate
@synthesize username;
@synthesize password;
@synthesize tweetObserver;

+(id)tweetDelegateWithUsername:(NSString*)username password:(NSString*)password
         andTweetObserver:(id<TweetObserver>) observer{
    TweetDelegate *result = [[TweetDelegate alloc] autorelease];
    result.username = username;
    result.password = password;
    result.tweetObserver = observer;
    return result;
}

-(void)connectionDidFinishLoading:(NSURLConnection*)connection{
    [self.tweetObserver tweetSucceeded];
}

.......

@end

And now to deal with failures.


def test_listener_notified_of_failure
  error = OSX::NSError.alloc

  @testee.tweet_andNotifyObserver "oi!", @tweet_observer
  OSX::NSURLConnection.last_delegate.connection_didFailWithError nil, error

  assert_equal error, @tweet_observer.tweet_error
  assert !@tweet_observer.tweet_succeeded  
end

def test_user_notified_of_authentication_failure
  error = OSX::NSError.errorWithDomain_code_userInfo("NSURLErrorDomain", 
        OSX::NSURLErrorUserCancelledAuthentication, nil)
  @testee.tweet_andNotifyObserver "hi!", @tweet_observer

  OSX::NSURLConnection.last_delegate.connection_didFailWithError nil, error
  assert @tweet_observer.tweet_failed_authentication
  assert !@tweet_observer.tweet_error
  assert !@tweet_observer.tweet_succeeded    
end

Impementation:


@implementation TweetDelegate
.....

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error{
    if ([error code] == NSURLErrorUserCancelledAuthentication){
        [self.tweetObserver tweetFailedAuthentication];
    }else{    
        [self.tweetObserver tweetFailedWithError:error];
    }
}
@end

That’s about it. We’ve now test driven a functional increment: the ability to send a tweet. We’re not really done-done, though: there are still a few questions hanging around in the ether.

Wot no mocks?

So we’ve gone to the trouble of hand-rolling all this stubbing around NSURLConnection and friends: why not use one of the popular Ruby mock-object framework, like FlexMock or Mocha? While I’m not keen on mocks, this isn’t the primary reason. After all we’ve ended up writing mostly interaction tests against NSURLConnection and friends, and Flexmock, at least, is somewhat agnostic about whether it is a mocking or a stubbing framework.

We mainly have not used a mocking framework in order to limit the number of moving parts in the example. This is experimental stuff: it is challenging enough to track down anomalies to coding mistakes, misunderstandings, or RubyCocoa glitches; throwing another framework, particularly one with the potential to react in interesting and unexpected ways with RubyCocoa, would make things even more difficult.

Consider also that the stubbing code that we have written is generally useful: it has the potential to be extracted into a library (gem) to be reused in different projects.

Wot no RSPec?

The mainstream of Ruby, particularly Rails, development has gone all BDD these days and RBIPhonetest does support RSpec. We have used TestUnit because I’m old-school. Expect to see some Shoulda in later posts, however.

Wot no separation of concerns?

If you’ve been following closely you may have noticed that we’ve added the functionality to send a tweet, with all that HTTP shenanigans, to the same class responsible for persisting the account details. Both the test and the productions code suffer from an unfortunate mix of concerns. But do not despair. Remember the old XP maxim:

Make it work, then make it right

In the next thrilling episode we’ll refactor the production code, supported by the tests; we’ll also refactor the tests, supported by the production code.

All