MVVM Unit Test Example Walkthrough

Zach Lucas
Humans in Space
Published in
3 min readFeb 4, 2016

If you don’t like unit testing your product, most likely your customers won’t like to test it either — Anonymous

Unit tests are hard. Unit tests in the iOS development world? Notoriously hard. But there’s hope! The MVVM architecture pattern (great introduction to MVVM here) makes unit testing much easier. Let’s walk through an example together.

Assumes working knowledge of iOS development and the MVVM pattern.

For Objective-C tests, we use Specta in conjunction with Quick, so if you’re used to XCTest, the syntax will be different but (we think!) very human readable.

We want to test a view model called LocationSearchViewModel.

The basic setup starts out like:

SpecBegin(LocationViewModelSpec)describe(@”Location View Model Spec”, ^{
__block LocationViewModel *viewModel;
});
SpecEnd

(The __block tells the compiler that the modifications done to a variable inside the block are also visible outside of the block.)

Next, we want to do some set up for our tests, in this case we want to initialize our view model with an appointment manager object. It goes like this, inside the describe block:

before(^{
AppointmentManager *appointmentManager = [[AppointmentManager alloc] init];
viewModel = [[LocationViewModel alloc] initWithAppointmentManager:appointmentManager];
});

This before block will be executed before anything else happens. This ensures that our view model is set up before we begin to use it. In more complex cases, we use stubbed-out JSON to initialize view models in place of API calls.

Now, we need to test each of the view model’s properties. We try to test “the zero case, the normal case, and the edge case”. After the before block from the last case, we add each of the view model’s properties. The properties of LocationViewModel are locationExists and placeID. So, we have two describes that look like:

describe(@”locationExists”, ^{
});
describe(@”placeID”, ^{
});

These are the properties we need to test. Let’s go inside the locationExists block. We can test this BOOL property simply by setting the appointmentManager’s locationExists property and expecting the view model to update correctly. Here’s the test:

describe(@”locationExists”, ^{
context(@”when location doesn’t exist in appointmentManger”, ^{
before(^{
appointmentManager.locationExists = NO;
});
it(@”should return NO”, ^{
expect(viewModel.locationExists).to.equal(NO);
});
});
});

You’ll notice that we didn’t test the YES case. This case is a bit more complex, as it involves waiting for the signal to propagate through the view model. That’s a relatively common occurrence in MVVM testing, and Quick offers a very easy way to test these cases: waitUntil. Here, we can wait until the signal is YES, and the test fails if YES doesn’t happen within a set amount of time (5 secs for our test).

context(@”when location exists in appointmentManger”, ^{
before(^{
appointmentManager.locationExists = YES;
});
it(@”should return YES”, ^{
waitUntil(^(DoneCallback done) {
[[RACObserve(viewModel, locationExists) filter:^BOOL(NSNumber *locationExists) {
return [locationExists boolValue];
}] subscribeNext:^(NSNumber *locationExists) {
expect(viewModel.locationExists).to.equal(@YES);
done();
}];
});
});
});

Think about how hard locationExists would’ve been to test had it been a method inside of a conventional MVC-type view controller. Splitting that single method out and testing under a variety of different circumstances would be very difficult.

Here’s the complete spec, along with another example parameter using OCMock to test NSUserDefaults:

SpecBegin(LocationViewModelSpec)
describe(@”Location View Model Spec”, ^{
__block LocationViewModel *viewModel;
__block id userDefaultsMock;
before(^{
AppointmentManager *appointmentManager = [[AppointmentManager alloc] init];
viewModel = [[LocationViewModel alloc] initWithAppointmentManager:appointmentManager];
});
describe(@”locationExists”, ^{
context(@”when location doesn’t exist in appointmentManger”, ^{
before(^{
appointmentManager.locationExists = NO;
});
it(@”should return NO”, ^{
expect(viewModel.locationExists).to.equal(NO);
});
});
context(@”when location exists in appointmentManger”, ^{
before(^{
appointmentManager.locationExists = YES;
});
it(@”should return YES”, ^{
waitUntil(^(DoneCallback done) {
[[RACObserve(viewModel, locationExists) filter:^BOOL(NSNumber *locationExists) {
return [locationExists boolValue];
}] subscribeNext:^(NSNumber *locationExists) {
expect(viewModel.locationExists).to.equal(@YES);
done();
}];
});
});
});
});
describe(@”placeID”, ^{
before(^{
userDefaultsMock = OCMClassMock([NSUserDefaults class]);
});
context(@”placeID exists and is valid”, ^{
before(^{
userDefaultsMock = OCMClassMock([NSUserDefaults class]);
PlaceID placeID = [[PlaceID alloc] init];
placeID.isValid = YES;
OCMStub([userDefaultsMock objectForKey:placeID]).andReturn(placeID);
viewModel = [[LocationViewModel alloc] initWithUserDefaults:userDefaultsMock];
});
it(@”should return YES because placeID is valid”, ^{
expect(viewModel.placeID.isValid).to.equal(@YES);
});
});
context(@”placeID exists and is not valid”, ^{
before(^{
userDefaultsMock = OCMClassMock([NSUserDefaults class]);
PlaceID placeID = [[PlaceID alloc] init];
placeID.isValid = NO;
OCMStub([userDefaultsMock objectForKey:placeID]).andReturn(placeID);
viewModel = [[LocationViewModel alloc] initWithUserDefaults:userDefaultsMock];
});
it(@”should return NO because placeID is invalid”, ^{
expect(viewModel.placeID.isValid).to.equal(@NO);
});
});
});
});
SpecEnd

--

--

Zach Lucas
Humans in Space

I like tech, politics, maps, anything with two wheels, and Oxford Commas; my favorite two words are pistachio mustache.