Clean Async XCTests

German Velibekov
Mar 31, 2020 · 6 min read

Writing tests has always been a nontrivial task, especially if the code you are testing is mainly asynchronous (i.e., a tested method receives callback as one of its parameters). Although a callback-style function invocation is entirely appropriate within a program, this style doesn’t fit so well into a unit test environment.

The Definition of Clean Unit Test

I strongly believe in the rule of thumb that a unit test should be as simple as possible. It has the word “unit” in the definition for a reason: to emphasize that it shouldn’t be complicated. Based on my experience, a well-written unit test is:

1. Non-nested

If possible, you should avoid nesting and stick to line-by-line code, which is easier to read. Of course, for-loops and if-conditions do not count as nesting, but a callback certainly does. As John Sundell writes in his article The power of Result types in Swift: “…when writing tests, we don’t really want to add any code branches or conditionals. We should keep all assertions and verifications at the top level of our test.”

2. Isolated

Each unit test should complete its lifecycle within predefined boundaries, meaning that it starts, executes and finishes inside the body of a single method.

3. Behavior-Driven

A unit test should follow the GivenWhenThen contract, in which each step in a test lifecycle’s (i.e., beginning, execution and completion) has a set place.

Having introduced the principles of clean unit testing, let’s see if those rules are met by the way we test our code using XCTest framework.

First, let’s examine a basic asynchronous API method:

func someAPI(callback: (result: ResultType) -> ()) -> Void

If we read the signature of this method, it would go something like this: “a function that receives another function (callback) as a parameter, which also receives a parameter (of type ResultType), while both functions return empty type (void)”.

If we unit-test this method, we would probably write the following:

func testSomeAPI() {
let expectedValue = SOME_VALUE
let expectaction = self.expectation(description: "Wait for \(expectedValue)")
var result: ResultType!
someAPI {
result = $0
expectaction.fulfill()
}

waitForExpectations(timeout: 10.0)

XCTAssertEqual(result, expectedValue, "Should be \(expectedValue)")
}

Note: The above code doesn’t compile. We’ll look at the working examples further.

Unfortunately, this implementation violates all the clean unit testing rules we defined earlier. In this particular example we have:

  • nesting
  • a non-procedural test
  • boilerplate code for configuring a timeout

So, is it even possible to overcome those issues and achieve a clean unit test structure? Briefly, yes. But we first need to do a little pre-work, in order to process the whole method (a function that receives another function which, when called sometime in the future, will provide an obtained result). This method should be in an external module, so the process of waiting for a result will be transparent.

Writing such a module will help us to create a proper environment for our unit test to help it look more “unit-testy”.

Functional Async Test Wrapper

Thanks to Swift’s Higher Order Functions support, we can pass the whole async API method to another function. That function can deal with all the required configurations, such as waiting for a callback to be called, before returning the final result to our unit test. One of the possible ways to implement this is:

XCAsyncTest.swift

In the above code snippet, the async method is placed into XCTestCase extension. This helper method performs the same code from the above testSomeAPI. However, this time, an API is received as an external parameter. It also returns a result as soon as the expectation fulfilled. For curious readers, this asyncmethod is a special case of what is known in algebra as Composition of Functions: f(g(x)).

Here’s an updated testSomeAPI which uses our newly-created async:

func testSomeAPI() {
let expectedValue = SOME_VALUE
let result = async(someAPI)
XCTAssertEqual(result, expectedValue, "Should be \(expectedValue)")
}

As you can see, the code is entirely procedural, without any nesting, and we’ve managed to wrap around a boilerplate code for creating expectation . Congratulations! We’ve successfully addressed all the drawbacks that we identified earlier.

It’s most common for API to have more than one send parameter. Let’s “expand an extension” to support an API with multiple parameters on input.

This could be done by overloading ourasync with just one difference: that it receives additional parameters to pass them later. But, we still want to reuse the base async implementation, as we don’t want to end up duplicating the code. To sum up, we need to find a way how to convert an API method with multiple parameters into a method with only one parameter (callback), so it has a suitable signature for calling the base async method.

Can we even achieve this or is the only viable option to re-implementing the async method? 🙍🏻‍♂️ Don’t despair, my friend. The answer lies in another functional principle called currying.

From Wikipedia: currying is the technique of translating the evaluation of a function that takes multiple arguments into evaluating a sequence of functions, each with a single argument. This is exactly what we need: an ability to “split” the API receiving multiple parameters and call it gradually (a.k.a “partial applying”) so that we can modify it to the signature acceptable by the base async. Let’s define our currying helper as shown in this article, and create async overload:

That’s how curry function works. It receives a complete function but delays the call for it until both parameters are supplied. If we check a type of curriedCallback we’ll see this:

And that’s exactly the type of callback parameter our base async method expects.

The test for such an API with multiple parameters would look like this:

func testSomeAPIWithParameters() {
let expectedResult = EXP_RESULT
let result = async(input, someAPI)
XCTAssertEqual(result, expectedResult, "Should be \(expectedValue)")
}

The only difference from the previous test example is that we now pass a parameter and Swift knows to call an overloaded method.

As you might guess, we can create similar asyncoverloads together with curry helpers to support multiple input parameters, aiming to cover as many different API signatures as possible. Eventually, all the overloads will call the base async, so no code duplication needs to be done.

Note: I’m not referring to the APIs that return more than one parameter. However, I believe there are plenty of APIs like that, such as Return<Success, Failure> which is now supported by Swift.

I’ve built a simple Xcode project containing Test target, in which I tried to recreate a couple of functioning examples to illustrate the above concepts. Feel free to download the project.

Conclusion

This journey towards Clean Async Unit Testing has been made solely based on pure Swift principles. Perhaps this approach can’t be used broadly — in a library, for example — because it still has disadvantages such as a lack of support of more complex APIs. However, you still might want to adopt this functional way of thinking to your particular needs a moment before you try any fancy libraries for testing.

I hope you enjoyed reading this article! You’re welcome to share your thoughts and ideas, maybe how to extend this concept even more 😀. You can write to me on Twitter or leave your comments below, and I hope that you and your families are staying safe during these tough times!

References

Mac O’Clock

The best stories for Apple owners and enthusiasts

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store