Why Mocking Matters in iOS Unit Testing
Here are my other articles of the Testing Flow in iOS :
- Testing in iOS: From Zero to Hero!
- Unlocking the Power of UI Testing in iOS Development
- Why Mocking Matters in iOS Unit Testing (Current)
- iOS Dev Must-Have: Snapshot Testing
- Why to Unit Test in Async Mode
Imagine you’re building an iOS app, and it needs to fetch data from the internet, which takes a long 5 seconds each time you test. But, in your app, there are many places where this happens. Now, when you want to test your code, waiting for these slow downloads can be frustrating. To avoid this, we use a cool trick called mocking.
Mocking means we fake the internet service and make our tests run super fast on our own computer. It’s like playing with toy data instead of waiting for the real stuff. So, you can quickly check if your code works without getting bored waiting for the internet. It’s like a shortcut to happy and speedy testing!
In this article, we’ll talk about why pretending or “mocking” is a big deal when testing stuff in iOS apps. So, let’s dive into why this is so awesome! 😄
What is Mocking?
Mocking is a testing technique used to isolate and evaluate individual components of your code. In iOS development, this typically means creating mock objects to stand in for real objects or dependencies that your code interacts with. These mock objects mimic the behaviour of the real ones but allow you to control and observe interactions during testing.
The cool part is we can watch and control what happens when we test our code. It’s like having a test double that does what we want.
The Need for Mocking
Isolation and Control
Unit tests are designed to focus on the behavior of a single unit or component of your code, such as a function, method, or class. Mocking is essential for isolating this unit from its dependencies. By replacing these dependencies with mock objects, you can precisely control the conditions under which your unit is tested. This isolation ensures that any failures are due to issues within the unit itself and not its dependencies.
Predictable Testing
Mocking allows you to simulate various scenarios, edge cases, and error conditions. You can program these mock objects to produce specific responses, errors, or exceptions, ensuring that your code handles these situations correctly. This predictability is critical for verifying that your unit functions as expected under different circumstances.
Speed and Efficiency
Real dependencies, like network requests or databases, can be slow, unreliable, or costly to access during testing. By using mock objects, you eliminate the need to interact with these resources, making your unit tests faster and more efficient. This means you can run tests frequently during development without incurring the overhead of real-world dependencies.
How to Implement Mocking in iOS Unit Testing
Alright, enough talk! Let’s roll up our sleeves, dive into the code, and have some fun getting our hands a bit dirty. 😄
Code Safari: Let’s look what we have
We’ve got a button on the main screen that, when you press it, downloads a photo and shows it on the screen. To make it more exciting, we’ll even add a little nap for our app, making it sleep for 5 seconds!
class NetworkService {
func loooongNetworkCall(_ completion: @escaping (Result<UIImage, Error>) -> ()) {
sleep(5)
// Open source photo from https://unsplash.com
let request = URLRequest(url: URL(string: "https://unsplash.com/photos/XVoyX7l9ocY/download?ixid=M3wxMjA3fDB8MXxzZWFyY2h8OHx8c2NvdGxhbmR8ZW58MHx8fHwxNjk3NTA5MTQ4fDA&force=true&w=640")!)
let task = URLSession.shared.dataTask(with: request) { data, _, error in
if let data, let image = UIImage(data: data) {
completion(.success(image))
} else if let error {
completion(.failure(error))
}
}
task.resume()
}
}
This piece of code is like a recipe for your app to do something special. It’s all about getting a picture from the internet and showing it on your screen. But, to make things interesting, it pretends to take a little nap for 5 seconds.
Here’s what’s happening:
- We have a function called
loooongNetworkCall
, which means it's going to take some time to finish. It needs a special helper (the completion handler) to tell us when it's done. - The code first pretends to sleep for 5 seconds. It’s like your app is taking a quick snooze.
- Then, it sends a request to the internet to fetch a picture. The URL in the code tells it where to find the picture online.
- After a while, when it gets a response, it checks if everything went well. If it did, it gives us the picture. If something went wrong, it tells us there was an error.
So, this code is like a little adventure where our app takes a 5-second nap, goes on the internet to find a picture, and then either shows you the picture or says, “Oops, something didn’t go as planned!”
Today’s SUT (system under testing) is DeepOceanViewModel
class DeepOceanViewModel: ObservableObject {
@Published var uiImage: UIImage?
@Published var error: Error?
private let networkService = NetworkService()
func downloadWallpaper() {
networkService.loooongNetworkCall { [weak self] result in
guard let self else { return }
switch result {
case .success(let uiImage):
DispatchQueue.main.async {
self.uiImage = uiImage
}
case .failure(let error):
self.error = error
}
}
}
}
When you tap the button, it tells the app to download an image from the internet using the NetworkService
. Once the download is complete, it will either display the image if it was successful or show an error message if something went wrong.
For testing purposes, we’ll create a pretend version of NetworkService
to make sure everything works correctly without needing to access the internet. This way, we can check if the app behaves as expected when you press the button.
What is a plan?
Having a handy plan in your hand is like having a cheerful road map to guide you through your adventure. Let’s write our steps:
- We’ll begin by making a set of rules (a protocol) for both the real
NetworkService
and our pretend version calledMockNetworkService
. - Then, we’ll change the way we set up the
DeepOceanViewModel
so we can easily put in the pretend service from the outside. - Next, we’ll create our pretend
MockNetworkService
and teach it how to act. - We’ll get busy writing tests to check if everything works as expected.
- And, when we’re done, we’ll tidy up and make sure everything is nice and clean.
This plan helps us make our code better and more reliable!
Step 1: Why we need a protocol?
We’re adding protocols to our app for a specific reason: so that both our real NetworkService and the pretend MockNetworkService speak the same language and follow the same rules. This way, we can switch between them without any trouble, like swapping parts in a toy set, thanks to a cool concept called “polymorphism” in programming.
protocol AnyNetworkService {
func loooongNetworkCall(_ completion: @escaping (Result<UIImage, Error>) -> ())
}
In this code, we’re defining a protocol called AnyNetworkService
that sets the rules for any network service that wants to conform to it.
Here’s what it’s saying:
- It requires a function called
loooongNetworkCall
that takes a completion handler as a parameter. - This
loooongNetworkCall
function should accept a closure that takes aResult
object, which contains either anUIImage
(if everything goes well) or anError
(if there's a problem).
By creating this protocol, we’re establishing a common set of rules that any network service must follow. This makes it easier to use different network services interchangeably in our code, as long as they adhere to these rules. It’s all about making our code flexible and consistent!
And eventually, we’ll make sure that our NetworkService
follows the rules laid out in the AnyNetworkService
protocol. In other words, NetworkService
will promise to do what the protocol requires:
class NetworkService: AnyNetworkService {
...
}
Step 2: Injecting AnyNetworkService
into DeepOceanViewModel
class DeepOceanViewModel: ObservableObject {
...
private let networkService: AnyNetworkService
init(networkService: AnyNetworkService) {
self.networkService = networkService
}
...
}
In this updated DeepOceanViewModel
code, we're introducing a new way to set up the networkService
by accepting it as a parameter in the view model's constructor (init method). Here's what's happening:
- We declare a private property
networkService
of the typeAnyNetworkService
. This means we can use any object that conforms to theAnyNetworkService
protocol as our network service. - In the constructor (
init
), we require the caller to provide an object that follows theAnyNetworkService
protocol as an argument. This is often referred to as "dependency injection." By doing this, we make it flexible to use either the real network service or a mock version (likeMockNetworkService
) depending on the context.
This change allows us to easily switch between different network services while creating the DeepOceanViewModel
.
Step 3: Crafting MockNetworkService Behaviour
In this chapter, we’re diving into the world of creating a “pretend” version of our network service, known as the MockNetworkService
. The goal here is to make it act just like a real network service but under our complete control. Why do we need this? Well, it's all about making testing easier and more efficient.
We’ll be wearing our developer hats and teaching the MockNetworkService
how to respond to our requests. For example, we'll make it return images or errors as if they're coming from a real network. This way, we can mimic different scenarios and see how our app behaves, all without waiting for the real internet.
Think of it as setting up a virtual playground where we can create various situations and test how our app handles them. So, let’s roll up our sleeves and start crafting the behavior of our pretend network service!
To begin, open your unit test directory and create a new folder called “MockServices.” Inside this folder, create a Swift file named “MockNetworkService”. Here’s what it should look like:
And replace code inside with the following:
import XCTest
@testable import MyOceanTests
final class MyOceanTestsTests: XCTestCase {
// 1
var sut: DeepOceanViewModel!
var mockNetworkService: MockNetworkService!
override func setUp() {
super.setUp()
// 2
mockNetworkService = MockNetworkService()
sut = DeepOceanViewModel(networkService: mockNetworkService)
}
override func tearDown() {
// 3
sut = nil
mockNetworkService = nil
super.tearDown()
}
}
- Here, we’re setting up our test case for
DeepOceanViewModel
. We declare two properties,sut
(System Under Test, which is the view model we're testing) andmockNetworkService
(our pretend network service). - In the
setUp
method, which runs before each test, we initialize ourmockNetworkService
by creating an instance ofMockNetworkService
. We also set up oursut
(DeepOceanViewModel) with themockNetworkService
. This ensures that our view model uses the mock service during testing. - The
tearDown
method, which runs after each test, is used to clean up resources. Here, we release references tosut
andmockNetworkService
to ensure they don't interfere with subsequent tests.
This setup allows us to create a clean environment for each test case, ensuring that any changes made during one test won’t impact the next test. It’s an essential part of unit testing to maintain consistency and isolate test cases.
Step 4: Testing Download Photo Functionality
At the end of your unit test class, add this test case:
// 1
func test_DeepOceanViewModel_downloadUserPhoto_didGetImage() {
// given
let expectation = XCTestExpectation(description: #function)
// when
sut.downloadUserPhoto()
let listener = sut.$uiImage.dropFirst().sink { _ in
expectation.fulfill()
}
wait(for: [expectation], timeout: 3)
// then
XCTAssertNotNil(sut.uiImage)
}
You don’t need to focus too much on the expectation and wait lines here. These lines are one of the ways to test asynchronous behaviour, and we’ll dive deeper into this topic in future articles.
Let’s break down the code:
- We’re starting a test for the
DeepOceanViewModel
function calleddownloadUserPhoto()
. This test checks whether the view model can successfully obtain a user's photo. - In “given” section, we set up an
expectation
usingXCTestExpectation
. This expectation is like a promise that the test will wait for something to happen before it finishes. We give it a description based on the test function's name. - In the “when” section, we call
sut.downloadUserPhoto()
, which simulates the action of downloading a user's photo. - We create a
listener
using the$uiImage
property ofsut
. When it emit changes, we fulfill our expectation. - We’re using
wait(for:timeout:)
to wait for the expectation to come true. Specifically, we're waiting for a maximum of 3 seconds to see if the image gets downloaded. If nothing happens within 3 seconds, we consider the test as failed. - In the “then” section, we make an assertion using
XCTAssertNotNil
to check if theuiImage
property ofsut
is notnil
. If it's notnil
, the test passes, indicating that we were able to download an image successfully.
This test verifies whether the DeepOceanViewModel
can indeed download a user's photo and whether the uiImage
property is updated as expected.
Hooray! We’ve done it! We’ve cleverly pretended with our network stuff to speed up our testing. Try it out, and feel proud of your fantastic work. You’ve done an awesome job! 🎉
Step 5: Wrapping Up and Cleaning
You might be wondering, “Hey, Asilbek, we’ve tested the successful scenario, but what about testing when things go wrong?” That’s a great question, and I’m glad you’re thinking along with me. The cool thing is, we’re in charge of our pretend network, so we can use our special powers to make it misbehave for our tests. 😄
What we’re planning is to make a special variable inside our mock network service that we can adjust. Depending on this variable, we’ll make the mock service behave differently and return the right result to test different cases.
Change your mockNetworkService
:
class MockNetworkService: AnyNetworkService {
var isSuccessCase: Bool = true
func loooongNetworkCall(_ completion: @escaping (Result<UIImage, Error>) -> ()) {
if isSuccessCase {
completion(.success(UIImage()))
} else {
completion(.failure(NetworkError.failure))
}
}
}
If the isSuccessCase
is set to true, we'll make the mock return the successful scenario. If it's not true, we'll make it send an error instead.
Add new test case to your unit test class:
func test_DeepOceanViewModel_downloadUserPhoto_didGetError() {
// given
// 1
mockNetworkService.isSuccessCase = false
let expectation = XCTestExpectation(description: #function)
// when
sut.downloadUserPhoto()
// 2
let listener = sut.$error.sink { _ in
expectation.fulfill()
}
wait(for: [expectation], timeout: 3)
// then
// 3
XCTAssertNotNil(sut.error)
}
Let’s go through the test code step by step, based on the comment numbers:
- We’re starting a test to check how the
DeepOceanViewModel
behaves when it encounters an error during the download process. We set theisSuccessCase
variable of themockNetworkService
tofalse
. This indicates that we want the mock service to simulate a scenario where there's an error. - In the “then” section, we check whether the
error
property of the view model (sut
) is notnil
. If it's notnil
, the test passes. This indicates that the view model successfully detected and stored an error, as expected.
This test checks if the DeepOceanViewModel
responds correctly when the mockNetworkService
is set to return an error instead of a successful scenario during the download. It's essential to ensure that the view model handles error cases properly.
Conclusion
In a nutshell, mocking is a vital tool in iOS unit testing. It helps you break down and thoroughly examine different parts of your code. By using mock objects, you can make sure your code works correctly under different scenarios while keeping your tests fast and cost-effective. Incorporating mocking into your testing process is a smart approach that can result in more dependable and easier-to-maintain iOS applications. It’s a serious practice that serious developers should consider.
Find it a good read?
Recommend this post by clicking the 👏 button so other people can see it and let’s connect on LinkedIn asilbekdjamaldinov