How to write unit tests in iOS Part 1: XCTestCase


What is unit test?

Unit test is basically just a test at the unit level.

Your project will have multiple classes. Each class will have multiple methods. Each method will have multiple cases. And it’s in those cases that we are at the unit level.

A unit test will test for a specific case in a method. It usually has 4 steps:

  1. Setup:
    • This is where you initialize the class you want to test
  2. Execution:
    • Call method on the class or instance of the class
  3. Expectation:
    • Set some expectations for the outcome
  4. Cleanup: (optional)
    • Clean up anything that may affect other subsequent tests

Let’s look at some example unit tests here (in pseudo code):

Test the shoot method on Gun class:

  • Case 1: if it has bullet(s), the gun CAN shoot
1. Setup: create a gun with 1 bullet
2. Execution: shoot
3. Expectation: expect gun to be out of bullet
  • Case 2: if it has no bullet, the gun CANNOT shoot
1. Setup: create a gun with no bullet
2. Execution: shoot
3. Expectation: expect gun not to shoot anything

Test the reload method on Gun class:

1. Setup: create a gun with no bullet
2. Execution: reload the gun
3. Expectation: expect the gun to be full of bullets

After we finish writing unit tests for all cases in all methods of the Gun class, we can say that the Gun class is fully tested. All the edge cases are covered and we would have the confidence that this Gun class should work as expected.

But that’s just a bunch of pseudo code. How does we translate it into iOS?

Xcode does provide a way to write unit tests natively through the XCTestCase class. It provides a variety of assertion methods to help us describe our expectation for the test. Let’s dive in and see what it looks like.

First, add a new file to your test target. (if your project name is MyAwesomeProject, then your test target’s name would be MyAwesomeProjectTests)

Choose the Unit Test Case Class template > Next

Make sure you subclass from XCTestCase.

Originally, your class will look like this:

import XCTest

class MyNativeTests: XCTestCase {

  override func setUp() {
      super.setUp()
      // Put setup code here. This method is called before the invocation of each test method in the class.
  }

  override func tearDown() {
      // Put teardown code here. This method is called after the invocation of each test method in the class.
      super.tearDown()
  }

  func testExample() {
      // This is an example of a functional test case.
      // Use XCTAssert and related functions to verify your tests produce the correct results.
  }

  func testPerformanceExample() {
      // This is an example of a performance test case.
      self.measureBlock {
          // Put the code you want to measure the time of here.
      }
  }

}

For simplicity, we’ll remove everything and just put our single test there. Notice that your test method MUST begin with the prefix test. (Ex: testSomething, testFirst, testTheWorld)

import XCTest

class MyNativeTests: XCTestCase {

  func testSomething() {

  }

}

Let’s write our first expectation.

func testSomething() {
  XCTAssert(1 + 1 == 2)
}

This reads as expect the boolean expression (1 + 1 == 2) to be true or expect 1 + 1 to equal to 2. When you run this test (⌘ + U), it should pass.

But when you change it to:

func testSomething() {
  XCTAssert(1 + 1 == 3)
}

This will fail.

When the test fails, we know exactly which line it is located but we don’t know why it failed, we only see a general statement XCTAssertTrue failed, which is not very useful. Let’s fix that by adding another argument for error message.

func testSomething() {
  XCTAssert(1 + 1 == 3, "1 + 1 should equal to 2")
}

Now it looks better when it fails.

Besides XCTAssert, there are some other methods you can use for assertion, such as:

  • XCTAssertTrue(booleanExpression, errorMessage): pass when booleanExpression is true, otherwise fail with errorMessage. This one is equipvalent to XCTAssert.

  • XCTAssertFalse(booleanExpression, errorMessage): pass when booleanExpression is false, otherwise fail with errorMessage.

  • XCTAssertNil(object, errorMessage): pass when object is nil, otherwise fail with errorMessage.

  • XCTAssertEqual(object1, object2, errorMessage): pass when object1 is equal to object2, otherwise fail with errorMessage.

  • XCTAssertThrowsError(expression, errorMessage, errorHandler): pass when expression throws exception while evaluated, otherwise fail with errorMessage. We can also use the errorHandler to assert for the exception thrown. For example:

enum Error: ErrorType {
  case SomeExpectedError
  case SomeUnexpectedError
}

func functionThatThrows() throws {
    throw Error.SomeExpectedError
}

XCTAssertThrowsError(try functionThatThrows(), "some message") { (error) in
  XCTAssertEqual(error as? Error, Error.SomeExpectedError)
}

This is just some methods that I use often. For the full list, please refer to Apple documenation.

Let’s write real unit tests for the Gun example we talked about earlier.

Test the shoot method on Gun class:

  • Case 1: Gun CAN shoot if it has bullet(s).
func testGunCanShootIfItHasBullets() {
  // 1. Setup: create a gun with 1 bullet
  let gun = Gun(bullets: 1)
  // 2. Execution: shoot
  gun.shoot()
  // 3. Expectation: expect Gun to be out of bullet
  XCTAssertTrue(gun.bullets == 0, "expect Gun to be out of bullet")
}
  • Case 2: Gun CANNOT shoot if it has no bullet.
func testGunCannotShootIfItHasNoBullet() {
  // 1. Setup: create a gun with no bullet
  let gun = Gun(bullets: 0)
  // 2. Execution: shoot
  gun.shoot()
  // 3. Expectation: expect Gun to not shoot anything
  XCTAssertTrue(gun.bullets == 0, "expect the number of bullets to remain the same")
}

Both of these 2 tests will fail because we haven’t implemented the Gun class yet. Let’s go ahead and do it.

Add a new file called Gun.swift inside your main target (not inside the test target). We will do a very simple implementation here:

import Foundation

class Gun : NSObject {

  var bullets = 0

  init(bullets: Int) {
    self.bullets = bullets
  }

  func shoot() {
    if self.bullets > 0 {
      self.bullets -= 1
    }
  }

}

Go to the test class (MyNativeTests.swift) and import your main target. Without this step, all of your classes in the main target won’t be visible to the tests.

// add this line to the top of the file
@testable import MyAwesomeProject

The @testable means that you don’t need to declare your classes and methods as public anymore. All of them will be visible in the test target eventually.

Now when you run the tests (⌘ + U), they should all pass. Here is the test class at this point:

import XCTest
@testable import MyAwesomeProject

class MyNativeTests: XCTestCase {

  func testGunCanShootIfItHasBullets() {
    // 1. Setup: create a gun with 1 bullet
    let gun = Gun(bullets: 1)
    // 2. Execution: shoot
    gun.shoot()
    // 3. Expectation: expect Gun to be out of bullet
    XCTAssertTrue(gun.bullets == 0, "expect Gun to be out of bullet")
  }

  func testGunCannotShootIfItHasNoBullet() {
    // 1. Setup: create a gun with no bullet
    let gun = Gun(bullets: 0)
    // 2. Execution: shoot
    gun.shoot()
    // 3. Expectation: expect Gun to not shoot anything
    XCTAssertTrue(gun.bullets == 0, "expect the number of bullets to remain the same")
  }

}

There is still the reload method to test but I guess you can easily do it on your own since it’s quite similar to what we just did.

Wrap up

Today we learnt what a unit test is and how to write it properly in iOS using XCTestCase. We also learnt how to test an object at the unit level (test each case in each method, test all methods in the class).

Although XCTestCase is simple and quite straightforward to use, it might get really tedious as your test becomes more complicated. On the next post, we’ll discover some of XCTestCase’s problems and how we can solve it using Behaviour Driven Development.

Related Posts

Write better unit test assertions with Nimble

How to write unit tests in iOS Part 2: Behavior-driven Development (BDD)

How to setup testing for new iOS project

Hoang Tran Weekly iOS Awesomeness

Subscribe to receive great contents about various iOS development topics right into your mailbox.I do not spam. I only add values.