In an ideal world, testing should be part of the development process of an app. The truth, however, is that testing is not always in the mix, and that’s mostly because it requires additional time; an asset that’s often missing from developers, especially when it’s necessary to meet deadlines. Or it’s not considered important enough because we all suffer from the illusion that we can predict all possible outcomes of our code.
Undeniably, testing should be a recursive task in our workflow, but leaving the general discussion aside, what I would like to focus on in this post today is something quite specific. In particular, my intension is to highlight how to test error throwing methods which belong to actors; the new type presented in WWDC 2021 that provides an isolated context and helps avoid data races when needed to support concurrency in our apps. Consider this post to be a short of a quick tip that will show you the way to avoid certain unnecessary issues if you have not written tests using actors and concurrency before.
Getting started with a usual type
To demonstrate the point of this article, let me start with a simple type that has nothing to do with concurrency and actors. That is a class that contains a stored property and a method to update the property’s value:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class MyType { private(set) var userValue: Int = 0 func set(value: Int) throws { if value > 0 && value < 100 { userValue = value } else { throw value <= 0 ? CustomErrors.lowValue : CustomErrors.highValue } } } |
As you figure out from the above implementation, the input value in the set(value:)
method is assigned to the userValue
property if only it belongs to the closed range 1…99. In any other case, an error is thrown from the method -that’s why it’s marked with throws
– and the error varies depending on the value itself.
The CustomErrors
type appearing in the above code is this:
1 2 3 4 5 |
enum CustomErrors: Error { case lowValue, highValue } |
In order to ensure that the set(value:)
method works as expected we are going to write a few simple unit tests. We’ll start with the next one:
1 2 3 4 5 6 |
func testSuccessfulUserInput() { let myType = MyType() XCTAssertNoThrow(try myType.set(value: 20)) } |
After having initialized a MyType
instance, we assert here that calling set(value:)
with a value within the acceptable range (1…99) won’t throw an error. Running the above test succeeds just fine.
Let’s add two more tests, where we want to see if an error will be thrown in case we provide a quite low and quite high value respectively:
1 2 3 4 5 6 7 8 9 10 11 |
func testThrowsOnLowValue() { let myType = MyType() XCTAssertThrowsError(try myType.set(value: –10)) } func testThrowsOnHighValue() { let myType = MyType() XCTAssertThrowsError(try myType.set(value: 500)) } |
We can go one step further, and make sure that the correct error is thrown in each case:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
func testLowValueError() { let myType = MyType() do { try myType.set(value: –10) } catch { XCTAssertEqual(error as! CustomErrors, .lowValue) } } func testHighValueError() { let myType = MyType() do { try myType.set(value: 500) } catch { XCTAssertEqual(error as! CustomErrors, .highValue) } } |
Notice that in this case we use a do-catch
statement. In the try
block we’re providing as arguments two values that are out of the predefined acceptable range. In the catch
block that we are interested in, we assert that the proper error is thrown in each test method.
All of the above tests will be successful when they’ll run. We were not expecting any surprises at all anyway, as they are intentionally simple in order to make a point.
Using an actor
To follow on the topic, let’s implement an actor with the same content as before:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
actor MyActor { private(set) var userValue: Int = 0 func set(value: Int) throws { if value > 0 && value < 100 { userValue = value } else { throw value <= 0 ? CustomErrors.lowValue : CustomErrors.highValue } } } |
As you can see, nothing changes here except for the kind of type –actor
instead of a class
– and the type’s name. Since all properties and methods in actors are by default isolated, unless it’s pointed otherwise, accessing either the stored property or the method through a MyActor
instance is something that takes place asynchronously. Let’s see how that affects us when testing.
The first thing to take care of before making any asynchronous calls to invoke either the actor’s method or access its stored property, is to append the async
keyword in the test method’s signature:
1 2 3 4 5 |
func testSuccessfulInputOnActor() async { } |
Alternatively, you can define a Task
block and write any asynchronous code in its body:
1 2 3 4 5 6 7 |
func testSuccessfulInputOnActor() { Task { } } |
Now let’s implement the same test as the first one we wrote earlier:
1 2 3 4 5 6 |
func testSuccessfulInputOnActor() async { let myActor = MyActor() XCTAssertNoThrow(try myActor.set(value: 20)) } |
This attempt will crash on a wall, as Xcode will show the next error:
“Actor-isolated instance method ‘set(value:)’ can not be referenced from a non-isolated autoclosure”
As said already, set(value:)
method exists in an isolated environment, so trying to access it like shown above will always fail (find out why right next). Also, we should call it prefixing it with the await
keyword, indicating that this is an asynchronous call. The following however not only does not fix the problem, but adds a new one to the list:
1 2 3 |
XCTAssertNoThrow(try await myActor.set(value: 20)) |
The reason for all that is that a closure with the given expression will be created automatically here even though it looks like a normal parameter currently, and that because the assert method’s parameters are marked with the @autoclosure
attribute; that results to a context other than the test method’s, and async
has no effect there.
Let’s go the working way now, ensuring that the above call will occur in a concurrent context and the async
keyword will have an actual impact. First off, let’s call the set(value:)
method in a do-catch
statement. For clarification, note that we use try
because it’s an error throwing method, and await
because we’re making an asynchronous call:
1 2 3 4 5 6 7 8 9 10 11 |
func testSuccessfulInputOnActor() async { let myActor = MyActor() do { try await myActor.set(value: 20) } catch { } } |
We are expecting the value 20 given as argument not to trigger an error throw, but what exactly are we going to assert here?
There must always be something that we can measure, no matter if that’s a boolean result, an integer value or something else. In this particular example it is the userValue
stored property whose value we can check against the argument we supply the set(value:)
with, and therefore assert for equality.
But similarly as before, we can’t just do this:
1 2 3 |
XCTAssertEqual(await myActor.userValue, 20) |
Instead, we should access userValue
asynchronously in a separate statement, and assign the fetched value to a local constant. Then, we can assert that this value is equal to 20:
1 2 3 4 |
let value = await myActor.userValue XCTAssertEqual(value, 20) |
And what about the catch
block? Normally, we don’t expect the execution to ever get there, but just to be aware that the catch
block is reached if the tested code changes in the future, we can add a XCTFail(_:)
method; doing so will break the test, including a message which will appear if the catch
block will get executed:
1 2 3 |
XCTFail(“This is not supposed to ever happen!”) |
So, putting everything together, this is a working test method:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func testSuccessfulInputOnActor() async { let myActor = MyActor() do { try await myActor.set(value: 20) let value = await myActor.userValue XCTAssertEqual(value, 20) } catch { XCTFail(“This is not supposed to ever happen!”) } } |
Notice the use of the await
keyword twice; an occurrence for each attempt to access asynchronously an isolated property or method in the actor’s instance.
Testing asynchronously thrown errors
The previous test works for the good scenario where an acceptable value is given to the set(value:)
method. But just like that, we should also add tests to make sure that errors are thrown properly for out of range values.
In order to achieve that, we should use a do-catch
statement once again, focusing on the catch
block this time. There, we should assert that the expected errors are thrown for each error case.
For instance, this is the test for a low value:
1 2 3 4 5 6 7 8 9 10 |
func testLowValueOnActor() async { let myActor = MyActor() do { try await myActor.set(value: –200) } catch { XCTAssertEqual(error as! CustomErrors, .lowValue) } } |
In the same fashion we can also assert that the error thrown for a high value is equal to .highValue
:
1 2 3 4 5 6 7 8 9 10 |
func testHighValueOnActor() async { let myActor = MyActor() do { try await myActor.set(value: 500) } catch { XCTAssertEqual(error as! CustomErrors, .highValue) } } |
Conclusion
Writing unit, integration and UI tests is a vital part of implementing apps, as they let us verify that our code works as intended, exposing at the same time potential bugs that we can fix before completion and delivery. Definitely nobody wants to convert end users to test users, so embrace testing and don’t just rely on trying things out manually. When it comes to testing asynchronous code, it might take sometimes a little longer code in order to prepare a test, and to adjust the way of thinking about how and what to test. In this post I just scratched that on a quite particular topic, which, even though it may look straightforward to some developers, it’s not the same for some others who just start on testing or asynchronous programming. So, I hope what you met here today to be proved useful for you.
Thank you for reading, enjoy coding! ????