Introduction:
When I embarked on my coding journey, Objective-C reigned supreme, and the MVC (Massive View Controller) pattern was our foundation. It began smoothly, but as our project's ambitions grew, so did the codebase's complexity. Before we knew it, our once-smooth-sailing ship started showing signs of strain. Code changes became treacherous minefields of regressions, and bugs transformed from minor annoyances into significant roadblocks.
For the next project, our team embraced Swift and the MVVM pattern, which had gained popularity at the time. We also committed to covering every view model with unit tests. Unit testing offered us three essential benefits:
- Guaranteed Expected Behavior: Ensured the code functioned as intended.
- Confident Code Changes: Enabled confident code modifications, knowing tests would catch simple mistakes. It provides a safety net and helps identify potential issues.
- Clear Documentation: Served as clear documentation of how the system under test (SUT), in this case the view model, should operate.
Our biggest hurdle was getting started. As a small team of three developers, none of us had prior experience with unit testing. I took it upon myself to research and share my findings with the team, which is what I'm doing in this blog post.
Writing Testable Code:
The first and most crucial step is writing testable code. While this may seem challenging, we can break it down into two key questions: what constitutes testable code, and how can we hone this skill?
To illustrate what constitutes testable code, consider a Movie list app with a view model responsible for fetching data and populating a movie array. The specifics of the technology or UI framework matter less than the principle of testability.
First, we need a simple data fetching mechanism:
class NetworkClient {
static var shared = NetworkClient()
private init() {}
func fetchData<T: Codable>(for url: URL) async throws -> T {
let (jsonData, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(T.self, from: jsonData)
}
}
Next, a straightforward view model, kept as simple as possible to focus on testing:
@Observable
class ViewModel {
var movies = [Movie]()
func onAppear() {
Task { [weak self] in
let url = URL(string: "https://getmovies.com")!
do {
self?.movies = try await NetworkClient.shared.fetchData(for: url)
} catch {
print(error)
}
}
}
}
Now we need to test the ViewModel, but we encounter an initial challenge: the network client is implemented as a singleton, rendering it unreplaceable within our tests. Unit tests should prioritize speed and self-containment. Reliance on external servers is undesirable, as it introduces potential unavailability and the risk of overwhelming them with excessive requests.
Fortunately, Dependency Injection (DI) emerges as a potent solution. By injecting the NetworkClient, we gain control over it within our testing environment, effectively isolating the ViewModel from network interactions. Furthermore, to provide the unit test with comparative data, we employ mock data. This necessitates the creation of a protocol for NetworkClient, empowering us to specify the mock data we require.
protocol NetworkClientProtocol {
func fetchData<T: Codable>(for url: URL) async throws -> T
}
class NetworkClient: NetworkClientProtocol {
func fetchData<T: Codable>(for url: URL) async throws -> T {
let (jsonData, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(T.self, from: jsonData)
}
}
@Observable
class ViewModel {
let networkClient: NetworkClientProtocol
var movies = [Movie]()
init(networkClient: NetworkClientProtocol = NetworkClient()) {
self.networkClient = networkClient
}
func onAppear() {
Task {
let url = URL(string: "https://getmovies.com")!
do {
self.movies = try await self.networkClient.fetchData(for: url)
} catch {
print(error)
}
}
}
}
With the foundation of DI and mock data in place, we can proceed to construct our first test case. The objective is to verify that the onAppear function, when invoked, successfully populates the Movies list with the expected data.
func testOnAppear() {
let sut = ViewModel(networkClient: FakeNetworkClient())
sut.onAppear()
XCTAssertEqual(sut.movies, [FakeNetworkClient.movie])
}
class FakeNetworkClient: NetworkClientProtocol {
static var movie = Movie(id: 1, title: "The Karate Kid")
func fetchData<T: Codable>(for url: URL) async throws -> T {
return [Self.movie] as! T
}
}
We’ve injected the fake network client with the mock data, but despite our efforts, the test still fails. The Movies list remains empty during the assertion. If we run the app, it looks like everything is working correctly. So why doesn’t it work? The answer is - asynchronous operations and their impact on test execution.
The Task within onAppear triggers a context switch, meaning the execution flow temporarily shifts away from the test's primary thread. As a result, the assertion executes before the fetchData call completes, leading to an empty list and a failed test. Delaying the assertion with sleep or using expectations with timeouts can address this issue. However, these approaches introduce uncertainty and potential slowness, compromising test speed and reliability. And always remember - the fact that it works on your local machine does not guarantee it will work on the CI (Continuous Integration) machine!
Swift's async capabilities offer a more elegant solution. By declaring onAppear as async and shifting the Task initiation to the View layer, we can:
- Ensure Sequential Execution: The test code and onAppear function execute sequentially within the same context, eliminating premature assertions.
- Preserve Test Speed: The test avoids unnecessary delays or timeouts, maintaining its swiftness and efficiency.
Here is the new implementation:
func onAppear() async {
let url = URL(string: "https://getmovies.com")!
do {
self.movies = try await self.networkClient.fetchData(for: url)
} catch {
print(error)
}
}
func testOnAppear() async {
let sut = ViewModel(networkClient: FakeNetworkClient())
await sut.onAppear()
XCTAssertEqual(sut.movies, [FakeNetworkClient.movie])
}
Improving Test Writing Skills:
Having successfully navigated the initial challenges of testing asynchronous operations in our Movies app, we've gained valuable insights into writing testable code. Now, we turn our attention to the second crucial question: how can we continuously improve our testable code writing skills?
While the suggestion I'm about to offer may initially seem daunting, the most effective method I've encountered is Test-Driven Development (TDD). Now, before you flee in terror, allow me to reassure you: don't feel pressured to adopt TDD for everything; it's entirely your choice. Even simply understanding the principles of TDD can enhance your daily code structure.
The key aspect of TDD I want to suggest is engaging in “coding katas”. Think of them as specific coding challenges, similar to how karate katas train muscle memory for martial arts movements. By repeatedly practicing these challenges, you solidify testable code habits, naturally enhancing your skills. Each kata presents a unique problem you address multiple times, progressively improving your ability to write testable code.
The concept is straightforward, and you can delve deeper in the excellent Quality Coding site HERE by Jon Reid. As a starting point, I highly recommend the Bowling Game challenge. Dedicating just 15 minutes daily for a week, and you'll likely see a significant improvement in your testing skills by the end!
Over the years, I've interacted with many iOS developers working on codebases lacking test coverage, often unsure how to introduce it to their teams. If you find yourself in this situation, consider proposing Katas as a workshop. This allows your team to experience TDD firsthand in a collaborative setting, alleviating their concerns by emphasizing the learning aspect. Build upon this initial workshop experience as you move forward.
For teams with existing tests, incorporating Katas into the onboarding process for new developers can be immensely beneficial. Even those with limited testing experience can quickly gain proficiency through this practice.
The journey doesn't stop here! In the next post, we'll explore further strategies to enhance your testing flow. We'll eliminate the need for protocols to inject mocks in our dependencies and explore testing beyond the "happy path" scenario. Stay tuned!
Top comments (0)