Unit Testing Example with OCUnit
Asked Answered
P

1

39

I'm really struggling to understand unit testing. I do understand the importance of TDD, but all the examples of unit testing I read about seem to be extremely simple and trivial. For example, testing to make sure a property is set or if memory is allocated to an array. Why? If I code out ..alloc] init], do I really need to make sure it works?

I'm new to development so I'm sure I'm missing something here, especially with all the craze surrounding TDD.

I think my main issue is I can't find any practical examples. Here is a method setReminderId that seems to be a good candidate for testing. What would a useful unit test look like to make sure this is working? (using OCUnit)

- (NSNumber *)setReminderId: (NSDictionary *)reminderData
{
    NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        // Increment the last reminderId
        currentReminderId = @(currentReminderId.intValue + 1);
    }
    else {
        // Set to 0 if it doesn't already exist
        currentReminderId = @0;
    }
    // Update currentReminderId to model
    [[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];

    return currentReminderId;
}
Phocis answered 4/12, 2012 at 21:6 Comment(0)
L
95

Update: I've improved on this answer in two ways: it's now a screencast, and I switched from property injection to constructor injection. See How to Get Started with Objective-C TDD

The tricky part is that the method has a dependency on an external object, NSUserDefaults. We don't want to use NSUserDefaults directly. Instead, we need to inject this dependency somehow, so that we can substitute a fake user defaults for testing.

There are a few different ways of doing this. One is by passing it in as an extra argument to the method. Another is to make it an instance variable of the class. And there are different ways of setting up this ivar. There's "constructor injection" where it's specified in the initializer arguments. Or there's "property injection." For standard objects from the iOS SDK, my preference is to make it a property, with a default value.

So let's start with a test that the property is, by default, NSUserDefaults. My toolset, by the way, is Xcode's built-in OCUnit, plus OCHamcrest for assertions and OCMockito for mock objects. There are other choices, but that's what I use.

First Test: User Defaults

For lack of a better name, the class will be named Example. The instance will be named sut for "system under test." The property will be named userDefaults. Here's a first test to establish what its default value should be, in ExampleTests.m:

#import <SenTestingKit/SenTestingKit.h>

#define HC_SHORTHAND
#import <OCHamcrestIOS/OCHamcrestIOS.h>

@interface ExampleTests : SenTestCase
@end

@implementation ExampleTests

- (void)testDefaultUserDefaultsShouldBeSet
{
    Example *sut = [[Example alloc] init];
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

@end

At this stage, this doesn't compile — which counts as the test failing. Look it over. If you can get your eyes to skip over the brackets and parentheses, the test should be pretty clear.

Let's write the simplest code we can to get that test to compile and run — and fail. Here's Example.h:

#import <Foundation/Foundation.h>

@interface Example : NSObject
@property (strong, nonatomic) NSUserDefaults *userDefaults;
@end

And the awe-inspiring Example.m:

#import "Example.h"

@implementation Example
@end

We need to add a line to the very beginning of ExampleTests.m:

#import "Example.h"

The test runs, and fails with the message, "Expected an instance of NSUserDefaults, but was nil". Exactly what we wanted. We have reached step 1 of our first test.

Step 2 is to write the simplest code we can to pass that test. How about this:

- (id)init
{
    self = [super init];
    if (self)
        _userDefaults = [NSUserDefaults standardUserDefaults];
    return self;
}

It passes! Step 2 is complete.

Step 3 is to refactor code to incorporate all changes, in both production code and test code. But there's really nothing to clean up yet. We are done with our first test. What do we have so far? The beginnings of a class that can access NSUserDefaults, but also have it overridden for testing.

Second Test: With no matching key, return 0

Now let's write a test for the method. What do we want it to do? If the user defaults has no matching key, we want it to return 0.

When first starting with mock objects, I recommend making them by hand at first, so that you get an idea of what they're for. Then start using a mock object framework. But I'm going to jump ahead and use OCMockito to make things faster. We add these lines to the ExampleTest.m:

#define MOCKITO_SHORTHAND
#import <OCMockitoIOS/OCMockitoIOS.h>

By default, an OCMockito-based mock object will return nil for any method. But I'll write extra code to make the expectation explicit by saying, "given that it's asked for objectForKey:@"currentReminderId", it will return nil." And given all that, we want the method to return the NSNumber 0. (I'm not going to pass an argument, because I don't know what it's for. And I'm going to name the method nextReminderId.)

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    Example *sut = [[Example alloc] init];
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

This doesn't compile yet. Let's define the nextReminderId method in Example.h:

- (NSNumber *)nextReminderId;

And here's the first implementation in Example.m. I want the test to fail, so I'm going to return a bogus number:

- (NSNumber *)nextReminderId
{
    return @-1;
}

The test fails with the message, "Expected <0>, but was <-1>". It's important that the test fail, because it's our way of testing the test, and ensuring that the code we write flips it from a failing state to a passing state. Step 1 is complete.

Step 2: Let's get the test test to pass. But remember, we want the simplest code that passes the test. It's going to look awfully silly.

- (NSNumber *)nextReminderId
{
    return @0;
}

Amazing, it passes! But we're not done with this test yet. Now we come to Step 3: refactor. There's duplicate code in the tests. Let's pull sut, the system under test, into an ivar. We'll use the -setUp method to set it up, and -tearDown to clean it up (destroying it).

@interface ExampleTests : SenTestCase
{
    Example *sut;
}
@end

@implementation ExampleTests

- (void)setUp
{
    [super setUp];
    sut = [[Example alloc] init];
}

- (void)tearDown
{
    sut = nil;
    [super tearDown];
}

- (void)testDefaultUserDefaultsShouldBeSet
{
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

@end

We run the tests again, to make sure they still pass, and they do. Refactoring should only be done in "green" or passing state. All tests should continue to pass, whether refactoring is done in the test code or the production code.

Third Test: With no matching key, store 0 in user defaults

Now let's test another requirement: the user defaults should be saved. We'll use the same conditions as the previous test. But we create a new test, instead of adding more assertions to the existing test. Ideally, each test should verify one thing, and have a good name to match.

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

The verify statement is the OCMockito way of saying, "This mock object should have been called this way one time." We run the tests and get a failure, "Expected 1 matching invocation, but received 0". Step 1 is complete.

Step 2: the simplest code that passes. Ready? Here goes:

- (NSNumber *)nextReminderId
{
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return @0;
}

"But why are you saving @0 in user defaults, instead of a variable with that value?" you ask. Because that's as far as we've tested. Hang on, we'll get there.

Step 3: refactor. Again, we have duplicate code in the tests. Let's pull out mockUserDefaults as an ivar.

@interface ExampleTests : SenTestCase
{
    Example *sut;
    NSUserDefaults *mockUserDefaults;
}
@end

The test code shows warnings, "Local declaration of 'mockUserDefaults' hides instance variable". Fix them to use the ivar. Then let's extract a helper method to establish the condition of the user defaults at the start of each test. Let's pull that nil out to a separate variable to help us with the refactoring:

    NSNumber *current = nil;
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];

Now select the last 3 lines, context click, and select Refactor ▶ Extract. We'll make a new method called setUpUserDefaultsWithCurrentReminderId:

- (void)setUpUserDefaultsWithCurrentReminderId:(NSNumber *)current
{
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
}

The test code that invokes this now looks like:

    NSNumber *current = nil;
    [self setUpUserDefaultsWithCurrentReminderId:current];

The only reason for that variable was to help us with the automated refactoring. Let's inline it away:

    [self setUpUserDefaultsWithCurrentReminderId:nil];

Tests still pass. Since Xcode's automated refactoring didn't replace all instances of that code with a call to the new helper method, we need to do that ourselves. So now the tests look like this:

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

See how we continually clean as we go? The tests have actually become easier to read!

Fourth Test: With matching key, return incremented value

Now we want to test that if the user defaults has some value, we return one greater. I'm going to copy and alter the "should return zero" test, using an arbitrary value of 3.

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldReturnOneGreater
{
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    assertThat([sut nextReminderId], is(equalTo(@4)));
}

That fails, as desired: "Expected <4>, but was <0>".

Here's simple code to pass the test:

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return reminderId;
}

Except for that setObject:@0, this is starting to look like your example. I don't see anything to refactor, yet. (There actually is, but I didn't notice until later. Let's keep going.)

Fifth Test: With matching key, store incremented value

Now we can establish one more test: given those same conditions, it should save the new reminder ID in user defaults. This is quickly done by copying the earlier test, altering it, and giving it a good name:

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}

That test fails, with "Expected 1 matching invocation, but received 0". To get it passing, of course, we simply change the setObject:@0 to setObject:reminderId. Everything passes. We're done!

Wait, we're not done. Step 3: Is there anything to refactor? When I first wrote this, I said, "Not really." But looking it over after watching Clean Code episode 3, I can hear Uncle Bob telling me, "How big should a function be? 4 lines is OK, maybe 5. 6 is… OK. 10 is way too big." That's at 7 lines. What did I miss? It must be violating the rule of functions by doing more than one thing.

Again, Uncle Bob: "The only way to be really be sure that a function does one thing is to extract 'til you drop." Those first 4 lines work together; they calculate the actual value. Let's select them, and Refactor ▶ Extract. Following Uncle Bob's scoping rule from episode 2, we'll give it a nice, long descriptive name since its scope of use is very limited. Here's what the automated refactoring gives us:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    return reminderId;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId;
    reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

Let's clean that up to make it tighter:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        return @([reminderId integerValue] + 1);
    else
        return @0;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

Now each method is really tight, and it's easy for anyone to read the 3 lines of the main method to see what it does. But I'm uncomfortable having that user defaults key spread across two methods. Let's extract that into a constant at the head of Example.m:

static NSString *const currentReminderIdKey = @"currentReminderId";

I'll use that constant wherever that key appears in the production code. But the test code continues to use the literals. This guards us from someone accidentally changing that constant key.

Conclusion

So there you have it. In five tests, I have TDD'd my way to the code you asked for. Hopefully it gives you a clearer idea of how to TDD, and why it's worth it. By following the 3-step waltz

  1. Add one failing test
  2. Write the simplest code that passes, even if it looks dumb
  3. Refactor (both production code and test code)

you don't just end up at the same place. You end up with:

  • well-isolated code that supports dependency injection,
  • minimalist code that only implements what has been tested,
  • tests for each case (with the tests themselves verified),
  • squeaky-clean code with small, easy-to-read methods.

All these benefits will save more time than the time invested in TDD — and not just in the long term, but immediately.

For an example involving a full app, get the book Test-Driven iOS Development. Here's my review of the book.

Leonialeonid answered 4/12, 2012 at 21:6 Comment(7)
Excellent writeup, Jon. It does seem like it's overkill to mock out NSUserDefaults though. Why not query NSUserDefaults directly?Petrie
Christopher, I was concerned that either setting up values in NSUserDefaults (for reading) or having the method write actual values would interfere with NSUserDefaults when running the app manually. (Does that make sense? Let me know if you disagree.)Leonialeonid
Excellent post, thanks for all your work about TDD in iOS. I have a question, if in place of NSUserDefaults i have my own Singleton class (shared instance). Is this considered as a dependency injection ? if i have other class ( class that are defined bu the user, for example a model object or a UIViewControlelr) in the setReminderId method should i create a mock object for each object like your are doing for NSUserDefaults ?Beachlamar
If it's a singleton you control, you can use another form of dependency injection called Ambient Context: In setUp, have the singleton set aside its real guts with fake guts the test controls. Then in tearDown, move the real guts back into place. …I'd probably still prefer one of the other forms of dependency injection (especially Constructor Injection), but Ambient Context does become an option.Leonialeonid
@JonReid, Is this behaviour verification or Return Value verification.Austinaustina
@Austinaustina These are Interaction Tests, because they verify interactions with NSUserDefaults. You can think of it as establishing a contract between 2 parties.Leonialeonid
So I guess that's "behavior"Leonialeonid

© 2022 - 2024 — McMap. All rights reserved.