Unit Testing Cocoa with MacRuby

I spend most of my development time split between Rails and iOS. Each offers a rich API that makes building projects much more productive and enjoyable. There is one place, however, that Ruby clobbers Objective-C: testing.

It’s not that Objective-C doesn’t have test frameworks. There are several: OCUnit (bundled with Xcode), GHUnit, Cedar and Kiwi. Cedar and Kiwi use blocks to get close to an RSpec-like syntax, but there’s only so far it can go. Objective-C can’t match Ruby when creating a DSL, even with preprocessor abuse.

Fortunately, there is a way to write tests in Ruby and run them against Objective-C code: MacRuby. MacRuby is an implementation of Ruby 1.9 that compiles Ruby to LLVM bytecode and has full access to Objective-C frameworks. You can write full Mac desktop applications with it, but for this post I’ll simply use it to write test specs with RSpec for testing Objective-C classes.

In this post, I’ll walk you through how to install MacRuby, RSpec and how to set up your Cocoa (Mac or iOS) project to unit test with RSpec. These tests are analogous to model specs in Rails. We’ll be testing classes in isolation, not trying to recreate the Cocoa environment in order to test window or view controllers, etc.

A big caveat for iOS developers: MacRuby loads Objective-C code from a framework, but iOS targets don’t support building frameworks. What this means is that you don’t have access to the UI* classes in the code under test. This limits what you can test. Pure model classes (those without any external dependencies) will be fine. Theoretically, it should be possible to find the simulator frameworks and link against those, but I’ve been unsuccessful thus far. Chameleon might help if you want to dig into this further.

With that out of the way, let’s get started. If you haven’t already, download and install MacRuby. This places the macruby binary and other Ruby tools you’re familiar with (irb, gem, rake) in /usr/local/bin, with mac prefixes.

Then, install RSpec 2.5 (at present, there are problems running specs with 2.6).

$ sudo macgem install --bindir /usr/local/bin --format-executable rspec -v '~>2.5.0'

--bindir ensures the rspec script doesn’t overwrite one that might already exist in /usr/bin, and --format-executable adds the mac prefix to it, so it matches the other MacRuby-provided scripts.

In Xcode, open your project and create a new target (File -> New Target). Use the Cocoa Framework template, found under Mac OS X, Framework & Library. Call it whatever you like. I used “Specs”. Do not use Automatic Reference Counting. MacRuby, like MRI Ruby, is garbage collected and will use the Objective-C garbage collector. If your project uses ARC, that’s OK. retain, release and autorelease are no-ops under GC, and nothing changes if the calls to them are missing in the first place. Include Unit Tests should also be unchecked.

You must adjust some build settings for the new target. Under Apple LLVM Compiler – Language, find the settings for Objective-C Automatic Reference Counting and ensure it is set to NO. Just below that, set Objective-C Garbage Collection to Supported.

Go to the target build phases, and add the .m files you wish to test to the compile sources phase. As you add files to your project later, be sure to check the boxes for both your main target and the Specs target if you want to test the new code.

Finally, you need to configure the schemes for the new target. In the build scheme, check the run box. In the run scheme, choose /usr/local/bin/macruby as the executable to test.

Set up the arguments to pass to the executable when it runs as follows:

Choose your framework as the base for expansions. You’ll pass two arguments to the program: the path to the RSpec wrapper and the directory containing the specs.

You might wonder why we’re using MacRuby as the executable to test instead of RSpec directly. If you try to use RSpec, Xcode won’t recognize it as a 64-bit executable and will refuse to run it under the debugger. It will work, but you must also choose “debugger: none” and give up your breakpoints, etc.

With this new target you get a new top-level group in your project. Xcode has also created Specs.h and Specs.m, which we don’t need. You should delete them.

Create a new file in this group called spec_helper.rb and paste this in:

framework 'Specs'
 
# Requires supporting ruby files with custom matchers and macros, etc,
# in spec/support/ and its subdirectories.
Dir["#{ENV['SRCROOT']}/Specs/support/**/*.rb"].each {|f| require f}
 
RSpec.configure do |config|
  # == Mock Framework
  #
  # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line:
  #
  # config.mock_with :mocha
  # config.mock_with :flexmock
  # config.mock_with :rr
  config.mock_with :spec
end

At this point, you should be able to choose your new target from the scheme picker in the toolbar and run. We haven’t created any specs yet, so you should see this:

GNU gdb 6.3.50-20050815 (Apple version gdb-1708) (Mon Aug  8 20:32:45 UTC 2011)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "x86_64-apple-darwin".tty /dev/ttys004
[Switching to process 1636 thread 0x0]
No examples were matched. Perhaps {:if=>#<Proc:0x4007553a0 (lambda)>, :unless=>#<Proc:0x400795b60 (lambda)>} is excluding everything?

Finished in 0.0331 seconds
0 examples, 0 failures
Program ended with exit code: 0

You may not see any output. You can use ⌘7 and look at the most recent log or use this tip from Martin Pilkington to create a new window for output. I find this much less intrusive than allowing Xcode to constantly pop up the debugging panes in my editor windows.

Let’s create a simple spec file. Assuming you added a .m file that implements a class called Foo, create a new file in your Specs group called foo_spec.rb:

require "#{ENV['SRCROOT']}/Specs/spec_helper"
 
describe Foo do
  it { should be_an_instance_of(Foo) }
end

Run again and you should see this:

GNU gdb 6.3.50-20050815 (Apple version gdb-1708) (Mon Aug  8 20:32:45 UTC 2011)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "x86_64-apple-darwin".tty /dev/ttys004
[Switching to process 1796 thread 0x0]
.

Finished in 0.40422 seconds
1 example, 0 failures
Program ended with exit code: 0

It works! Now try adding breakpoints, mocking and stubbing objects, etc. You have all the features of RSpec available to you.