Unit Testing in iOS Development
Unit testing is an essential part of software development, ensuring each part of your code operates as anticipated. As we venture into the realm of iOS development, unit testing remains a cornerstone of efficient, reliable software creation. Since the introduction of SwiftUI, Apple’s innovative, declarative UI framework, the process of writing unit tests has become even more intuitive and critical. In this article, we aim to explore the essentials of unit testing in iOS, focusing particularly on SwiftUI applications.
What is Unit Testing?
Unit testing involves testing individual components or ‘units’ of a software. The purpose is to validate that each unit of the software performs as designed. In software terms, a unit could be a method, function, procedure, etc. For iOS development, this could mean testing a single function or method within your View
, ViewModel
, or Model
.
XCTest Framework
XCTest is a robust testing framework provided by Apple, enabling developers to write and perform unit tests for iOS applications. It offers a comprehensive set of APIs to define tests, run them, and measure their execution performance.
To add a test case, you create a new Swift file that inherits from XCTestCase
, then generate methods within this class that start with the word "test". XCTest will automatically identify these as unit test methods.
SwiftUI Unit Testing Example: Counter App
Consider a simple SwiftUI counter app that has a label displaying the count and two buttons to increase and decrease the count.
Here’s how you might structure your ViewModel
:
import SwiftUI
class ViewModel: ObservableObject {
@Published var count: Int = 0
func increaseCount() {
count += 1
}
func decreaseCount() {
count -= 1
}
}
We would then use this ViewModel in our ContentView
:
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
Text("\(viewModel.count)")
.font(.largeTitle)
HStack {
Button {
viewModel.decreaseCount()
} label: {
Text("-")
.padding(.horizontal)
}
.tint(.pink)
Button {
viewModel.increaseCount()
} label: {
Text("+")
.padding(.horizontal)
}
.tint(.green)
}
.buttonStyle(.borderedProminent)
.font(.title2.bold())
}
.padding()
}
}
Our Counter application is now functional. Next, we need to write our unit tests. We’ll generate tests to verify our increment and decrement functions are performing correctly.
Create a new target of type “Unit Testing Bundle”
Make sure the target is selected
You should now have a new target and a new file called SimpleCounterTests.swift
you can delete testPerformanceExample()
and testExample()
for now, and import your app to have access to its classes and views:
import XCTest
@testable import SimpleCounter
final class SimpleCounterTests: XCTestCase {
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
}
Declare new property inside the class called viewModel
and initialize it inside setUpWithError()
. In tearDownWithError()
assign nil
to viewModel
to reset its value. Your file should look something similar to:
import XCTest
@testable import SimpleCounter
final class SimpleCounterTests: XCTestCase {
// Implicitly unwrapped (!)
// because by the time our test case has set it up,
// we can guarantee it will be initialized inside setUpWithError()
var viewModel: ViewModel!
override func setUpWithError() throws {
try super.setUpWithError()
viewModel = ViewModel()
}
override func tearDownWithError() throws {
viewModel = nil
try super.tearDownWithError()
}
}
Next the fun part!, we need to write our unit tests. Add two new functions with names starting with a test
(so Xcode can recognize them as Unit Tests), the first function will increase the count by 1 and verifies that the new count equals to 1, the second one will increase the count by 3 times, and decrease 1 time, can you guess what the value of “count” now?
Here is how the entire file should look like by now:
import XCTest
@testable import SimpleCounter
final class SimpleCounterTests: XCTestCase {
// Implicitly unwrapped (!)
// because by the time our test case has set it up,
// we can guarantee it will be initialized inside setUpWithError()
var viewModel: ViewModel!
override func setUpWithError() throws {
try super.setUpWithError()
viewModel = ViewModel()
}
override func tearDownWithError() throws {
viewModel = nil
try super.tearDownWithError()
}
func testIncreaseCount() {
viewModel.increaseCount()
XCTAssertEqual(viewModel.count, 1, "Count was not incremented correctly")
}
func testDecreaseCount() {
for _ in 0..<3 {
viewModel.increaseCount()
}
viewModel.decreaseCount()
XCTAssertEqual(viewModel.count, 2, "Count was not decremented correctly")
}
}
To run these tests, simply press command + U in Xcode, or select “Test” from the “Product” menu. All tests should pass (hopefully) and you should see green checkmarks next to the class name and next to each test function.
Pro Tip: You can click on any green checkmark to run individual test cases.
In our example, we used XCTAssertEqual
to verify “count” after increasing and decreasing its value, XCTest framework has many more assertions to explore, here is a list of the most commonly used:
XCTAssert(condition, message)
: This is the most basic assertion. It verifies that the condition istrue
. If not, it fails and logs an error message.XCTAssertEqual(a, b, message)
: This assertion verifies thata
equalsb
. Ifa
does not equalb
, it fails and logs an error message.XCTAssertNotEqual(a, b, message)
: This assertion verifies thata
does not equalb
. Ifa
equalsb
, it fails and logs an error message.XCTAssertNil(a, message)
: This assertion verifies thata
isnil
. Ifa
is notnil
, it fails and logs an error message.XCTAssertNotNil(a, message)
: This assertion verifies thata
is notnil
. Ifa
isnil
, it fails and logs an error message.XCTAssertTrue(condition, message)
: This assertion verifies that thecondition
istrue
. If thecondition
isfalse
, it fails and logs an error message.XCTAssertFalse(condition, message)
: This assertion verifies thatcondition
isfalse
. Ifcondition
istrue
, it fails and logs an error message.XCTFail(message)
: This assertion always fails and logs an error message.
In all of these, the message
parameter is optional and used to provide additional context if the test fails.
Remember that while these XCTest assertions are powerful, they’re not a substitute for careful design and thoughtful error handling in your code. They’re tools to help catch and identify issues, not to solve them.
Happy Testing!