A walkthrough of testing a class
iOS hasn't always been the most unit-test friendly environment, but since Xcode 4 this has changed - a lot of the setup has now been automated away for us. These improvements make adding unit tests to your application easier than ever. However, the question remains: What should be tested?
What needs to be tested?
There is no generic answer to that question instead it always needs to be answered in the context of an example:
.h
typedef NS_ENUM(NSUInteger, UTEPersonGender)
{
UTEPersonGenderMale = 0,
UTEPersonGenderFemale = 1
};
@interface UTEPerson : NSObject
@property (nonatomic, copy) NSString *firstname;
@property (nonatomic, copy) NSString *surname;
@property (nonatomic, assign) NSInteger age;
@property (nonatomic, assign) UTEPersonGender gender;
- (id)initWithFirstname:(NSString *)firstname surname:(NSString *)surname age:(NSInteger)age gender:(UTEPersonGender)gender;
@end
.m
@implementation UTEPerson
#pragma - Init
- (id)initWithFirstname:(NSString *)firstname surname:(NSString *)surname age:(NSInteger)age gender:(UTEPersonGender)gender
{
self = [super init];
if (self) {
if ([firstname length] == 0) {
NSLog(@"firstname is nil...");
return nil;
}
if ([surname length] == 0) {
NSLog(@"surname is nil...");
return nil;
}
if (age < 0) {
NSLog(@"age is 0...");
return nil;
}
if (gender != PersonGenderMale && gender != PersonGenderFemale) {
NSLog(@"gender is nil...");
return nil;
}
_firstname = firstname;
_surname = surname;
_age = age;
_gender = gender;
}
return self;
}
@end
UTEPerson
is our system's representation of a person. As you can see to be a considered a person, you need to have a firstname, surname, age and gender.
Before we get to writing any tests, take a moment to look through initWithFirstname:surname:age:gender:
again and write down any possible tests that you can think of.
I'll wait 😴.
Are all done?
Super 💃.
Ok, now that you've had the chance to think about it, let's list the possible paths:
- Create a valid person and check that that person is non-nil
- Create a valid person and check firstname assignment
- Create a valid person and check surname assignment
- Create a valid person and check age assignment
- Create a valid person with age equaling zero and check age assignment
- Create a valid person with gender equaling male and check gender assignment
- Create a valid person with gender equaling female and check gender assignment
- Zero-length for the firstname parameter
- Nil for firstname parameter
- Zero-length for the surname parameter
- Nil for surname parameter
- Minus value for **age **parameter
- Invalid value for gender
The above list covers all possible paths through initWithFirstname:surname:age:gender:
- happy (results in an UTEPerson
instance being returned) and unhappy (results in nil
being returned) paths. With unit testing, it's easy to fall into the trap of only focusing on the happy paths, but unhappy paths are just as important.
Turning the list into tests
If you speak to unit test purist they will tell you that rule is:
one unit test, one assert
While this has merit, I believe that the rule should be to ensure that our unit tests have cohesion. If you want to have more than one assert in your unit test, you need to ensure that the unit test still has one purpose.
So let's implement the above paths into unit tests.
@interface UTEPersonTest : XCTestCase
@end
@implementation UTEPersonTest
#pragma mark - Tests
- (void)testInitializationSuccessfulWithValidPersonBeingReturned
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"appleseed" age:26 gender:PersonGenderMale];
XCTAssertNotNil(person, @"This person object should not be nil");
}
@end
Woohoo! First unit test, out of the way!
I always like to start with happy paths test cases as its good for my morale. Starting with the happy paths also helps to avoid the scenario where if you start with an unhappy path and it passes you need to double-check: is it passing because it fails as expected or is it passing because it's broken. With the happy paths written (and passing) we do that the method isn't broken.
Let's look into the structure of the unit test and determine what is actually doing. The assert XCTAssertNotNil
is used when we want to check that an object is not nil. XCTAssertNotNil
takes the object under test and a description. The description parameter is printed out to the console upon failure and should describe what has caused the failure in a human-readable manner. It can take a variable set of parameters in the same manner that stringWithFormat does.
Let's crack on and complete the other unit tests.
I'll wait while you complete them.
By the end your UTEPersonTest.m
should contain these methods (or something similar to them):
@implementation UTEPersonTest
// Omitted tests
- (void)testInitializationSuccessfulWithFirstnameParameterBeingCorrectlyAssigned
{
NSString *firstname = @"johnny";
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:firstname surname:@"appleseed" age:26 gender:PersonGenderMale];
XCTAssertEqual(firstname, person.firstname, @"Person's firstname should be: %@ instead it is: %@", firstname, person.firstname);
}
- (void)testInitializationSuccessfulWithSurnameParameterBeingCorrectlyAssigned
{
NSString *surname = @"appleseed";
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:surname age:26 gender:PersonGenderMale];
XCTAssertEqual(surname, person.surname, @"Person's surname should be: %@ instead it is: %@", surname, person.surname);
}
- (void)testInitializationSuccessfulWithAgeParameterBeingCorrectlyAssigned
{
NSInteger age = 26;
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"appleseed" age:age gender:PersonGenderMale];
XCTAssertEqual(age, person.age, @"Person's age should be: %ld instead it is: %ld", (long)age, person.age);
}
- (void)testInitializationSuccessfulWithAgeParameterBeingZeroAndCorrectlyAssigned
{
NSInteger age = 0;
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"appleseed" age:age gender:PersonGenderMale];
XCTAssertEqual(age, person.age, @"Person's age should be: %d instead it is: %d", age, person.age);
}
- (void)testInitializationSuccessfulWithGenderParameterBeingCorrectlyAssigned
{
UTEPersonGender gender = PersonGenderMale;
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"appleseed" age:26 gender:gender];
XCTAssertEqual(gender, person.gender, @"Person's gender should be: %d instead it is: %d", gender, person.gender);
}
- (void)testInitializationUnsuccessfulWithFirstnameBeingNil
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:nil surname:@"appleseed" age:26 gender:PersonGenderMale];
XCTAssertNil(person, @"This person object should be nil");
}
- (void)testInitializationUnsuccessfulWithFirstnameBeingOfZeroLength
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"" surname:@"appleseed" age:26 gender:PersonGenderMale];
XCTAssertNil(person, @"This person object should be nil");
}
- (void)testInitializationUnsuccessfulWithSurnameBeingNil
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:nil age:26 gender:PersonGenderMale];
XCTAssertNil(person, @ "This person object should be nil");
}
- (void)testInitializationUnsuccessfulWithSurnameBeingOfZeroLength
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"" age:26 gender:PersonGenderMale];
XCTAssertNil(person, @"This person object should be nil");
}
- (void)testInitializationUnsuccessfulWithAgeBeingANegativeValue
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"appleseed" age:-26 gender:PersonGenderMale];
XCTAssertNil(person, @"This person object should be nil");
}
- (void)testInitializationUnsuccessfulWithGenderBeingNeitherMaleNorFemale
{
UTEPerson *person = [[UTEPerson alloc] initWithFirstname:@"johnny" surname:@"appleseed" age:26 gender:34];
XCTAssertNil(person, @"This person object should be nil");
}
@end
It's important to note that in the above happy paths we are not testing to see that assigning to a property works properly - we are testing that the parameters are being assigned to the correct properties. If you ever find yourself in a situation where you are testing framework or third-party libraries, then your test case is invalid.
Time to move on
Congratulations on writing your first unit tests 🥳. Go away, have some tea, and when you're ready, pick your next untested class and write some unit tests for it.