iOS Unit Tests Basics

Photo of Patryk Strzemiecki

Patryk Strzemiecki

Updated Mar 13, 2023 • 10 min read
woman in glasses

Similar to code implementation, unit testing suites may be approached in many different ways.

Let's focus on two main assumptions here:
  • We’re interested in more than just covering the implementation with tests.

  • We want to use native testing methods only.

What do I mean by not focusing on coverage only?

Imagine being new to a project and feeling lucky that the code is unit tested. You’ll be able to know when you break something while changing the implementation and that's the main idea of unit test coverage. But from my experience, tests are often unreadable from the perspective of someone new to a project. But when tests are well-written, they can be a part of the project documentation!

Main assumptions:

  • Tests should conform to the F.I.R.S.T. principle (Fast, Independent, Repeatable, Self-validating and Thorough).

  • We try to cover all behaviors of the tested object (BDD in practice).

  • We do not focus on its appearance (unless regulated by the input configuration).

  • We expect the dependencies we use to be already tested.

Below I try to:

  • Present my way of using the BDD (Behavior-driven development) approach in unit tests suites to make them more readable.

  • Explain the basics of a good looking test suite class with some tips.

  • Present the use of the Stub, Spy, and Mock abstractions.

Basics

No matter what you are testing with the XCTest framework, you’ll need two imports above the class definition.

import XCTest //must use XCTest in native iOS testing

@testable import CleanLogin //my test target name for the test to know the app implementation

Next steps will differ depending on the architecture used in the implementation, but the idea is similar no matter if it's MVVM, CleanSwift or VIPER etc.

All the examples that I use in this guideare available here.

final class LoginSceneViewControllerTests: XCTestCase {
private var sut: LoginSceneViewController! // sut - system under test
}

The class name is the test suite name.

It does not need to be the same as the class that will be covered by the suite. But it's highly recommended to name so a person using this suite will know what it covers. In my example, the suite covers all test cases for the View Controller. But if you would like to split it into multiple classes to test every responsibility in a different file, feel free to do it by naming the class in a more descriptive way.

The SUT convention will solve the ‘what does this suite cover’ dilemma. System under test is the property defining what the test class actually covers.

Every element needed for the test should be initialized in the setUp method and deinitialized in the tearDown method to be sure that the test suite can perform the cases in random order with a clean state for every case. We don’t want to keep any state between cases unless it's necessary.

override func setUp() {
super.setUp()

sut = LoginSceneViewController()
}

override func tearDown() {
sut = nil

super.tearDown()
}

How to make an implementation testable?

  • Use dependency injection.

  • Use protocols.

  • Pass singletons as dependencies.

  • Use the Single Responsibility pattern.

Other tips:

  • To make tests more readable, use single test case responsibilities.

  • The test case should test a single condition or a group of related assertions to make the cases easy to read and maintain.

  • For test behaviors that will be reused, use private methods or extensions of suites.

Mock, Stub, Spy

All the dependencies for our SUT should be passed as Mocks. When the implementation is testable (uses protocols), it will be possible for the mock to conform to the protocol and the test class will contain and pass the mock to the SUT. It's very helpful as we can easily pass the fake data needed for our test case as a Stub and find out if the proper output was generated with the use of Spy.

In CleanSwift architecture I try to keep Mocks, Spies and Stubs in the same file as the test suite, but they can be reused between suites, so feel free to move them to separate files if you like.

To better understand how Mocks, Stubs and Spies work, take a look at my LoginSceneAuthLogicMock.

private final class LoginSceneAuthWorkerMock: LoginSceneAuthLogic {
var makeAuthStub: Result<CleanLoginUser, LoginSceneAuthWorker.LoginSceneAuthWorkerError>?
var makeAuthCalled = false

func makeAuth(completion: @escaping (Result<CleanLoginUser, LoginSceneAuthWorker.LoginSceneAuthWorkerError>) -> Void) {
makeAuthCalled = true
if let stub = makeAuthStub {
completion(stub)
}
}
}

LoginSceneAuthLogic consists of only one makeAuth method.

To test if the SUT called this method in a test case, first we need to initialize the mock in the setUp method, assign it to the variable in our test class, pass it as a dependency to the SUT, and remember to deallocate it in the tearDown method of the suite.

After that to test if the method was called, just check the makeAuthCalled property value - this is our spy.

func test() {
sut.tryToLogIn()

XCTAssertTrue(worker.makeAuthCalled)
}

If you need not only to check if the method was called but also produced suitable output, use the stub makeAuthStub to pass the necessary data. More examples of Spies, Stubs, and Mocks are in my repo here.

Depending on where you search for an answer, there are different definitions of the unit test helpers patterns, but the basics should be clear.

Mock - Superset of stubs and spies. Mocks the interfaces used in a system under test. (ex. Your class expects a dependency that conforms to a protocol; instead of passing the default implementations, make a mock conforming to that protocol and pass the mock inside a test suite).

Stub - Provides answers. Can be used to create the needed behavior of a mock. (ex. You need a method in a mock to return a specific value, in the mock implementation use the stub as a default result).

Spy - Caches the behaviors. Can record and keep states that will be used to validate the test. (ex. Save to spy variable that a method in a mock was called to set the assertion on the result of the spy).

Test cases in BDD

To make unit tests cases (methods in test class) highly readable, let's use the BDD approach. There are two ways to do it.

func test_givenSomeSut_whenDoSth_thenAIsTrue() {
sut.setup() // can be in the setUp method or in test case
doSth() // the actual behaviour
XCTAssertTrue(a) // check the result
}

or

func testAIsTrueWhenDoSth() {
//GIVEN some Sut
sut.setup() // can be in the setUp method or in test case
//WHEN do sth
doSth() // the actual behaviour
//THEN A is true
XCTAssertTrue(a) // check the result
}

I prefer to use the first approach despite the fact that the method name is very long. I prefer not to use comments as they are hard to maintain. But you pick your way, both are fine and clearly explain what happens in the app.

GIVEN - the setup of a single case describes what we need to run the test.

WHEN - the behavior that we call to get the result.

THEN - result that we check.

Common mistakes you should avoid:

  • Large test cases (methods) - a case should test only one thing or multiple related things and be easy to read.

  • Not reusing test methods - setting up mocks and the ‘given’ parts of cases should be moved to reusable private methods of the suite, consider creating an extension of XCTestCase if possible.

  • Unreadable tests - cases should clearly explain what happens inside.

  • Testing only for coverage - spending time on creating unit tests may be fun, but 100% coverage is not always necessary and wasting time to cover all initializers and edge cases may take a lot of time, but not improve the app.

  • Not clearing the state of dependencies - remember to always use setUp to initialize and tearDown to deinitialize all the mocks to be sure that the state for every test case was reset.

  • Faulty mocks - always think what the mock should actually do, try to use a new mock for every dependency for consistency.

  • Suites running forever - use Execution Time Allowance and always try to set Expectation execution time to 0.1.

Summary

This article is a good start to unit testing, feel free to use any ideas you found here in your desired way. Remember the method described above isn’t the only way to go and there are a lot of different approaches. I just wanted to explain some things that you should consider while creating unit test suites.

The most important parts are to always try to make tests simple, readable, and think about who will read your tests in a few months or years and how to help this person in advance. How you do this depends on you, but it's always nice to use common patterns instead of reinventing the wheel.

Photo of Patryk Strzemiecki

More posts by this author

Patryk Strzemiecki

Senior iOS Developer
Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business