Why to Unit Test in Async Mode

Asilbek Djamaldinov
8 min readOct 28, 2023

--

Here are my other articles of the Testing Flow in iOS :

  1. Testing in iOS: From Zero to Hero!
  2. Unlocking the Power of UI Testing in iOS Development
  3. Why Mocking Matters in iOS Unit Testing
  4. iOS Dev Must-Have: Snapshot Testing
  5. Why to Unit Test in Async Mode (current)

Welcome to the fifth and final chapter in our joyful journey through the intricate world of iOS testing. By now, you’ve likely mastered the art of unit testing and gained insights into a variety of testing techniques, but there’s one more crucial skill to add to your arsenal: unit testing in asynchronous environments.

In this article, we’re going to delve into the exciting realm of asynchronous unit testing, equipping you with the knowledge and skills necessary to ensure your iOS applications are robust, reliable, and free from pesky bugs. While we can’t cover every nook and cranny of testing in a single article, this guide will serve as a springboard for you to confidently venture into the world of testing in the iOS ecosystem.

So, whether you’re a seasoned iOS developer looking to enhance your testing abilities or a newcomer keen to get started, fasten your seatbelts. We’re about to embark on a delightful journey that will make you an expert in iOS testing, enabling you to create apps that stand the test of time. Get ready to write unit tests in asynchronous environments with joy and confidence!

Why we need asynchronous testing?

In iOS, we often work with asynchronous operations and multiple threads, even if it’s not immediately visible. While we’re aware that network tasks are asynchronous, there are times when we might not notice that specific pieces of code are running in the background or are designed to work asynchronously.

A big chunk of our iOS code is closely tied to asynchronous stuff: like networking, event listeners, closures, handling notifications, working with Combine publishers, and even user interactions. And there’s more — these asynchronous elements are woven into the fabric of our apps, making them dynamic and responsive.

Testing in an asynchronous iOS environment can be a tad trickier than in a synchronous one. However, don’t fret; it’s not a daunting task. Thanks to Apple, we’ve got a bunch of nifty tools at our disposal to craft tests and manage how our code behaves with ease. So, while it’s a bit different, it’s far from difficult, and these tools are here to make your testing journey a smooth one.

Alright, enough with the words — let’s sketch out a strategy to conquer the beast called “asynchronous testing.” We’re going to map out a plan to tackle it head-on, making sure it doesn’t stand in the way of our app’s success.

Our Topics For Today

  1. Mocking: Surprisingly Problem-Solving
  2. What to Expect From XCTestExpectation
  3. Fulfilment Will Help You More Than You Think
  4. New Testing Strategy for New Way of Concurrency

Topic 1: How to Achieve Synchronous Code with Mocking

The best way to tackle a problem is not having one in the first place, right? Honestly, the most elegant solution is simply getting rid of the problem itself. So, when we’re testing our asynchronous code, our first move is to attempt to kick out the asynchronous part. Of course, it doesn’t always work, but hey, we’ve got to give it a shot!

Let’s imagine we’ve got this service that likes to chat with the cloud asynchronously:

protocol AnyService {
var value: Int { get }

func someAsyncOperation()
}

class CloudService: AnyService {
var value: Int = 0

func someAsyncOperation() {
DispatchQueue.global().asyncAfter(deadline: .now() + 3) { [weak self] in
self?.value = 1
}
}
}

Now, picture our trusty viewModel, which, inside one of its functions, gives our service a ring. Just a quick heads-up, this article won’t dive deep into the nitty-gritty of mocking. But if you’re curious, there’s another article where we unravel the mysteries of mocking — so feel free to check that out if you’ve got questions!

class DeepOceanViewModel {
let service: AnyService

init(service: AnyService = CloudService()) {
self.service = service
}

func callSomeAsyncOperation() {
service.someAsyncOperation()
}
}

Time to craft a test for this setup!

// Without mocking
final class MyOceanTestsTests: XCTestCase {
...

func test_DeepOceanViewModel_callSomeAsyncOperation_didIncreaseValue() {
// when
sut.callSomeAsyncOperation()

// then
XCTAssertEqual(sut.service.value, 1)
}
}

Do you think this test will succeed? Nope, it won’t. The test will wrap up before the value gets a chance to increase.

Let’s implement mocking for this test case:

class MockCloudService: AnyService {
var value: Int = 0

func someAsyncOperation() {
value = 1
}
}

Let’s take a closer look at our handy MockCloudService class. In the someAsyncOperation function, we've done a little trick. Instead of the usual asynchronous stuff, it now works like regular, straightforward code – no waiting around. That's the magic of mocking that makes our asynchronous tests a piece of testable cake!

final class MyOceanTestsTests: XCTestCase {
var sut: DeepOceanViewModel!
var mockCloudService: MockCloudService!

override func setUp() {
super.setUp()
mockCloudService = MockCloudService()
sut = DeepOceanViewModel(service: mockCloudService)
}

...

func test_DeepOceanViewModel_callSomeAsyncOperation_didIncreaseValue() {
// when
sut.callSomeAsyncOperation()

// then
XCTAssertEqual(sut.service.value, 1)
}
}

Let’s fire it up and take a peek… Yippee! It passes with flying colours. That’s the sheer strength of mocking, making our testing journey smooth as butter!

Topic 2: Our Expectations from XCTestExpectation

XCTestExpectation is a part of Apple's XCTest framework and a very powerful tool for asynchronous testing. It allows us to wait for specific conditions or events to occur in the code during testing.

Here’s a simple 3-step guide to using XCTestExpectation:

  1. Create your expectation.
  2. Pause and wait for it to be met.
  3. Make it happen — fulfill the expectation when your condition is satisfied.

It’s as straightforward as that! Truth be told, testing isn’t all that tricky, especially when you develop it hand-in-hand with well-organised code. It’s a recipe for success!

In this testing method, we’ll be testing another asynchronous function with closure.

class DeepOceanViewModel {
var value: Int = 0
...

func anotherAsyncOperation(completion: @escaping () -> ()) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
value = 77
completion()
}
}
}

Mocking isn’t a one-size-fits-all solution for asynchronous testing. That’s why we enlist the help of XCTestExpectation. It’s crucial to note that XCTestExpectation isn’t meant to replace mocking; instead, it complements it. In most cases, you’ll use mocking for service calls and XCTestExpectation to handle test cases with asynchronous expectations. They’re a dynamic duo in the world of testing!

final class MyOceanTestsTests: XCTestCase {
...

func test_DeepOceanViewModel_anotherAsyncOperation_didCallCompletion() {
// given
let expectation = XCTestExpectation(description: #function)

// when
sut.anotherAsyncOperation {
expectation.fulfill()
}

waitForExpectations(timeout: 3)

// then
XCTAssertEqual(sut.value, 77)
}
}

In this test:

  • Given: We prepare for an asynchronous operation by creating an expectation with the test case’s name.
  • When: We initiate the asynchronous operation and set a 3-second maximum waiting time. If the operation completes earlier due to expectation.fulfill(), the test proceeds to make an assertion.
  • Then: We verify whether sut.value is equal to 77.

If everything goes as planned, the test passes; if not, it fails, indicating potential code issues.

Topic 3: How to fulfil that’s already been filled?

In app development, we frequently employ a debouncer to manage button clicks or search logic, preventing multiple server calls. Let’s now craft a unit test for this kind of function. Debouncer code you can find here.

class DeepOceanViewModel {
let debouncer = Debouncer(timeInterval: 0.3)

...

func searchWithDebouncer(_ value: String, completion: @escaping () -> ()) {
debouncer.renewInterval()

debouncer.handler = {
service.search(for: value)
completion()
}
}
}

In this scenario, we’re going to perform two very quick calls to the search method. We’ll wait for only one expectation to occur. If both expectations are fulfilled, it indicates a test failure. To fail the test when an expectation is met, we’ll set cancelExpectation.isInverted to true.

func test_DeepOceanViewModel_searchWithDebouncer_oneExpectationFulfilled() {
// given
let canceledExpectation = XCTestExpectation(description: "Canceled")
canceledExpectation.isInverted = true

let completedExpectation = XCTestExpectation(description: "Completed")

// when
sut.searchWithDebouncer("Cancel") {
canceledExpectation.fulfill()
}

sut.searchWithDebouncer("Completed") {
completedExpectation.fulfill()
}

// then
wait(for: [canceledExpectation, completedExpectation], timeout: 3)
}

In this test, we’ve set up two expectations, with one of them being set as “inverted.” This means that if the inverted expectation is fulfilled, the test will fail. We then called the sut.searchWithDebouncer function twice – the second call cancels out the first. Finally, we use a waiting function that considers both expectations, allowing us to conveniently wait for specific expectations to be met.

The essence of this test is to confirm that the debouncer effectively handles rapid search calls, ensuring only one can proceed while the other is canceled. It validates the expected behavior of the debouncer implementation.

Topic 4: Testing of async/await

We won’t delve into the intricacies of the await and async code structures, as there are plenty of resources available for that. Our main focus here is testing.

In this example, we’ll fetch an image from Unsplash.com and transform the data into a UIImage. Please note that we won’t be addressing the concept of mocking in this context.

class CloudService: AnyService {
...

func asyncAwaitCall() async throws -> Data {
let url = URL(string: "https://images.unsplash.com/photo-1697208254530-eb42576be354?auto=format&fit=crop&q=80&w=3686&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D")!
let (data, _) = try await URLSession.shared.data(from: url)

return data
}
}

class DeepOceanViewModel: ObservableObject {
...

func callAsyncAwaitCloudService() async throws -> UIImage? {
UIImage(data: try await service.asyncAwaitCall())
}
}

Thanks to Swift’s concurrency model with async/await, writing and testing asynchronous code becomes more straightforward and dependable, resulting in code that's easier to understand and verify.

// 1
func test_DeepOceanViewModel_callAsyncAwaitCloudService_didGetImage() async {
// when
let image = try? await sut.callAsyncAwaitCloudService()

// then
XCTAssertNotNil(image)
}

To test a structured concurrency function, simply add async after the test case, and it handles the asynchronous aspects automatically. You can write the code as if it were synchronous. In this test, we're fetching an image from the function and checking if it's not nil. If it's not nil, the test passes.

Testing with structured concurrency and async/await simplifies the process. I absolutely adore how simple this is.

Conclusion

Congratulations, dear readers, on embarking on the journey of becoming proficient in writing unit tests in asynchronous environments, specifically for iOS. This article marks the fifth and final instalment in our series on testing, and it has been an incredible ride so far. While we may not have covered every aspect of testing, we’ve certainly equipped you with the knowledge and skills needed to confidently assert your expertise in iOS testing.

As we wrap up this series, let’s take a moment to reflect on what you’ve learned. You’ve delved into the world of unit and UI testing, tried your first Snapshot tests, mocked different services from expensive calls and finally tackled with asynchronous testing challenges, and honed your skills to become a true testing maestro. Your newfound knowledge will empower you to create robust, reliable code, ensuring that your iOS apps run smoothly and bug-free.

Remember, becoming an expert is a journey, and practice makes perfect. So, don’t stop here. Continue writing tests, exploring new testing techniques, and pushing your boundaries. With each test you write, you’re not only improving your code but also building confidence in your testing prowess.

In the world of iOS development, where quality and reliability are paramount, your expertise in testing will set you apart. Your dedication to honing your testing skills will not only benefit your own projects but also the broader iOS community. So, go forth and write tests with confidence, and watch your reputation as a testing expert grow. Here’s to a bug-free future in iOS development!

Find it a good read?

Recommend this post by clicking the 👏 button so other people can see it and I will be happy to stay connected on LinkedIn asilbekdjamaldinov.

--

--