Injecting Singletons in Objective-C Unit Tests
I've promised to write this up a few times now. As I've just given another talk that covers it I thought it was time to make good on that promise.
The topic is the use of singletons in UIKit (and AppKit) and how that makes code using them hard to test. These APIs are riddled with singletons and you can't really avoid them. In case you need convincing that singletons are problematic take this contrived function:
NSString* makeWidget() {
NSString* colour =
[[NSUserDefaults standardUserDefaults] stringForKey: @"defaultColour"];
return [colour stringByAppendingString: @"Widget"];
}
NSUserDefaults is a singleton - the sole instance of which is returned when you call standardUserDefaults.
A perturbing problem
Now consider how we might test this code. Obviously in an example this trivial there are various ways we could change the code to make the problem go away. Consider this a scaled down example of a problem that may be deeper in the code - perhaps a legacy code-base (or even some third party library!).
A naive test might set the "defaultColour" key in NSUserDefaults prior to calling makeWidget(). The problem with that is that the environment is left in a changed state after the test. Subsequent tests may now pick up a different value if they use NSUserDefaults. Worse: NSUserDefaults is backed by persistent storage that can potentially leave your whole user account in a changed state!
So, at the very least, we should restore the prior value at the end of the test. This leads to further problems: If the test fails, or an exception is otherwise thrown, the clean-up would not be called. So we'd need to wrap it in a @try-@finally too. Then, can we be sure we know what value to restore it to. It's probably nil - but if it's not the environment is still in a different state. So we should capture the prior value first and hold it in a variable.
Now what if you need to set more than one value. Or you change the keys used. We're starting to do a lot of bookkeeping just to compensate for the fact that a singleton is being used. Not only is it ugly but it's increasingly error prone.
Better if we can avoid this in the first place. If we have the option - prefer to pass dependencies in - rather than have your code reach out to these Dependency Singularities. In our example either pass in the default colour, or failing that, pass in NSUserDefaults.
NSString* makeWidget( NSUserDefaults* defaults ) {
NSString* colour = [defaults stringForKey: @"defaultColour"];
return [colour stringByAppendingString: @"Widget"];
}
At first this doesn't seem to buy us much. We still need an instance of NSUserDefaults. Even if we alloc-init it we'll get a copy of the global one. That's better but we'd still be dependent on the environment and have to take steps to compensate. And in other cases we may not even have that option
If you can't make it - fake it!
We might not be able to create completely fresh instances of NSUserDefaults - but we can create instances of a stand-in class. Due to Objective-C's dynamic nature we don't even need to subclass - and we only have to implement the methods that are actually called - in this case stringForKey:. We could do that with a Mock Object. Or we can build our own Fake. Let's assume you've written a Fake called FakeUserDefaults, which contains an NSMutableDictionary, a means to populate it (perhaps via an initialiser) and an implementation of stringForKey: that looks the key up in the dictionary. Now we can test like this:
TEST_CASE() {
id defaults =
[[FakeUserDefaults alloc] initWithValue: @"Red"
forKey: @"defaultColour"];
REQUIRE_THAT( makeWidget( defaults ), StartsWith( @"Red" ) );
}
Great. That seems to tick all the boxes. We have complete control of the default value and we haven't perturbed our environment. No clean-up is required at the end of the test (not even memory, if we're using ARC)
Assuming you have the freedom to change the code under test, here, of course. If makeWidget() was buried deep in some legacy code, for example, it may not be feasible to make such a change (yet). Even if we can make the change it can be useful to be able to put the test in first to watch your back while you change it. If we need to leave the call to [NSUserDefaults standardUserDefaults] baked into the code under test for whatever reason what else can we do?
To catch a singleton we must think like a singleton
What we'd like is that, when standardUserDefaults is called on NSUserDefaults deep in the bowels of the code under test, it returns an instance of our fake class instead - but only while we're testing. Again, due to Objective-C's dynamic nature we can achieve this. But it starts to get messier. It involves gritty low-level functions from objc/runtime.h. Can we package that away somewhere?
Of course we can! Enter TBCSingletonInjector. I've uploaded the code to GitHub, but there's actually not much to it. It exposes one public (class) method:
+(void) injectSingleton: (id) injectedSingleton
intoClass: (Class) originalClass
forSelector: (SEL)originalSelector
withBlock: (void (^)(void) ) code;
The usage is best explained by example:
TEST_CASE() {
id defaults =
[[FakeUserDefaults alloc] initWithValue:@"Red" forKey:@"defaultColour"];
[TBCSingletonInjector injectSingleton: defaults
intoClass: [NSUserDefaults class]
forSelector: @selector(standardUserDefaults)
withBlock: ^ {
REQUIRE_THAT( makeWidget(), StartsWith( @"Red" ) );
} ];
}
Magic! How does it work? It uses a technique known as "method swizzling" (Ruby or Pythonists know it as "monkey patching"). In short we replace a singleton accessor method (such as standardUserDefaults) with one we control (actually another, not otherwise exposed, class method of TBCSingletonInjector). More specifically we swap the two implementations. This is so we can swap them back again when we're done. Then we call the code block - all within a @try-@finally - so no matter what happens we always restore everything to its previous state.
What does the method we swap in do? It returns a global variable.
Wait, what? I thought globals and singletons were basically the same thing? Aren't we out of the frying pan into the fire?
In the war against singletons we must fight them with singletons! Well it's not all bad. This global is only in our test code and we have full control over it. It gets set to our "injected" singleton instance (and set back to nil at the end). It's not perfect - we can only use this implementation to handle one singleton at a time. I've not yet needed to handle more than one but I daresay the implementation could be extended to handle it.
Keep it clean
Since we've hand rolled our own fake class here (FakeUserDefaults) we can tidy things up further if we encapsulate the use of the singleton injector within it. Just adding a method like this should do the trick:
-(void) use:(void (^)(void) ) code
{
[TBCSingletonInjector injectSingleton: self
intoClass: [NSUserDefaults class]
forSelector: @selector(standardUserDefaults)
withBlock: code ];
}
Now the test code becomes:
FakeUserDefaults* defs =
[[FakeUserDefaults alloc] initWithValue: @"Red"
forKey: @"defaultColour"];
[defs use:^{
REQUIRE_THAT( makeWidget(), StartsWith( @"Red" ) );
}];
Or, if you prefer, even:
[[[FakeUserDefaults alloc] initWithValue: @"Red"
forKey: @"defaultColour"]
use:^{
REQUIRE_THAT( makeWidget(), StartsWith( @"Red" ) );
}];
Not too bad, really. But, still, prefer to avoid the singletons in the first place if you have the option.
Mocking a monster
Rather than hand rolling a Fake you might prefer to use a Mock object too. I've found OCMock does the job well enough. I'm sure other mocking frameworks would do so at least as well. I prefer to use mocks when I want to test the behaviour, though. In this context that might equate to testing that some code under test sets a value in a singleton (e.g. sets a key in NSUserDefaults). The Singleton Injector works just as well for that, of course.
So there we have it. When you really have to deal with the beast you now have some tools to do so. If you do it please consider only doing so until you are able to replace the singularity with something better behaved instead.