Asserting state with #expect in Swift Testing
Published on: November 21, 2024I don't think I've ever heard of a testing library that doesn't have some mechanism to test assertions. An assertion in the context of testing is essentially an assumption that you have about your code that you want to ensure is correct.
For example, if I were to write a function that's supposed to add one to any given number, then I would want to assert that if I put 10 into that function I get 11 out of it. A testing library that would not be able to do that is not worth much. And so it should be no surprise at all that Swift testing has a way for us to perform assertions.
Swift testing uses the #expect
macro for that.
In this post, we're going to take a look at the #expect
macro. We'll get started by using it for a simple Boolean assertion and then work our way up to more complex assertions that involve errors.
Testing simple boolean conditions with #expect
The most common way that you're probably going to be using #expect
is to make sure that certain conditions are evaluated to betrue
. For example, I might want to test that the function below actually returns 5 whenever I call it.
func returnFive() -> Int {
return 0
}
Of course this code is a little bit silly, it doesn't really do that much, but you could imagine that a more complicated piece of code would need to be tested more thoroughly.
Since I haven't actually implemented my returnFive
function yet, it just returns 0. What I can do now is write a test as shown below.
@Test func returnFiveWorks() async throws {
let functionOutput = Incrementer().returnFive()
#expect(5 == functionOutput)
}
This test is going to test that when I call my function, we get number 5 back. Notice the line where it says #expect(5 == functionOutput)
.
That is an assertion.
I am trying to assert that 5 equals the output of my function by using the #expect
macro.
When our function returns 5, my expression (5 == functionOutput
) evaluated to true
and the test will pass. When the expression is false
, the test will fail with an error that looks a bit like this:
Expectation failed: 5 == (functionOutput → 0)
This error will show up as an error on the line of code where the expectation failed. That means that we can easily see what went wrong.
We can provide more context to our test failures by adding a comment. For example:
@Test func returnFiveWorks() async throws {
let functionOutput = Incrementer().returnFive()
#expect(5 == functionOutput, "returnFive() should always return 5")
}
If we update our tests to look a little bit more like this, if the test fails we will see an output that is a bit more elaborate (as you can see below).
Expectation failed: 5 == (functionOutput → 0)
returnFive() should always return 5
I always like to write a comment in my expectations because this will provide a little bit more context about what I expected to happen, making debugging my code easier in the long run.
Generally speaking, you're either going to be passing one or two arguments to the expect macro:
- The first argument is always going to be a Boolean value
- A comment that will be shown upon test failure
So in the test you saw earlier, I had my comparison between 5 and the function output inside of my expectation macro as follows:
5 == functionOutput
If I were to change my code to look like this where I put the comparison outside of the macro, the output of my failing test is going to look a little bit different. Here's what it will look like:
@Test func returnFiveWorks() async throws {
let functionOutput = Incrementer().returnFive()
let didReturnFive = 5 == functionOutput
#expect(didReturnFive, "returnFive() should always return 5")
}
// produces the following failure message:
// Expectation failed: didReturnFive
// returnFive() should always return 5
Notice how I'm not getting any feedback right now about what might have gone wrong. I simply get a message that says "Expectation failed: didReturnFive" and no context as to what exactly might have gone wrong.
I always recommend trying to put your expressions inside the expect macro because that is simply going to make your test output a lot more useful because it will inspect variables that you inserted into your expect macro and it will say "you expected 5 but you've got 0".
In this case I only know that I did not get 5, which is going to be a lot harder to debug.
We can even have multiple variables that we're using inside of expect and have the testing framework tell us about those as well.
So imagine I have a function where I input a number and the amount that I want to increment the number by. And I expect the function to perform the math increment the input by the amount given. I could write a test that looks like this.
@Test func incrementWorks() async throws {
let input = 1
let incrementBy = 2
let functionOutput = Incrementer().increment(input: input, by: incrementBy)
#expect(functionOutput == input + incrementBy, "increment(input:by:) should add the two numbers together")
}
This test defines an input variable and the amount that I want to increment the first variable by.
It passes them both to an increment function and then does an assertion that checks whether the function output equals the input plus the increment amount. If this test fails, I get an output that looks as follows:
Expectation failed: (functionOutput → 4) == (input + incrementBy → 3)
increment(input:by:) should add the two numbers together
Notice how I quite conveniently see that my function returned 4, and that is not equal to input + increment
(which is 3). It's really like this level of detail in my failure messages.
It’s especially useful when you pair this with the test arguments that I covered in my post on parameterized testing. You can easily see a clear report on what your inputs were, what the output was, and what may have gone wrong for each different input value.
In addition to boolean conditions like we’ve seen so far, you might want to write tests that check whether or not your function threw an error. So let's take a look at testing for errors using expect next.
Testing for errors with #expect
Sometimes, the goal of a unit test isn't necessarily to check that the function produces the expected output, but that the function produces the expected error or that the function simply doesn't throw an error. We can use the expect
macro to assert this.
For example, I might have a function that throws an error if my input is either smaller than zero or larger than 50. Here's what that test could look like with the expect
macro:
@Test func errorIsThrownForIncorrectInput() async throws {
let input = -1
#expect(throws: ValidationError.valueTooSmall, "Values less than 0 should throw an error") {
try checkInput(input)
}
}
The syntax for the expect macro when you're using it for errors is slightly different than you might expect based on what the Boolean version looked like. This macro comes in various flavors, and I prefer the one you just saw for my general purpose error tests.
The first argument that we pass is the error that we expect to be thrown. The second argument that we pass is the comment that we want to print whenever something goes wrong. The third argument is a closure. In this closure we run the code that we want to check thrown errors for.
So for example in this case I'm calling try checkInput
which means that I expect that code to throw the error that I specified as the first argument in my #expect
.
If everything works as expected and checkInput
throws an error, my test will pass as long as that error matches ValidationError.valueTooSmall
.
Now let's say that I accidentally throw a different error for this function the output will look a little bit like this
Expectation failed: expected error "valueTooSmall" of type ValidationError, but "valueTooLarge" of type ValidationError was thrown instead
Values less than 0 should throw an error
Notice how the message explains exactly which error we received (valueTooLarge
) and the error that we expected (valueTooSmall
). It's quite convenient that the #expect
macro will actually tell us what we received and what we expected, making it easy to figure out what could have gone wrong.
Adding a little comment just like we did with the Boolean version makes it easier to reason about what we expected to happen or what could be happening.
If the test does not throw an error at all, the output would look as shown below
ExpectMacro.swift:42:3: Expectation failed: an error was expected but none was thrown
Values less than 0 should throw an error
This error pretty clearly tells us that no error was thrown while we did expect an error to be thrown.
There could also be situations where you don't really care about the exact error being thrown, but just that an error of a specific type was thrown. For example, I might not care that my "value too small" or "value too large" error was thrown, but I do care that the type of error that got thrown was a validation error. I can write my test like this to check for that.
@Test func errorIsThrownForIncorrectInput() async throws {
let input = -1
#expect(throws: ValidationError.self, "Values less than 0 should throw an error") {
try checkInput(input)
}
}
Instead of specifying the exact case on validation error that I expect to be thrown, I simply pass ValidationError.self
. This will allow my test to pass when any validation error is thrown. If for whatever reason I throw a different kind of error, the test would fail.
There's a third version of expect in relation to errors that we could use. This one would first allow us to specify a comment like we can in any expect. We can then pass a closure that we want to execute (e.g. calling try checkInput
) and a second closure that receives whatever error we received. We can perform some checks on that and then we can return whether or not that was what we expected.
For example, if you have a bit more complicated setup where you're throwing an error with an associated value you might want to inspect the associated value as well. Here's what that could look like.
@Test func errorIsThrownForIncorrectInput() async throws {
let input = -1
#expect {
try checkInput(input)
} throws: { error in
guard let validationError = error as? ValidationError else {
return false
}
switch validationError {
case .valueTooSmall(let margin) where margin == 1:
return true
default:
return false
}
}
}
In this case, our validation logic for the error is pretty basic, but we could expand this in the real world. This is really useful when you have a complicated error or complicated logic to determine whether or not the error was exactly what you expected.
Personally, I find that in most cases I have pretty straightforward error checking, so I’m generally using the very first version of expect that you saw in this section. But I've definitely dropped down to this one when I wanted to inspect more complicated conditions to determine whether or not I got what I expected from my error.
What you need is, of course, going to depend on your own specific situation, but know that there are three versions of expect that you can use when checking for errors, and that they all have sort of their own downsides that you might want to take into account.
In Summary
Usually, I evaluate testing libraries by how powerful or expressive their assertion APIs are. Swift Testing has done a really good job of providing us with a pretty basic but powerful enough API in the #expect
macro. There's also the #require
macro that we'll talk about more in a separate post, but the #expect
macro on its own is already a great way to start writing unit tests. It provides a lot of context about what you're doing because it's a macro and it will expand into a lot more information behind the scenes. The API that we write is pretty clear, pretty concise, and it's powerful for your testing needs.
Make sure to check out this category of Swift testing on my website because I had a lot of different posts with Swift testing, and I plan to expand this category over time. If there's anything you want me to talk about in terms of Swift testing, make sure you find me on social media, I’d love to hear from you.