Unit Test in Swift: A Starting Guide
Learn key concepts about testing and how you can start adding Unit Tests to your iOS project
Do you like fixing bugs?
I'm sure you don't.
Having a proper unit test structure will help you avoid headaches in the future.
Besides helping to make sure your code still works as expected after every change you make, unit tests have several implicit benefits, such as:
- Being more confident when shipping new code to production.
- Increasing overall code quality.
- Reducing code-base complexity.
Let’s see the key concepts behind tests and how to add them to your iOS project.
What are Unit Tests?
Just to be on the same page, we can say that unit tests are pieces of code that check if a small, isolated, and well-defined block of our application’s code (aka unit) is working as expected.
Test Structure
There are several components we need to understand before we jump in into the coding part.
- Test methods are the methods that validate portions of our code. Here is where we create and develop the test. These methods will produce a test result, either pass or fail.
- Test classes are a set of test methods. Usually, they group a specific logic to test, for example: Authentication.
- Test bundles contain a group of test classes and represent one of two test types: Unit or UI.
- Test plans are a group of test bundles, so we can include both Unit and UI tests here. In the test plans, we set up a list of configurations to be considered when running tests.
I will be using the code of my Coffee Shop Demo, you can check the source code here
We start by creating a Test Bundle
Go to File > New > Target
and search for Unit Testing Bundle. Give the target a proper name, typically your app name ending with Tests.
By creating a test bundle, a test plan will be automatically created for us with default configurations.
Setting up our Unit Tests
Every programming language has a framework or library to execute unit tests. In the iOS development world, we have XCTest framework.
Now that we have our test bundle, we can add a new test class. Create it from the navigation panel or the menu (File > File > Unit Test Case Class
).
Notice that our test class is a subclass of XCTestCase. This class provides us with all the operations needed to write and execute unit tests.
We want to test the logic behind the OrderManager class, so a good name for our test class could be OrderTests
.
// Add a new order
func add(_ order: Order) throws { ... }
// Repeat the last order, adding it to the list of orders
func repeatLastOrder() { ... }
Let’s create a test method for each function inside our test class.
To access our app’s code from our test target without going file by file and sharing them, we need to add @testable instruction and import our app.
import XCTest
@testable import Coffee_Shop_App
final class OrderTests: XCTestCase {
override func setUpWithError() throws {}
override func tearDownWithError() throws {}
func testAddNewOrder() {}
func testRepeatLastOrder() {}
}
Before each test starts, XCTest executes setUpWithError()
function. So here we need to set the initial state for our tests. This could be creating instances of specific classes, injecting dependencies, configuring some variable values, and so on.
On the other side, after each test ends, tearDownWithError()
is called. This is a good place to clean up anything that we consider.
Method test names should start with the word test to be executed.
Now we have everything in place to start creating our Unit Tests
First, we need to access OrdersManager
.
- Add a property to the class.
- Instance it in
setUpWithErro()
method. - Cleanup the state in
tearDownWithError()
method.
We’re using the Singleton pattern in the demo just for simplicity.
@testable import Coffee_Shop_App
final class OrderTests: XCTestCase {
private var ordersManager: OrdersManager!
override func setUpWithError() throws {
ordersManager = OrdersManager.shared
}
override func tearDownWithError() throws {
ordersManager.removeAllOrders()
ordersManager = nil
}
func testAddNewOrder() {}
func testRepeatLastOrder() {}
}
If you’re getting started with unit tests, using the pattern Arrange / Act / Assert would be a good initial point. The idea is to break down the tests into those steps:
- Arrange → Create all the necessary objects or data that the test will use.
- Act → Execute the method or function you want to test.
- Assert → Compare the results that you get with the expected ones.
final class OrderTests: XCTestCase {
private var ordersManager: OrdersManager!
override func setUpWithError() throws {
ordersManager = OrdersManager.shared
}
override func tearDownWithError() throws {
ordersManager.removeAllOrders()
ordersManager = nil
}
func testAddNewOrder() {
// 1 - Arrange
let orderItems: [OrderItem] = [
.init(item: AnyMenuItem(Coffee.flatwhite), size: .regular, quantity: 1),
.init(item: AnyMenuItem(Food.chickenSandwich), size: .regular, quantity: 1),
]
let order = Order(items: orderItems)
// 2 - Act
try? ordersManager.add(order)
// 3 - Assert
XCTAssertEqual(ordersManager.orders.count, 1)
}
func testRepeatLastOrder() {
// 1 - Arrange
let orderItems: [OrderItem] = [
.init(item: AnyMenuItem(Coffee.flatwhite), size: .regular, quantity: 1),
.init(item: AnyMenuItem(Food.chickenSandwich), size: .regular, quantity: 1),
]
// 2 - Act
let order = Order(items: orderItems)
try? ordersManager.add(order))
ordersManager.repeatLastOrder()
// 3 - Assert
XCTAssertEqual(ordersManager.orders.count, 2)
}
}
Running Unit Tests directly from Xcode
We can run all tests included in a configuration set, all tests included in a test bundle, all tests included in a test class, a group of specific test methods, or just one test method at a time.
To run all tests, click on Product > Test. From the Test navigator, we can run all tests, a specific group, or just one.
Once we’ve run them, we will get a test report with the results. In addition, we can see our test results directly from the Test navigator and inside the test classes.
Test coverage
After we run our tests, we can enable test coverage information inside the code that has been tested.
In green, we see the portion of code that was reached in the last test run, and in red we see the portion of code that wasn’t. In the demo, we’re missing the throwing part, which is a consequence of checking the minimum amount for ordering.
If we notice this scenario, we could add a new unit test to check this and run the tests again.
Using different XCTAssert
There are a bunch of functions we can use when asserting test results. Here are a few examples.
You can check Apple’s documentation for the full list.
// 1 - Check that a throw function doesn't throw
XCTAssertNoThrow(try ordersManager.add(order))
// 2 - Check that a throw function, throws an error
XCTAssertThrowsError(try ordersManager.add(order)) { error in
if let appError = error as? AppError, let errorType = appError.type as? OrderError {
XCTAssertEqual(errorType.code, 2)
} else {
XCTFail("Wrong error type triggered")
}
}
// 3 - Check that an optional value is not nil
let lastCoffeeDescription = ordersManager.getLastCoffee()
XCTAssertNotNil(lastCoffeeDescription)
Add a custom fail message to every assertion.
XCTAssertEqual(ordersManager.orders.count, 1, "Order was not added correctly")
Things to keep in mind
- It is always better to spend time writing tests than fixing bugs.
- Don’t see unit tests as an extra workload. Try to add them as part of your development workflow.
- Have in mind the FIRST principle. Every unit test should be Fast, Independent, Repeatable, Self-validating, and Timely.
- When a test fails, be careful, you’ll be tempted to adapt your tests. And while these may be right in some cases (if the test was written wrong), usually is the logic that you’re testing that is wrong.
- Don’t aim for 100% test coverage. You could have 100% coverage, and yet have tests for 1 of 10 possible scenarios. Instead, focus on writing quality tests.
- Use tests as an opportunity to find small code refactors that will increase your overall code quality.