Testing iOS Apps — Unit Tests
A practical guide to writing unit tests in iOS using TDD principles and the XCTest framework.
Companies increasingly value developers who can write effective tests. Two primary testing techniques exist in Apple's ecosystem: Unit Tests and UI Tests, both utilizing XCTest — Apple's native framework integrated with Xcode.
Testing serves as training wheels while learning to ride a bicycle. While possible without them, tests substantially reduce the probability of failures.
The Testing Pyramid
The Testing Pyramid provides a structured approach to software testing, ensuring complete coverage and early error detection.
Unit Tests
Unit tests form the pyramid's largest layer because applications always contain more unit tests than other test types. These tests prove essential for verifying application logic, confirming that given specific inputs, code consistently produces expected outputs.
The pyramid's lower sections execute faster than upper sections. UI tests require more time than unit tests. Additionally, unit tests demand less maintenance effort compared to UI tests.
Test Driven Development (TDD)
Test Driven Development follows the "Red-Green-Refactor" cycle:
- Red: Create a failing test
- Green: Write minimal code to pass the test
- Refactor: Improve code quality and efficiency
This approach accelerates development while maintaining high code quality.
Example: Legal Age Verification
Consider determining whether a user qualifies as a legal adult based solely on birth year:
/// We test cases in which the user is of legal age.
func testLegalAge() {
XCTAssertTrue(isLegalAge(year: 1990)) // 34-year-old person
XCTAssertTrue(isLegalAge(year: 2000)) // 24-year-old person
XCTAssertTrue(isLegalAge(year: 2005)) // 19-year-old person
}
/// We test cases in which the user is a minor
func testMinorAge() {
XCTAssertFalse(isLegalAge(year: 2010)) // 14-year-old person
XCTAssertFalse(isLegalAge(year: 2015)) // 9-year-old person
XCTAssertFalse(isLegalAge(year: 2020)) // 4-year-old person
}
/// We test the limits
func testAgeLimit() {
XCTAssertTrue(isLegalAge(year: 2006)) // 18-year-old person (right at the limit)
XCTAssertFalse(isLegalAge(year: 2007)) // 17-year-old person (just below the limit)
}
Initial Implementation
The initial implementation passes some tests but fails others:
func isLegalAge(year: Int) -> Bool {
true
}
Working Implementation
func isLegalAge(year: Int) -> Bool {
let age = 2024 - year
return age >= 18
}
This implementation has two significant problems. First, the hardcoded year (2024) becomes obsolete as time passes. Second, it violates the Single Responsibility Principle by combining age calculation with majority verification.
Refactored Solution
private func calculateAge(year: Int) -> Int {
let currentDate = Date()
let calendar = Calendar.current
let currentYear = calendar.component(.year, from: currentDate)
return currentYear - year
}
func isLegalAge(year: Int) -> Bool {
calculateAge(year: year) >= 18
}
The refactored approach separates concerns: one function calculates age while another evaluates majority status, resulting in clearer, more maintainable code adhering to solid design principles.
Test Coverage
Test coverage represents the percentage of code reviewed by tests. In Xcode, view coverage through the Report Navigator. Enable visualization in the Editor by selecting "Adjust Editor → Code Coverage" to see which code sections undergo testing.
Functions not covered by tests display a red stripe with a "0" indicator. The numbers represent execution counts within tests.
Tests Not Required
Achieving maximum coverage percentage matters, yet testing functions outside your control often lacks practical value.
Consider sorting an array alphabetically:
let stringArray = ["Banana", "Apple", "Orange", "Mango", "Peach"]
let sortedArray = stringArray.sorted()
While you could write a test:
func testAlphabeticalSortedArray() {
let stringArray = ["Banana", "Apple", "Orange", "Mango", "Peach"]
let sortedArray = stringArray.sorted()
XCTAssertEqual(sortedArray, ["Apple", "Banana", "Mango", "Orange", "Peach"])
}
This test verifies the framework's built-in functionality rather than your code. If this function fails, you cannot fix its internal implementation. Therefore, testing external code you don't control typically wastes resources.
However, exceptions exist. When testing components like URLSession, CLLocationManager, or FileManager, creating protocols that define communication APIs allows developing mock implementations, covered in integration testing discussions.