Unit Testing Best Practices on iOS with Swift
We are writing unit tests, because we want our code to work and to keep it working. Economics of Test Automation depicts a significant cost-efficiency difference between well- and poorly-written tests. Given that you’ve written Swift unit tests before, by reading this article you’ll be doing this more effectively.
If looking for an introduction to the subject, I suggest to begin with Real-World Unit Testing in Swift and then get back to this article.
The article is structured as a collections of best practices and tips. It is intended to be read from top to bottom, which determines the order: closely related subjects are placed together.
Arrange-Act-Assert
There is a well-established practice to structure a unit test around three steps:
- Initialize test data and system under test.
- Call system under test.
- Verify that output is as expected.
These steps are usually named Arrange-Act-Assert or Given-When-Then. They are pretty much expected in all unit tests. I recommend to follow this structure except for one- or two-line tests that are immediately obvious:
// ✅
func testValidEmail() {
XCTAssertTrue(EmailValidator().isValid("a@b.com"))
}
// ❌
func testInvalidEmail() {
// Arrange
let invalidEmail = "ab.com"
let sut = EmailValidator()
// Act
let result = sut.isValid(invalidEmail)
// Assert
XCTAssertFalse(result)
}
Specialize Assertions
Use the most specialized assertion available when there is such a choice. Say, if you are deciding between XCTAssert
and XCTAssertNil
, use the latter. This improves Xcode test reports and makes code more readable:
// Equality
XCTAssert(x == y) // ❌
XCTAssertEqual(x, y) // ✅
// Nil and Non-nil
XCTAssert(x != nil) // ❌
XCTAssertNotNil(x) // ✅
XCTest
is Apple system framework for unit testing. Among other things, it contains a family of assertion methods, which I highly recommend over any third-parties.
Comparing Floating Point Numbers
Floating point numbers should not be compared for equality. Instead, we should verify that they are almost equal by using some error bound:
let epsilon = 0.0001
XCTAssertEqual(x, y, accuracy: epsilon)
Floating point math is hard, hence for large values you may consider using ULP instead of
epsilon
. This article explains the problem.
Throwable Test Methods
Mark XCTestCase
methods as throwable to avoid do-catch
and force try!
in your test code. Why they are undesirable? Force try!
can crash the whole suite, so that we receive incomplete Xcode test report. The do-catch
operator introduces branching logic. You don’t want any logic in your tests, since it opens room for bugs. We’ll discuss branching in a dedicated section.
Test method will be automatically marked as failed in case error is thrown in try
:
class ItemTests: XCTestCase {
// `Item` has custom Codable implementations that we want to test
func testItemCodable() throws {
let expected = Item(id: 1)
let encoded = try JSONEncoder().encode(expected)
let decoded = try JSONDecoder().decode(Item.self, from: encoded)
XCTAssertEqual(decoded, expected)
}
}
Use Simple Values
Use simplest possible test data to make the outcome obvious to the reader. Do not randomize test data. The tests must verify that fixed input produces fixed output.
For instance, when testing square root method, do not use value for which nobody knows the answer.
func testSquareRoot() {
XCTAssertEqual(sqrt(3), 1.73205080757, accuracy: epsilon) // ❌
XCTAssertEqual(sqrt(4), 2, accuracy: epsilon) // ✅
}
Generate Mocks with Sourcery
Sourcery is my primary tool for mocking. Given a Swift protocol, it can generate mocks which are ready-to-use in your tests. It results in numerous benefits:
- This will save you tons of time on writing boilerplate code.
- It also eliminates the chance of making an error when implementing mocks manually.
- Additionally it helps to maintain a unified code style for mocks.
Fake Network Data
It’s important to control networking from your tests. Otherwise, you’ll face random errors caused by latency, connectivity, change of external contracts and much more.
For the most of the time you can get away by mocking your http client. But sometimes you cannot do this. Say, you are testing the http client itself or the code which cannot be changed (legacy, 3rd party etc).
With the help of OHHTTPStubs you can fake network data and even simulate slow networks to check how the app behaves under poor connectivity.
Shortcuts
These shortcuts are essential when writing unit tests in Xcode.
Command + U
to run all tests.Command + Option + Control + G
to run latest test.
Aim for 85% Code Coverage
Research shows that 85% is best cost-efficient code coverage. Use Xcode built-in Code Coverage tool to monitor your current level and discover coverage gaps.
Verify One Concern Per Test Method
Unit means one. Hence, a unit test must verify one concern. A concern is a single end result: a return value or change to app state. The good way to think about it: if the first assert fails, does it matter what happens to the next one? If yes, you must split the test into smaller ones.
Control Side-Effects
When we change global app data from our test, this indirectly affects the whole test suite. To prevent this from happening we must cleanup before and after each test. In our XCTestCase
class, override following methods:
setUp()
to reset initial state before individual test method. The method has it’s class counterpart which sets up initial state for all test methods from the test case.tearDown()
to cleanup after each test method finishes. The class methodtearDown()
performs final cleanup after all test methods finish.
Do Not Initialize in Setup
By default, XCTestCase
provides a setUp()
method as a single entry point for all tests. It’s common to initialize system under test with all dependencies there. I recommend against doing this. Here is why:
- The setup method indirectly couples tests. When tests are coupled, a change in one is likely to affect the others.
- Setup methods tend to grow over time as more tests are added. This makes tests unreadable and hard to manage.
- Tests receive dependencies which they don’t actually need. Such tests can break or raise compilation errors, because of changes in unrelated dependencies.
My suggestion is to use overloaded factory methods which return system under test with different configurations:
class UserStorageTests: XCTestCase {
// MARK: - Helpers
func makeSUT() -> UserStorage {
let sut = UserStorage(storage: storageMock, secureStorage: secureStorageMock)
return sut
}
func makeSUT(with user: User) -> UserStorage {
let sut = makeSUT()
sut.save(user)
return sut
}
}
Initialize Test Data in Properties
It’s common to reuse test data in different test methods. So that we do not copy and paste it, we store test data in properties of XCTestCase
classes.
Say, we are testing UserStorage
which saves sensitive data to keychain and the rest to user defaults. We follow the recommendations from Real-World Unit Testing in Swift and inject UserDefaults
and Keychain, so that they can be mocked:
class UserStorageTests: XCTestCase {
func testUsernameSavedToStorage() {
let userDefaultsMock = UserDefaultsMock()
let keychainMock = KeychainMock()
let user = User(id: 1, username: "U1", password: "P1")
let sut = UserStorage(storage: userDefaultsMock, secureStorage: keychainMock)
sut.save(user)
// Assert
}
func testPasswordSavedToSecureStorage() {
let userDefaultsMock = UserDefaultsMock()
let keychainMock = KeychainMock()
let user = User(id: 1, username: "U1", password: "P1")
let sut = UserStorage(storage: userDefaultsMock, secureStorage: keychainMock)
sut.save(user)
// Assert
}
}
Although we are testing one concern per test, this introduces another problem: the methods have lots of duplicated code. Let’s clean this up by following the best practices that we’ve discussed:
- Extract the duplicated test data and mocks into properties.
- Create factory methods to initialize the system under test with different configurations.
class UserStorageTests: XCTestCase {
let userDefaults = UserDefaultsMock()
let keychain = KeychainMock()
let user = User(id: 1, username: "U1", password: "P1")
func testUsernameSavedToStorage() {
makeSUT().save(user)
XCTAssertNotNil(userDefaults.inputUsername)
}
func testPasswordSavedToSecureStorage() {
makeSUT().save(user)
XCTAssertNotNil(keychain.inputPassword)
}
func makeSUT() -> UserStorage {
return UserStorage(storage: userDefaults, secureStorage: keychain)
}
}
An attentive reader might think that such code introduces risks, since the properties are reused in different test methods. XCTest framework source code proves this wrong, since the new XCTestCase
instance is created to run every test method.
Testing Asynchronous Code
Testing asynchronous code is hard. If you attempt to execute async code in the test, it will give a false positive, because the async code finishes after the test. We must adapt special patterns to control the execution flow of such tests. Not to repeat myself, I’ve discussed these patterns in detail in Unit Testing Asynchronous Code in Swift and Swift Asynchronous Unit Testing with Busy Assertion Pattern.
Do Not Leak Test Code into Production
Firstly, do not weaken encapsulation for the purpose of testing. If you cannot test something, because it has private access control, do not make it public. It’s either a flaw in the app design or you might be testing too much.
Secondly, do not put logic into production code which is there only to support testing. Changes in your test code should not affect the production.
Wrapping Up
Most of the discussed best practices are generic to all programming languages, and not restricted to Swift. The format of the article allows to discuss each practice in a distilled manner. If you feel like you want to learn more about iOS unit testing with Swift, the article can become a foundation for you further research.
If you enjoyed reading, you may find interesting my other articles about unit testing with Swift:
Thanks for reading!
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content. There I write daily on iOS development, programming, and Swift.