Karlin Fox and I have been using RubyMotion and ReactiveCocoa to build an iPad application for our current project. We’ve enjoyed the different paradigm and the way we’re able to stream data through our application. Adapting TouchDB live queries to ReactiveCocoa signals has been particularly rewarding.
But what about unit testing our iOS app? Unit testing in iOS has matured a lot in the past few years, and RubyMotion brings testing along from the start of a new project. However, development paradigms like FRP have a dramatic effect on how we test, what we test, and what tools we choose to build our tests.
Mocking vs. Real Objects
We’ve been alternating between mocking out signals and creating real signals we can instrument in our unit tests. Each method has its advantages.
Using a mock is often simpler than using a real signal in a simple case. For example, we have a few methods that take a signal from one component, call takeUntil
with another other signal, and return the result. We decided to use mocks here for simplicity (specifically, motion-facon).
On the other hand, method chaining can make testing using mocks a HUGE pain. See below for an example; it only gets worse with a longer method chain. You’ll find a few of our test helpers included later in this post (e.g., and_trap_block
) that make both mock- and Subject-based testing easier to deal with.
class ThatThing
attr_accessor :otherSignal
def thatMethod(signal)
signal.map! do |stuff|
stuf['aThing']
end.filter! do |thing|
thing != :none
end
end
end
describe 'thatMethod' do
before do
@target = ThatThing.new
end
it 'has signal method chaining and we use mocks' do
inSignal = mock 'first signal'
secondSignal = mock 'second signal'
mapBlock = inSignal.should.receive(:map!).
and_return(secondSignal).and_trap_block
filterBlock = secondSignal.should.receive(:filter!).
and_return(:finalSignal).and_trap_block
@target.thatMethod(inSignal).should.equal :finalSignal
mapBlock.trigger({'aThing' => 'got it').should.equal 'got it'
filterBlock.trigger('not none').should.equal true
filterBlock.trigger(:none).should.equal false
end
end
In these cases, it’s often easier to use a subject or two, and then test the results of pushing various values through your chain. Here is the above test re-written to use subjects instead of mocks:
describe 'thatMethod' do
before do
@target = ThatThing.new
end
it 'has signal method chaining and we use real signals' do
inputSignal = RACSubject.subject
values = valuesFromSignal @target.thatMethod
inputSignal.sendNext({'aThing' => 'stuff'})
inputSignal.sendNext({'aThing' => :none})
values.should.equal ['stuff']
end
end
Mocking can still be advantageous when the operations on the signal have a multiplicative effect and testing each case becomes prohibitively expensive.
State-based vs. Interaction-based Tests
While ReactiveCocoa delivers on its promise to turn ordinary application state into streams and signals with first-class handing of new and changing values, it does introduce a new kind of state to an application — the signals themselves.
We have many signals that are wired up at object creation time, and then are exposed as properties on that object. This has led us to pursue a mixed state-based and interaction-based testing style. We verify that the signal our object under test is subscribing to comes from the expected source using mocking, but then verify afterward that the property has the correct value.
One of the challenges of wiring up signals at object creation time is separating concerns to avoid mocking/stubbing hell. A simple solution to that problem is creating a separate method per initialization concern, and calling them all from the primary configuration method.
Our Helpers
A few of our test helpers from this project that are applicable to general iOS unit testing involving RubyMotion and ReactiveCocoa are included below with explanations
Fork of Motion-facon
We wanted a way to trap blocks (see and_trap_block
in earlier example) that get passed to mocked methods. This fork allows us to do that. The blocks can then be executed to test their specific effects on the state and interactions under test. When we get a little time, we’d like to finish tests and submit a pull request for this.
Bacon Assertion Fix
Bacon considers a test containing no bacon assertions empty and wrong, and whines about there being no assertions. Some of our tests only do mock assertions, so that behavior was not acceptable. We replace Bacon’s it
with our own version to avoid the problem.
# Monkey patch Bacon. Mmmmmm.
module Bacon
class Context
alias_method :old_it, :it
def it(description, &testBlock)
if !testBlock.nil?
old_it description do
testBlock.call
true.should.be.true # true dat
end
end
end
end
end
Getting Values from a Signal
Often, using real signals during a test to instrument the filtering, mapping, or other signal operations within a method under test, we want to know what sequence of values appeared on a signal. We created this helper to make that simple. It returns an array that will contain all the values that appear on a signal.
def valuesFromSignal(signal)
values = []
signal.subscribeNext ->(val) do
values << val
end
values
end