Swift Testing basics explained
Published on: October 23, 2024Swift testing is Apple's framework for running unit tests in a modern and more elegant way than it was with XCTest, which came before it. This post is the first one in a series of posts that will help you start using Swift Testing in your projects.
In this post, we'll take a look at the following topics:
- Adding a Swift Testing to an existing project
- Writing your first Swift test
- Understanding Swift Testing syntax
Let's go ahead and dive right in and see what it takes to add a new Swift test to an existing project.
Adding a Swift Testing to an existing project
Adding a new Swift Testing based test to an existing project is surprisingly straightforward. If you already have a test target, all you need to do is add a new Swift file, import the testing framework, and start writing your tests.
In the past, if you would make a new test file, the skeleton for what you’d put in that file looks a bit like this:
import XCTest
final class ExampleXCTest: XCTestCase {
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
func testExample() throws {
XCTAssertTrue(true, "This test will always pass")
}
}
If you’ve worked with unit testing before, this should look familiar to you. It’s a very plain and simple example of what an XCTest
based test can look like. All our tests are written inside of subclasses of XCTestCase
, they can contain setup and teardown methods, and we write our tests in functions prefixed with the word “test”.
With Swift testing, all you need to do is add a new file, import the testing framework, and start writing unit tests.
You don't need to configure any build settings, you don't have to configure any project settings - all you have to do is add a new file and import the Testing
framework, which is really convenient and allows you to experiment with Swift testing in existing projects even if the project already uses XCTest.
It's good to know that Swift Testing works with packages, executables, libraries, and any other project where you’re using Swift as you might expect.
Here's what the same skeleton looks like when we’re using for Swift Testing.
import Testing
@Test func swiftTestingExample() {
// do setup
#expect(true, "This test will always pass")
// do teardown
}
We don’t need to wrap our test in a class, we don’t need a setup or teardown method, and we don’t need to prefix our test with the word “test”.
Notice that the test that I just showed is essentially an @Test
macro applied to a function.
The @Test
macro tells the testing framework that the function that's wrapped in the macro is a function that contains a test. We can also put these test functions inside of structs or classes if we want, but for simplicity I chose to show it as a function only which works perfectly well.
When you place your tests inside of an enclosing object, you still need to apply @Test
to the functions that you want to run as your tests.
Let's say you choose to add your tests to a class. You can have setup and teardown logic in the initializer for your class because Swift testing will make a new instance of your class for every single test that it runs, meaning that you don't have to allow for one instance of the class to run all of your tests.
You know that you're going to always have a fresh instance for every single test, so you can set up in your initializer and tear down in a deinit
.
If you're working with a struct, you can do the same thing, and this really makes Swift testing a very flexible framework because you get to pick and choose the correct type of object that you would like to use.
When in doubt, you're free to just use the kind of object that you prefer to use. If at any point in time you find that you do need something that only a class or struct could provide, you can always switch and use that instead.
Personally, I prefer classes because of their deinit
where I can put any shared cleanup logic.
In the next section, I'd like to take a bit of a deeper look at structuring your tests and the kinds of things that we can do inside of a test, so let's dig into writing your first Swift test.
Writing your first Swift test
You've just seen your first test already. It was a free-floating function annotated with the @Test
macro. Whenever you write a Swift test function, you're going to apply the test macro to it. This is different from XCTest where we had to prefix all of our test functions with the word "test".
Writing tests with the @Test
macro is a lot more convenient because it allows us to have cleaner function names, which I really like.
Let’s grab the test from earlier and put that inside of a class. This will allow us to move shared setup and teardown logic to their appropriate locations.
class MyTestSuite {
init() {
// do setup
print("doing setup")
}
deinit {
// do teardown
print("doing teardown")
}
@Test func testWillPass() {
print("running passing test")
#expect(true, "This test will always pass")
}
@Test func testWillFail() {
print("running failing test")
#expect(1 == 2, "This test will always fail")
}
}
The code above shows two tests in a single test suite class. In Swift testing, we call enclosing classes and structs suites, and they can have names (which we'll explore in another post). For now, know that this test suite is called "MyTestSuite" (just like the class name).
If we run this test, we see the doing setup line print first, then we see that we're running the passing test, followed by the teardown. We're going to see another setup, another failing test, and then we'll see another teardown.
What's interesting is that Swift testing will actually run these tests in parallel as much as possible, so you might actually see two setups printed after each other or maybe a setup and a running test interleave depending on how fast everything runs. This is because Swift testing makes a separate instance of your test suite for every test function you have.
Having separate instances allows us to do setup in the initializer and teardown in the de-initializer.
If we expand this example here to something that's a little bit more like what you would write in the real world, here's what it could look like to test a simple view model that's supposed to fetch data for us.
class TestMyViewModel {
let viewModel = ExercisesViewModel()
@Test func testFetchExercises() async throws {
let exercises = try await viewModel.fetchExercises()
#expect(exercises.count > 0, "Exercises should be fetched")
}
}
Because we're making new instances of my view model, I don't really have to put the initialization of the exercises view model in an initializer. I can just write let viewModel = ExercisesViewModel()
to create my ExercisesViewModel
instance right when the class is created. And I can use it in my test and know that it's going to be cleaned up after the test runs.
This is really nice.
What is important to keep in mind though is that the fact that Swift testing uses separate instances for each of my tests means that I cannot rely on any ordering or whatever of my tests, so every test has to run in complete isolation which is a best practice for unit testing anyway.
Inside of my test fetch exercises function, I can just take my let exercises
and verify that it has more than zero items. If there are zero items, the test will fail because the expectation for my #expect
macro evaluates to false
.
I'd like to zoom in a little bit more on the syntax that I'm using here because the #expect
macro is the second macro we’re looking at in addition to the @Test
macro, so let's just take a really brief look at what kinds of macros we have available to us in the Swift testing framework.
Exploring the basics of Swift testing syntax
You've already seen some tests, so you're somewhat familiar with the syntax. You can recognize a Swift test by looking for the @Test
macro. The @Test
macro is used to identify individual tests, which means that we can give our functions any name that we want.
You have also seen the #expect
macro. The #expect
macro allows us to write our assertions in the form of expectations that are going to give us a boolean value (true or false) and we can add a label that shows us what should be presented in case of a failing test.
Before we take a deeper look at #expect
, let's take a closer look at the @Test
macro first. The @Test
macro is used to signal that a certain function represents a test in our test suite.
We can pass some arguments to our @Test
, one of these arguments is be a display name (to make a more human-readable version of our test). We can also pass test traits (which I'll cover in another post), and arguments (which I'll also cover in another post).
Arguments are the most interesting one in my opinion because they would allow you to actually run a test function multiple times with different input arguments. But like I said, that’s a topic for another day…
Let's stay on focus.
The display name that we can pass to a test macro can be used a little bit like this.
@Test("Test fetching exercises")
func testFetchExercises() async throws {
let exercises = try await viewModel.fetchExercises()
#expect(exercises.count > 0, "Exercises should be fetched")
}
Now whenever this test runs, it will be labeled as the human-readable test "Fetching exercises" vs the function name. For a short test like this, that's probably not really needed, but for longer tests, it will definitely be useful to be able to give more human-readable names to your tests. I would advise that you use the display name argument on your tests wherever relevant.
The second building block of Swift testing that I'd like to look at now is the macro for expecting a certain state to be true or false. The #expect
macro can take several kinds of arguments. It could take a statement that may or may not throw, or it could take a statement that will return a Boolean value. You've already seen the Bool
version in action.
Sometimes you'll write tests where you want to ensure that calling a certain function with an incorrect input will throw a specific error. The expect macro can also handle that.
We can give it a specific error type that we expect to be thrown, a human readable failure message, and the expression to perform.
This expression is what we expect to throw the error that was defined as the first argument.
Here’s an example of using #expect
to test for thrown errors.
@Test("Validate that an error is thrown when exercises are missing") func throwErrorOnMissingExercises() async {
await #expect(
throws: FetchExercisesError.noExercisesFound,
"An error should be thrown when no exercises are found",
performing: { try await viewModel.fetchExercises() })
}
I think these are the most useful things to know about the #expect
macro because with just knowing how to leverage Bool
expectations and knowing how to expect thrown errors, you're already able to write a very powerful tests.
In future posts, I will dig deeper into different macros and into setting up more complicated tests, but I think this should get you going with Swift testing very nicely.
In Summary
In this post, you've learned how you can get started with the Swift testing framework. You've seen that adding a new Swift test to an existing project is as simple as making a new file, importing the Swift testing framework, and writing your tests using the @Test
macro. The fact that it's so easy to add Swift testing to an existing project makes me think that everybody should go and try it out as soon as possible.
Writing unit tests with Swift testing feels a lot quicker and a lot more elegant than it ever did with XCTest. You've also seen the basics of writing unit tests with Swift testing. I talked a little bit about the @Test
macro and the #expect
macro and how they can be used to both create more readable tests and to do more than just comparing booleans.
As I've mentioned several times, I will be writing more posts about Swift testing, so in those posts, we'll dig deeply into more advanced and different features of the testing framework. But for now, I think this is a great introduction that hopefully gets you excited for a new era in testing your Swift code.