Observing properties on an @Observable class outside of SwiftUI views

On iOS 17 and newer, you have access to the Observable macro. This macro can be applied to classes, and it allows SwiftUI to officially observe properties on an observable class. If you want to learn more about Observable or if you're looking for an introduction, definitely go ahead and check out my introduction to @Observable in SwiftUI.

In this post, I would like to explore how you can observe properties on an observable class. While the ObservableObject protocol allowed us to easily observe published properties, we don't have something like that with Observable. However, that doesn't mean we cannot observe observable properties.

A simple observation example

The Observable macro was built to lean into a function called WithObservationTracking. The WithObservationTracking function allows you to access state on your observable. The observable will then track the properties that you've accessed inside of that closure. If any of the properties that you've tried to access change, there's a closure that gets called. Here's what that looks like.

@Observable
class Counter {
  var count = 0
}

class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { 
      print("counter.count: \(counter.count)")
    } onChange: {
      self.observe()
    }
  }
}

In the observe function that’s defined on CounterObserver, I access a property on the counter object.

The way observation works is that any properties that I access inside of that first closure will be marked as properties that I'm interested in. So if any of those properties change, in this case there's only one, the onChange closure will be called to inform you that there have been changes made to one or more properties that you've accessed in the first closure.

How withObservationTracking can cause issues

While this looks simple enough, there are actually a few frustrating hiccups to deal with when you work with observation tracking. Note that in my onChange I call self.observe().

This is because withObservationTracking only calls the onChange closure once. So once the closure is called, you don’t get notified about any new updates. So I need to call observe again to once more access properties that I'm interested in, and then have my onChange fire again when the properties change.

The pattern here essentially is to make use of the state you’re observing in that first closure.

For example, if you're observing a String and you want to perform a search action when the text changes, you would do that inside of withObservationTracking's first closure. Then when changes occur, you can re-subscribe from the onChange closure.

While all of this is not great, the worst part is that onChange is called with willSet semantics.

This means that the onChange closure is called before the properties you’re interested in have changed so you're going to always have access to the old value of a property and not the new one.

You could work around this by calling observe from a call to DispatchQueue.main.async.

Getting didSet semantics when using withObservationTracking

Since onChange is called before the properties we’re interested in have updated we need to postpone our work to the next runloop if we want to get access to new values. A common way to do this is by using DispatchQueue.main.async:

func observe() {
  withObservationTracking { 
    print("counter.count: \(counter.count)")
  } onChange: {
    DispatchQueue.main.async {
      self.observe()
    }
  }
}

The above isn’t pretty, but it works. Using an approach based on what’s shown here on the Swift forums, we can move this code into a helper function to reduce boilerplate:

public func withObservationTracking(execute: @Sendable @escaping () -> Void) {
    Observation.withObservationTracking {
        execute()
    } onChange: {
        DispatchQueue.main.async {
            withObservationTracking(execute: execute)
        }
    }
}

The usage of this function inside of observe() would look as follows:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    print("counter.count: \(counter.count)")
  }
}

With this simple wrapper that we wrote, we can now pass a single closure to withObservationTracking. Any properties that we've accessed inside of that closure are now automatically observed for changes, and our closure will keep running every time one of these properties change. Because we are capturing self weakly and we only access any properties when self is still around, we also support some form of cancellation.

Note that my approach is rather different from what's shown on the Swift forums. It's inspired by what's shown there, but the implementation shown on the forum actually doesn't support any form of cancellation. I figured that adding a little bit of support for cancellation was better than adding no support at all.

Observation and Swift 6

While the above works pretty decent for Swift 5 packages, if you try to use this inside of a Swift 6 codebase, you'll actually run into some issues... As soon as you turn on the Swift 6 language mode you’ll find the following error:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    // Capture of 'self' with non-sendable type 'CounterObserver?' in a `@Sendable` closure
    print("counter.count: \(counter.count)")
  }
}

The error message you’re seeing here tells you that withObservationTracking wants us to pass an @Sendable closure which means we can’t capture non-Sendable state (read this post for an in-depth explanation of that error). We can’t change the closure to be non-Sendable because we’re using it in the onChange closure of the official withObservationTracking and as you might have guessed; onChange requires our closure to be sendable.

In a lot of cases we’re able to make self Sendable by annotating it with @MainActor so the object always runs its property access and functions on the main actor. Sometimes this isn’t a bad idea at all, but when we try and apply it on our example we receive the following error:

@MainActor
class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { [weak self] in
      guard let self else { return }
      // Main actor-isolated property 'counter' can not be referenced from a Sendable closure
      print("counter.count: \(counter.count)")
    }
  }
}

We can make our code compile by wrapping access in a Task that also runs on the main actor but the result of doing that is that we’d asynchronously access our counter and we’ll drop incoming events.

Sadly, I haven’t found a solution to using Observation with Swift 6 in this manner without leveraging @unchecked Sendable since we can’t make CounterObserver conform to Sendable since the @Observable class we’re accessing can’t be made Sendable itself (it has mutable state).

In Summary

While Observation works fantastic for SwiftUI apps, there’s a lot of work to be done for it to be usable from other places. Overall I think Combine’s publishers (and @Published in particular) provide a more usable way to subscribe to changes on a specific property; especially when you want to use the Swift 6 language mode.

I hope this post has shown you some options for using Observation, and that it has shed some light on the issues you might encounter (and how you can work around them).

If you’re using withObservationTracking successfully in a Swift 6 app or package, I’d love to hear from you.

Solving “Main actor-isolated property can not be referenced from a Sendable closure” in Swift

When you turn on strict concurrency checking or you start using the Swift 6 language mode, there will be situations where you run into an error that looks a little bit like the following:

Main actor-isolated property can not be referenced from a Sendable closure

What this error tells us is that we're trying to use something that we're only supposed to use on or from the main actor inside of a closure that's supposed to run pretty much anywhere. So that could be on the main actor or it could be somewhere else.

The following code is an example of code that we could have that results in this error:

@MainActor
class ErrorExample {
  var count = 0

  func useCount() {
    runClosure {
      print(count)
    }
  }

  func runClosure(_ closure: @Sendable () -> Void) {
    closure()
  }
}

Of course, this example is very contrived. You wouldn't actually write code like this, but it is not unlikely that you would want to use a main actor isolated property in a closure that is sendable inside of a larger system. So, what can we do to fix this problem?

The answer, unfortunately, is not super straightforward because the fix will depend on how much control we have over this sendable closure.

Fixing the error when you own all the code

If we completely own this code, we could actually change the function that takes the closure to become an asynchronous function that can actually await access to the count property. Here's what that would look like:

func useCount() {
  runClosure {
    await print(count)
  }
}

func runClosure(_ closure: @Sendable @escaping () async -> Void) {
  Task {
    await closure()
  }
}

By making the closure asynchronous, we can now await our access to count, which is a valid way to interact with a main actor isolated property from a different isolation context. However, this might not be the solution that you're looking for. You might not want this closure to be async, for example. In that case, if you own the codebase, you could @MainActor annotate the closure. Here's what that looks like:

@MainActor
class ErrorExample {
  var count = 0

  func useCount() {
    runClosure {
      print(count)
    }
  }

  func runClosure(_ closure: @Sendable @MainActor () -> Void) {
    closure()
  }
}

Because the closure is now both @Sendable and isolated to the main actor, we're free to run it and access any other main actor isolated state inside of the closure that's passed to runClosure. At this point count is main actor isolated due to its containing type being main actor isolated, runClosure itself is main actor isolated due to its unclosing type being main actor isolated, and the closure itself is now also main actor isolated because we added an explicit annotation to it.

Of course this only works when you want this closure to run on the main actor and if you fully control the code.

If you don't want the closure to run on the main actor and you own the code, the previous solution would work for you.

Now let's take a look at what this looks like if you don't own the function that takes this sendable closure. In other words, we're not allowed to modify the runClosure function, but we still need to make this project compile.

Fixing the error without modifying the receiving function

When we're only allowed to make changes to the code that we own, which in this case would be the useCount function, things get a little bit trickier. One approach could be to kick off an asynchronous task inside of the closure and it'll work with count there. Here's what this looks like:

func useCount() {
  runClosure {
    Task {
      await print(count)
    }
  }
}

While this works, it does introduce concurrency into a system where you might not want to have any concurrency. In this case, we are only reading the count property, so what we could actually do is capture count in the closure's capture list so that we access the captured value rather than the main actor isolated value. Here is what that looks like.

func useCount() {
  runClosure { [count] in
    print(count)
  }
}

This works because we're capturing the value of count when the closure is created, rather than trying to read it from inside of our sendable closure. For read-only access, this is a solid solution that will work well for you. However, we could complicate this a little bit and try to mutate count which poses a new problem since we're only allowed to mutate count from inside of the main actor:

func useCount() {
  runClosure {
    // Main actor-isolated property 'count' can not be mutated from a Sendable closure
    count += 1
  }
}

We're now running into the following error:

Main actor-isolated property 'count' can not be mutated from a Sendable closure

I have dedicated post about running work on the main actor where I explore several ways to solve this specific error.

Out of the three solutions proposed in that post, the only one that would work for us is the following:

Use MainActor.run or an unstructured task to mutate the value from the main actor

Since our closure isn't async already, we can't use MainActor.run because that's an async function that we'd have to await.

Similar to how you would use DispatchQueue.main.async in old code, in your new code you can use Task { @MainActor in } to run work on the main actor:

func useCount() {
  runClosure { 
    Task { @MainActor in
      count += 1
    }
  }
}

The fact that we're forced to introduce a synchronicity here is not something that I like a lot. However, it is an effect of using actors in Swift concurrency. Once you start introducing actors into your codebase, you also introduce a synchronicity because you can synchronously interact with actors from multiple isolation contexts. An actor always needs to have its state and functions awaited when you access it from outside of the actor. The same applies when you isolate something to the main actor because when you isolate something to the main actor it essentially becomes part of the main actor's isolation context, and we have to asynchronously interact with main actor isolated state from outside of the main actor.

I hope this post gave you some insights into how you can fix errors related to capturing main actor isolated state in a sendable closure. If you're running into scenarios where none of the solutions shown here are relevant I'd love if you could share them with me.

Is 2025 the year to fully adopt Swift 6?

When Apple released Xcode 16 last year, they made the Swift 6 compiler available along with it. This means that we can create new projects using Swift 6 and its compile-time data race protections.

However, the big question for many developers is: Is 2025 the right time to adopt Swift 6 fully, or should we stick with Swift 5 for now?

In this post, I won’t give you a definitive answer. Instead, I’ll share my perspective and reasoning to help you decide whether adopting Swift 6 is right for you and your project(s).

The right answer depends on loads of variables like the project you work on, the team you work with, and your knowledge of Swift Concurrency.

Xcode 16, existing projects, and Swift 6

If you’ve opened an existing project in Xcode 16, you might not have noticed any immediate changes. While the Swift 6 compiler is used in Xcode 16 for all projects, Xcode defaults to the Swift 5 language mode for existing projects.

If you’ve experienced previous major migrations in Swift, you’ll remember that Xcode would usually prompt you to make changes to your project in order to make sure your project still works. This happened for the migration from Swift 1.2 to Swift 2, and from Swift 2 to Swift 3.

We got a new compiler, and we were forced to adopt the new Swift language version that came along with it.

Since then, the compiler has gained some “language modes”, and the Swift 6 compiler comes with a Swift 5 language mode.

The Swift 5 language mode allows the Swift 6 compiler to function without enforcing all the stricter rules of Swift 6. For example, the Swift 5 language mode will make it so that compile-time data race protections are not turned on.

So, when we talk about adopting Swift 6, we’re really talking about opting into the Swift 6 language mode.

Existing projects that are opened in Xcode 16 will, automatically, use the Swift 5 language mode. That’s why your project still compiles perfectly fine without adopting Swift 6.

What about new projects?

New projects in Xcode 16 also default to Swift 5 language mode. However, Swift packages created with the Swift 6 toolchain default to Swift 6 language mode unless explicitly configured otherwise. This distinction is important, because when you create new packages you’re operating in a different language mode than project (and that’s perfectly fine).

If you’re interested in enabling the Swift 6 language mode for existing projects or packages, I have some blog posts about that here:

Challenges of Adopting Swift 6

Switching to Swift 6 language mode can make projects that compiled just fine with Swift 5 break completely. For example, you’ll run into errors about capturing non-sendable parameters, sendable closures, and actor isolation.

Some fixes are straightforward—like making an immutable object explicitly sendable or refactoring objects that are used in async functions into actors. However, other issues, especially those involving crossing isolation boundaries, can be much trickier to fix.

For example, adding actors to resolve sendability errors often requires refactoring synchronous code into asynchronous code, leading to a ripple effect throughout your codebase. Even seemingly simple interactions with an actor require await, even for non-async functions because actors operate in their own isolation contexts.

Adopting actors is typically a task that will take much, much longer than you might expect initially.

Resolving errors with @MainActor

A common workaround is to liberally apply @MainActor annotations. While this reduces concurrency-related errors by forcing most code to run on the main thread, it’s not always the solution that you’re looking for. While not inherently wrong, this approach should be used with caution.

Reducing crossing of isolation boundaries

Apple recognizes the challenges of adopting Swift 6, especially for existing projects. One significant aspect of Swift Concurrency that can make adoption tricky is how non-isolated asynchronous functions inherit isolation contexts. Currently, nonisolated async functions run on a background thread unless explicitly isolated, which can lead to unnecessary crossing of isolation boundaries.

Apple is exploring ways for such functions to inherit the caller’s isolation context, potentially reducing sendability errors and making adoption of Swift 6 much more straightforward.

So, should we adopt Swift 6?

For existing projects, I recommend proceeding cautiously. Stick with Swift 5 language mode unless:

• Your project is small and manageable for migration.

• You have a strong understanding of concurrency concepts and can commit to resolving sendability issues.

New projects can be built with Swift 6 language mode from the start, but be prepared for challenges, especially when interacting with Apple’s frameworks, which may lack full concurrency support.

If you’re modularizing your codebase with Swift packages, I recommend using Swift 6 language mode for your (new) packages, as packages generally have fewer dependencies on Apple’s frameworks and are easier to adapt and you can have Swift 5 and Swift 6 modules in the same project.

Getting ready to adopt Swift 6

Before adopting Swift 6, ensure you understand:

• Sendability and how to resolve related errors.

• The use of actors and their impact on isolation and asynchronicity.

• How to navigate ambiguous compiler errors.

I cover all of these topics and more in my book, Practical Swift Concurrency as well as my workshops. You can also review and study Swift evolution proposals and forum discussions to get a good sense of how Swift Concurrency works.

If you’ve started adopting Swift 6 or decided to hold off, I’d love to hear your experiences! Connect with me on X, BlueSky, or Mastodon.

Sending vs Sendable in Swift

With Swift 6, we have an entirely new version of the language that has all kinds of data race protections built-in. Most of these protections were around with Swift 5 in one way or another and in Swift 6 they've refined, updated, improved, and expanded these features, making them mandatory. So in Swift 5 you could get away with certain things where in Swift 6 these are now compiler errors.

Swift 6 also introduces a bunch of new features, one of these is the sending keyword. Sending closely relates to Sendable, but they are pretty different in terms of why they're used, what they can do, and which problems they tend to solve.

In this post, I would like to explore the similarities and differences between Sendable and sending. By the end of this post, you will understand why the Swift team decided to change the closures that you pass to tasks, continuations, and task groups to be sending instead of @Sendable.

If you're not fully up to date on Sendable, I highly recommend that you check out my post on Sendable and @Sendable closures. In this post, it's most relevant for you to understand the @Sendable closures part because we're going to be looking at a comparison between a @Sendable closure and a sending argument.

Understanding the problem that’s solved by sending

In Swift 5, we didn't have the sending keyword. That meant that if we wanted to pass a closure or a value from one place to another safely, we would do that with the sendable annotation. So, for example, Task would have been defined a little bit like this in Swift 5.

public init(
  priority: TaskPriority? = nil,
  operation: @Sendable @escaping () async -> Success
)

This initializer is copied from the Swift repository with some annotations stripped for simplicity.

Notice that the operation argument takes a @Sendable closure.

Taking a @Sendable closure for something like a Task means that that closure should be safe to call from any other tasks or isolation context. In practice, this means that whatever we do and capture inside of that closure must be safe, or in other words, it must be Sendable.

So, a @Sendable closure can essentially only capture Sendable things.

This means that the code below is not safe according to the Swift 5.10 compiler with strict concurrency warnings enabled.

Note that running the example below in Xcode 16 with the Swift 6 compiler in Swift 5 mode will not throw any errors. That's because Task has changed its operation to be sending instead of @Sendable at a language level regardless of language mode.

So, even in Swift 5 language mode, Task takes a sending operation.

// The example below requires the Swift 5 COMPILER to fail
// Using the Swift 5 language mode is not enough
func exampleFunc() {
  let isNotSendable = MyClass()

  Task {
      // Capture of 'isNotSendable' with non-sendable type 'MyClass' in a `@Sendable` closure
    isNotSendable.count += 1
  }
}

If you want to explore this compiler error in a project that uses the Swift 6 compiler, you can define your own function that takes a @Sendable closure instead of a Task:

public func sendableClosure(
  _ closure: @Sendable () -> Void
  ) {
  closure()
}

If you call that instead of Task, you’ll see the compiler error mentioned earlier.

The compiler error is correct. We are taking something that isn't sendable and passing it into a task which in Swift 5 still took a @Sendable closure.

The compiler doesn't like that because the compiler says, "If this is a sendable closure, then it must be safe to call this from multiple isolation contexts, and if we're capturing a non-sendable class, that is not going to work."

This problem is something that you would run into occasionally, especially with @Sendable closures.

Our specific usage here is totally safe though. We're creating an instance of MyClass inside of the function that we're making a task or passing that instance of MyClass into the task.

And then we're never accessing it outside of the task or after we make the task anymore because by the end of exampleFunc this instance is no longer retained outside of the Task closure.

Because of this, there's no way that we're going to be passing isolation boundaries here; No other place than our Task has access to our instance anymore.

That’s where sending comes in…

Understanding sending arguments

In Swift 6, the team added a feature that allows us to tell the compiler that we intend to capture whatever non-sendable state we might receive and don't want to access it elsewhere after capturing it.

This allows us to pass non-sendable objects into a closure that needs to be safe to call across isolation contexts.

In Swift 6, the code below is perfectly valid:

func exampleFunc() async {
  let isNotSendable = MyClass()

  Task {
    isNotSendable.count += 1
  }
}

That’s because Task had its operation changed from being @Sendable to something that looks a bit as follows:

public init(
  priority: TaskPriority? = nil,
  operation: sending @escaping () async -> Success
)

Again, this is a simplified version of the actual initializer. The point is for you to see how they replaced @Sendable with sending.

Because the closure is now sending instead of @sendable, the compiler can check that this instance of MyClass that we're passing into the task is not accessed or used after the task captures it. So while the code above is valid, we can actually write something that is no longer valid.

For example:

func exampleFunc() async {
  let isNotSendable = MyClass()

  // Value of non-Sendable type ... accessed after being transferred; 
  // later accesses could race
  Task {
    isNotSendable.count += 1
  }

  // Access can happen concurrently
  print(isNotSendable.count)
} 

This change to the language allows us to pass non-sendable state into a Task, which is something that you'll sometimes want to do. It also makes sure that we're not doing things that are potentially unsafe, like accessing non-sendable state from multiple isolation contexts, which is what happens in the example above.

If you are defining your own functions that take closures that you want to be safe to call from multiple isolation contexts, you’ll want to mark them as sending.

Defining your own function that takes a sending closure looks as follows:

public func sendingClosure(
  _ closure: sending () -> Void
) {
  closure()
}

The sending keyword is added as a prefix to the closure type, similar to where @escaping would normally go.

In Summary

You probably won't be defining your own sending closures or your own functions that take sending arguments frequently. The Swift team has updated the initializers for tasks, detached tasks, the continuation APIs, and the task group APIs to take sending closures instead of @Sendable closures. Because of this, you'll find that Swift 6 allows you to do certain things that Swift 5 wouldn't allow you to do with strict concurrency enabled.

I think it is really cool to know and understand how sending and @Sendable work.

I highly recommend that you experiment with the examples in this blog post by defining your own sending and @Sendable closures and seeing how each can be called and how you can call them from multiple tasks. It's also worth exploring how and when each options stops working so you're aware of their limitations.

Further reading

Mocking a network connection in your Swift Tests

Unit tests should be as free of external dependencies as possible. This means that you want to have full control over everything that happens in your tests.

For example, if you're working with a database, you want the database to be empty or in some predefined state before your test starts. You operate on the database during your test and after your test the database can be thrown away.

By making your tests not depend on external state, you make sure that your tests are repeatable, can run in parallel and don't depend on one test running before another test.

Historically, something like the network is particularly hard to use in tests because what if your test runs but you don't have a network connection, or what if your test runs during a time where the server that you're talking to has an outage? Your tests would now fail even though there's nothing wrong with your code. So you want to decouple your tests from the network so that your tests become repeatable, independent and run without relying on some external server.

In this post, I'm going to explore two different options with you.

One option is to simply mock out the networking layer entirely. The other option uses something called URLProtocol which allows us to take full control over the requests and responses inside of URLSession, which means we can actually make our tests work without a network connection and without removing URLSession from our tests.

Defining the code that we want to test

In order to properly figure out how we're going to test our code, we should probably define the objects that we would like to test. In this case, I would like to test a pretty simple view model and networking pair.

So let's take a look at the view model first. Here's the code that I would like to test for my view model.

@Observable
class FeedViewModel {
  var feedState: FeedState = .notLoaded
  private let network: NetworkClient

  init(network: NetworkClient) {
    self.network = network
  }

  func fetchPosts() async {
    feedState = .loading
    do {
      let posts = try await network.fetchPosts()
      feedState = .loaded(posts)
    } catch {
      feedState = .error(error)
    }
  }

  func createPost(withContents contents: String) async throws -> Post {
    return try await network.createPost(withContents: contents)
  }
}

In essence, the tests that I would like to write here would confirm that calling fetchPost would actually update my list of posts as new posts become available.

Planning the tests

I would probably call fetchPost to make sure that the feed state becomes a value that I expect, then I would call it again and return different posts from the network, making sure that my feed state updates accordingly. I would probably also want to test that if any error would be thrown during the fetching phase, that my feed state will become the corresponding error type.

So to boil that down to a list, here's the test I would write:

  • Make sure that I can fetch posts
  • Make sure that posts get updated if the network returns new posts
  • Make sure that errors are handled correctly

I also have the create post function, which is a little bit shorter. It doesn't change the feed state.

What I would test there is that if I create a post with certain contents, a post with the provided contents is actually what is returned from this function.

I've already implemented the networking layer for this view model, so here's what that looks like.

class NetworkClient {
  let urlSession: URLSession
  let baseURL: URL = URL(string: "https://practicalios.dev/")!

  init(urlSession: URLSession) {
    self.urlSession = urlSession
  }

  func fetchPosts() async throws -> [Post] {
    let url = baseURL.appending(path: "posts")
    let (data, _) = try await urlSession.data(from: url)

    return try JSONDecoder().decode([Post].self, from: data)
  }

  func createPost(withContents contents: String) async throws -> Post {
    let url = baseURL.appending(path: "create-post")
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    let body = ["contents": contents]
    request.httpBody = try JSONEncoder().encode(body)

    let (data, _) = try await urlSession.data(for: request)

    return try JSONDecoder().decode(Post.self, from: data)
  }
}

In an ideal world, I would be able to test that calling fetchPosts on my network client is actually going to construct the correct URL and that it will use that URL to make a call to URLSession. Similarly for createPost, I would want to make sure that the HTTP body that I construct is valid and contains the data that I intend to send to the server.

There are essentially two things that we could want to test here:

  1. The view model, making sure that it calls the correct functions of the network.
  2. The networking client, making sure that it makes the correct calls to the server.

Replacing your networking layer with a mock for testing

A common way to test code that relies on a network is to simply remove the networking portion of it altogether. Instead of depending on concrete networking objects, we would depend on protocols.

Abstracting our dependencies with protocols

Here's what that looks like if we apply this to our view model.

protocol Networking {
  func fetchPosts() async throws -> [Post]
  func createPost(withContents contents: String) async throws -> Post
}

@Observable
class FeedViewModel {
  var feedState: FeedState = .notLoaded
  private let network: any Networking

  init(network: any Networking) {
    self.network = network
  }

  // functions are unchanged
}

The key thing that changed here is that instead of depending on a network client, we depend on the Networking protocol. The Networking protocol defines which functions we can call and what the return types for those functions will be.

Since the functions that we've defined are already defined on NetworkClient, we can update our NetworkClient to conform to Networking.

class NetworkClient: Networking {
  // No changes to the implementation
}

In our application code, we can pretty much use this network client passage to our feed view model and nothing would really change. This is a really low-key way to introduce testability into our codebase for the feed view model.

Mocking the network in a test

Now let's go ahead and write a test that sets up our feed view model so that we can start testing it.

class MockNetworkClient: Networking {
  func fetchPosts() async throws -> [Post] {
    return []
  }

  func createPost(withContents contents: String) async throws -> Post {
    return Post(id: UUID(), contents: contents)
  }
}

struct FeedViewModelTests {
  @Test func testFetchPosts() async throws {
    let viewModel = FeedViewModel(network: MockNetworkClient())

    // we can now start testing the view model
  }
}

Now that we have a setup that we can test, it's time to take another look at our testing goals for the view model. These testing goals are what's going to drive our decisions for what we'll put in our MockNetworkClient.

Writing our tests

These are the tests that I wanted to write for my post fetching logic:

  • Make sure that I can fetch posts
  • Make sure that posts get updated if the network returns new posts
  • Make sure that errors are handled correctly

Let’s start adding them one-by-one.

In order to test whether I can fetch posts, my mock network should probably return some posts:

class MockNetworkClient: Networking {
  func fetchPosts() async throws -> [Post] {
    return [
      Post(id: UUID(), contents: "This is the first post"),
      Post(id: UUID(), contents: "This is post number two"),
      Post(id: UUID(), contents: "This is post number three")
    ]
  }

  // ...
}

With this in place, we can test our view model to see if calling fetchPosts will actually use this list of posts and update the feed state correctly.

@Test func testFetchPosts() async throws {
  let viewModel = FeedViewModel(network: MockNetworkClient())

  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 3)
}

The second test would have us call fetchPosts twice to make sure that we update the list of posts in the view model.

In order for us to control our tests fully, we should probably have a way to tell the mock network what list of posts it should return when we call fetchPost. Let’s add a property to the mock that allows us to specify a list of posts to return from within our tests:

class MockNetworkClient: Networking {
  var postsToReturn: [Post] = []

  func fetchPosts() async throws -> [Post] {
    return postsToReturn
  }

  func createPost(withContents contents: String) async throws -> Post {
    return Post(id: UUID(), contents: contents)
  }
}

And now we can write our second test as follows:

@Test func fetchPostsShouldUpdateWithNewResponses() async throws {
  let client = MockNetworkClient()
  client.postsToReturn = [
    Post(id: UUID(), contents: "This is the first post"),
    Post(id: UUID(), contents: "This is post number two"),
    Post(id: UUID(), contents: "This is post number three")
  ]

  let viewModel = FeedViewModel(network: client)
  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 3)

  client.postsToReturn = [
    Post(id: UUID(), contents: "This is a new post")
  ]

  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 1)
}

The test is now more verbose but we are in full control over the responses that our mock network will provide.

Our third test for fetching posts is to make sure that errors are handled correctly. This means that we should apply another update to our mock. The goal is to allow us to define whether our call to fetchPosts should return a list of posts or throw an error. We can use Result for this:

class MockNetworkClient: Networking {
  var fetchPostsResult: Result<[Post], Error> = .success([])

  func fetchPosts() async throws -> [Post] {
    return try fetchPostsResult.get()
  }

  func createPost(withContents contents: String) async throws -> Post {
    return Post(id: UUID(), contents: contents)
  }
}

Now we can make our fetch posts calls succeed or fail as needed in the tests. Our tests would now need to be updated so that instead of just passing a list of posts to return, we're going to provide success with the list. Here's what that would look like for our first test (I’m sure you can update the longer test based on this example).

@Test func testFetchPosts() async throws {
  let client = MockNetworkClient()
  client.fetchPostsResult = .success([
    Post(id: UUID(), contents: "This is the first post"),
    Post(id: UUID(), contents: "This is post number two"),
    Post(id: UUID(), contents: "This is post number three")
  ])

  let viewModel = FeedViewModel(network: client)

  await viewModel.fetchPosts()

  guard case .loaded(let posts) = viewModel.feedState else {
    Issue.record("Feed state is not set to .loaded")
    return
  }

  #expect(posts.count == 3)
}

Data that we can provide a success or failure for our tests. We can actually go on ahead and tell our tests to throw a specific failure.

@Test func fetchPostsShouldUpdateWithErrors() async throws {
  let client = MockNetworkClient()
  let expectedError = NSError(domain: "Test", code: 1, userInfo: nil)
  client.fetchPostsResult = .failure(expectedError)

  let viewModel = FeedViewModel(network: client)
  await viewModel.fetchPosts()

  guard case .error(let error) = viewModel.feedState else {
    Issue.record("Feed state is not set to .error")
    return
  }

  #expect(error as NSError == expectedError)
}

We now have three tests that test our view model.

What's interesting about these tests is that they all depend on a mock network. This means that we're not relying on a network connection. But this also doesn't mean that our view model and network client are going to work correctly.

We haven't tested that our actual networking implementation is going to construct the exact requests that we expect it to create. In order to do this we can leverage something called URLProtocol.

Mocking responses with URLProtocol

Knowing that our view model works correctly is really good. However, we also want to make sure that the actual glue between our app and the server works correctly. That means that we should be testing our network client as well as the view model.

We know that we shouldn't be relying on the network in our unit tests. So how do we eliminate the actual network from our networking client?

One approach could be to create a protocol for URLSession and stuff everything out that way. It's an option, but it's not one that I like. I much prefer to use something called URLProtocol.

When we use URLProtocol to mock out our network, we can tell URLSession that we should be using our URLProtocol when it's trying to make a network request.

This allows us to take full control of the response that we are returning and it means that we can make sure that our code works without needing the network. Let's take a look at an example of this.

Before we implement everything that we need for our test, let's take a look at what it looks like to define an object that inherits from URLProtocol. I'm implementing a couple of basic methods that I will need, but there are other methods available on an object that inherits from URLProtocol.

I highly recommend you take a look at Apple's documentation if you're interested in learning about that.

Setting up ur URLProtocol subclass

For the tests that we are interested implementing, this is the skeleton class that I'll be working from:

class NetworkClientURLProtocol: URLProtocol {
  override class func canInit(with request: URLRequest) -> Bool {
    return true
  }

  override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    return request
  }

  override func startLoading() {
    // we can perform our fake request here
  }
}

In the startLoading function, we're supposed to execute our fake network call and inform the client (which is a property that we inherit from URLProtocol) that we finished loading our data.

So the first thing that we need to do is implement a way for a user of our fake network to provide a response for a given URL. Again, there are many ways to go about this. I'm just going to use the most basic version that I can come up with to make sure that we don't get bogged down by details that will vary from project to project.

struct MockResponse {
  let statusCode: Int
  let body: Data
}

class NetworkClientURLProtocol: URLProtocol {
  // ...

  static var responses: [URL: MockResponse] = [:]
  static var validators: [URL: (URLRequest) -> Bool] = [:]
  static let queue = DispatchQueue(label: "NetworkClientURLProtocol")

  static func register(
    response: MockResponse, requestValidator: @escaping (URLRequest) -> Bool, for url: URL
  ) {
    queue.sync {
      responses[url] = response
      validators[url] = requestValidator
    }
  }

  // ...
}

By adding this code to my NetworkClientURLProtocol, I can register responses and a closure to validate URLRequest. This allows me to test whether a given URL results in the expected URLRequest being constructed by the networking layer. This is particularly useful when you’re testing POST requests.

Note that we need to make our responses and validators objects static. That's because we can't access the exact instance of our URL protocol that we're going to use before the request is made. So we need to register them statically and then later on in our start loading function we'll pull out the relevant response invalidator. We need to make sure that we synchronize this through a queue so we have multiple tests running in parallel. We might run into issues with overlap.

Before we implement the test, let’s complete our implementation of startLoading:

class NetworkClientURLProtocol: URLProtocol {
  // ...

  override func startLoading() {
    // ensure that we're good to...
    guard let client = self.client,
      let requestURL = self.request.url,
      let validator = validators[requestURL],
      let response = responses[requestURL]
    else { 
      Issue.record("Attempted to perform a URL Request that doesn't have a validator and/or response")
      return 
    }

        // validate that the request is as expected
    #expect(validator(self.request))

    // construct our response object
    guard let httpResponse = HTTPURLResponse(
      url: requestURL, 
      statusCode: response.statusCode, httpVersion: nil,
      headerFields: nil
    ) else {
      Issue.record("Not able to create an HTTPURLResponse")
      return 
    }

    // receive response from the fake network
    client.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed)
    // inform the URLSession that we've "loaded" data
    client.urlProtocol(self, didLoad: response.body)
    // complete the request
    client.urlProtocolDidFinishLoading(self)
  }
}

The code contains comments on what we’re doing. While you might not have seen this kind of code before, it should be relatively self-explanatory.

Implementing a test that uses our URLProtocol subclass

Now that we’ve got startLoading implemented, let’s try and use this NetworkClientURLProtocol in a test…

class FetchPostsProtocol: NetworkClientURLProtocol { }

struct NetworkClientTests {
  func makeClient(with protocolClass: NetworkClientURLProtocol.Type) -> NetworkClient {
    let configuration = URLSessionConfiguration.default
    configuration.protocolClasses = [protocolClass]
    let session = URLSession(configuration: configuration)
    return NetworkClient(urlSession: session)
  }

  @Test func testFetchPosts() async throws {
    let networkClient = makeClient(with: FetchPostsProtocol.self)

    let returnData = try JSONEncoder().encode([
      Post(id: UUID(), contents: "This is the first post"),
      Post(id: UUID(), contents: "This is post number two"),
      Post(id: UUID(), contents: "This is post number three"),
    ])

    let fetchPostsURL = URL(string: "https://practicalios.dev/posts")!

    FetchPostsProtocol.register(
      response: MockResponse(statusCode: 200, body: returnData),
      requestValidator: { request in
        return request.url == fetchPostsURL
      },
      for: fetchPostsURL
    )

    let posts = try await networkClient.fetchPosts()
    #expect(posts.count > 0)
  }
}

The first thing I'm doing in this code is creating a new subclass of my NetworkClientProtocol. The reason I'm doing that is because I might have multiple tests running at the same time.

For that reason, I want each of my Swift test functions to get its own class. This might be me being a little bit paranoid about things overlapping in terms of when they are called, but I find that this creates a nice separation between every test that you have and the actual URLProtocol implementation that you're using to perform your assertions.

The goal of this test is to make sure that when I ask my network client to go fetch posts, it actually performs a request to the correct URL. And given a successful response that contains data in a format that’s expected from the server’s response, we're able to decode the response data into a list of posts.

We're essentially replacing the server in this example, which allows us to take full control over verifying that we're making the correct request and also have full control over whatever the server would return for that request.

Testing a POST request with URLProtocol

Now let’s see how we can write a test that makes sure that we’re sending the correct request when we’re trying to create a post.

struct NetworkClientTests {
  // ...

  @Test func testCreatePost() async throws {
    let networkClient = makeClient(with: CreatePostProtocol.self)

    // set up expected data
    let content = "This is a new post"
    let expectedPost = Post(id: UUID(), contents: content)
    let returnData = try JSONEncoder().encode(expectedPost)
    let createPostURL = URL(string: "https://practicalios.dev/create-post")!

    // register handlers
    CreatePostProtocol.register(
      response: MockResponse(statusCode: 200, body: returnData),
      requestValidator: { request in
        // validate basic setup
        guard 
          let httpBody = request.streamedBody,
          request.url == createPostURL,
          request.httpMethod == "POST" else {
            Issue.record("Request is not a POST request or doesn't have a body")
            return false
        }

        // ensure body is correct
        do {
          let decoder = JSONDecoder()
          let body = try decoder.decode([String: String].self, from: httpBody)
          return body == ["contents": content]
        } catch {
          Issue.record("Request body is not a valid JSON object")
          return false
        }
      },
      for: createPostURL
    )

    // perform network call and validate response
    let post = try await networkClient.createPost(withContents: content)
    #expect(post == expectedPost)
  }
}

There's quite a lot of code here, but overall it follows a pretty similar step to before. There's one thing that I want to call your attention to, and that is the line where I extract the HTTP body from my request inside of the validator. Instead of accessing httpBody, I'm accessing streamedBody. This is not a property that normally exists on URLRequest, so let's talk about why I need that for a moment.

When you create a URLRequest and execute that with URLSession, the httpBody that you assign is converted to a streaming body.

So when you access httpBody inside of the validator closure that I have, it's going to be nil.

Instead of accessing that, we need to access the streaming body, gather the data, and return alll data.

Here's the implementation of the streamedBody property that I added in an extension to URLRequest:

extension URLRequest {
  var streamedBody: Data? {
    guard let bodyStream = httpBodyStream else { return nil }
    let bufferSize = 1024
    let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
    var data = Data()
    bodyStream.open()
    while bodyStream.hasBytesAvailable {
      let bytesRead = bodyStream.read(buffer, maxLength: bufferSize)
      data.append(buffer, count: bytesRead)
    }
    bodyStream.close()
    return data
  }
}

With all this in place, I'm able to now check that my network client constructs a fully correct network request that is being sent to the server and that if the server responds with a post like I expect, I'm actually able to handle that.

So at this point, I have tests for my view model (where I mock out the entire networking layer to make sure that the view model works correctly) and I have tests for my networking client to make sure that it performs the correct requests at the correct times.

In Summary

Testing code that has dependencies is always a little bit tricky. When you have a dependency you'll want to mock it out, stub it out, remove it or otherwise hide it from the code that you're testing. That way you can purely test whether the code that you're interested in testing acts as expected.

In this post we looked at a view model and networking object where the view model depends on the network. We mocked out the networking object to make sure that we could test our view model in isolation.

After that we also wanted to write some tests for the networking object itself. To do that, we used a URLProtocol object. That way we could remove the dependency on the server entirely and fully run our tests in isolation. We can now test that our networking client makes the correct requests and handles responses correctly as well.

This means that we now have end-to-end testing for a view model and networking client in place.

I don’t often leverage URLProtocol in my unit tests; it’s mainly in complex POST requests or flows that I’m interested in testing my networking layer this deeply. For simple requests I tend to run my app with Proxyman attached and I’ll verify that my requests are correct manually.

Testing completion handler based code in Swift Testing

Swift's new modern testing framework is entirely driven by asynchronous code. This means that all of our test functions are async and that we have to make sure that we perform all of our assertions “synchronously”.

This also means that completion handler-based code is not as straightforward to test as code that leverages structured concurrency.

In this post, we’ll explore two approaches that can be useful when you’re testing code that uses callbacks or completion handlers in Swift Testing.

First, we’ll look at the built-in confirmation method from the Swift Testing framework and why it might not be what you need. After that, we’ll look at leveraging continuations in your unit tests to test completion handler based code.

Testing async code with Swift Testing’s confirmations

I will start this section by stating that the main reason that I’m covering confirmation is that it’s present in the framework, and Apple suggests it as an option for testing async code. As you’ll learn in this section, confirmation is an API that’s mostly useful in specific scenarios that, in my experience, don’t happen all that often.

With that said, let’s see what confirmation can do for us.

Sometimes you'll write code that runs asynchronously and produces events over time.

For example, you might have a bit of code that performs work in various steps, and during that work, certain progress events should be sent down an AsyncStream.

As usual with unit testing, we're not going to really care about the exact details of our event delivery mechanism.

In fact, I'm going to show you how this is done with a closure instead of an async for loop. In the end, the details here do not matter. The main thing that we're interested in right now is that we have a process that runs and this process has some mechanism to inform us of events while this process is happening.

Here are some of the rules that we want to test:

  • Our object has an async method called createFile that kicks of a process that involves several steps. Once this method completes, the process is finished too.
  • The object also has a property onStepCompleted that we can assign a closure to. This closure is called for every completed step of our process.

The onStepCompleted closure will receive one argument; the completed step. This will be a value of type FileCreationStep:

enum FileCreationStep {
  case fileRegistered, uploadStarted, uploadCompleted
}

Without confirmation, we can write our unit test for this as follows:

@Test("File creation should go through all three steps before completing")
func fileCreation() async throws {
  var completedSteps: [FileCreationStep] = []
  let manager = RemoteFileManager(onStepCompleted: { step in
    completedSteps.append(step)
  })

  try await manager.createFile()
  #expect(completedSteps == [.fileRegistered, .uploadStarted, .uploadCompleted])
}

We can also refactor this code and leverage Apple’s confirmation approach to make our test look as follows:

@Test("File creation should go through all three steps before completing")
func fileCreation() async throws {
  try await confirmation(expectedCount: 3) { confirm in 
    var expectedSteps: [FileCreationStep] = [.fileRegistered, .uploadStarted, .uploadCompleted]

    let manager = RemoteFileManager(onStepCompleted: { step in
      #expect(expectedSteps.removeFirst() == step)
      confirm()
    })

    try await manager.createFile()
  }
}

As I’ve said in the introduction of this section; confirmation's benefits are not clear to me. But let’s go over what this code does…

We call confirmation and we provide an expected number of times we want a confirmation event to occur.

Note that we call the confirmation with try await.

This means that our test will not complete until the call to our confirmation completes.

We also pass a closure to our confirmation call. This closure receives a confirm object that we can call for every event that we receive to signal an event has occurred.

At the end of my confirmation closure I call try await manager.createFile(). This kicks off the process and in my onStepCompleted closure I verify that I’ve received the right step, and I signal that we’ve received our event by calling confirm.

Here’s what’s interesting about confirmation though…

We must call the confirm object the expected number of times before our closure returns.

This means that it’s not usable when you want to test code that’s fully completion handler based since that would mean that the closure returns before you can call your confirmation the expected number of times.

Here’s an example:

@Test("File creation should go through all three steps before completing")
func fileCreationCompletionHandler() async throws {
  await confirmation { confirm in 
    let expectedSteps: [FileCreationStep] = [.fileRegistered, .uploadStarted, .uploadCompleted]
    var receivedSteps: [FileCreationStep] = []

    let manager = RemoteFileManager(onStepCompleted: { step in
      receivedSteps.append(step)
    })

    manager.createFile {
      #expect(receivedSteps == expectedSteps)
      confirm()
    }
  }
}

Notice that I’m still awaiting my call to confirmation. Instead of 3 I pass no expected count. This means that our confirm should only be called once.

Inside of the closure, I’m running my completion handler based call to createFile and in its completion handler I check that we’ve received all expected steps and then I call confirm() to signal that we’ve performed our completion handler based work.

Sadly, this test will not work.

The closure returns before the completion handler that I’ve passed to createFile has been called. This means that we don’t call confirm before the confirmation’s closure returns, and that results in a failing test.

So, let’s take a look at how we can change this so that we can test our completion handler based code in Swift Testing.

Testing completion handlers with continuations

Swift concurrency comes with a feature called continuations. If you are not familiar with them, I'd highly recommend that you read my post where I go into how you can use continuations. For the remainder of this section, I'm going to assume that you know continuations basics. I will just look at how they work in the context of Swift testing.

The problem that we're trying to solve is essentially that we do not want our test function to return until our completion handler based code has fully executed. In the previous section, we saw how using a confirmation doesn't quite work because the confirmation closure returns before the file managers create file finishes its work and calls its completion handler.

Instead of a confirmation, we can have our test wait for a continuation. Inside of the continuation, we can call our completion handler based APIs and then resume the continuation when our callback is called and we know that we've done all the work that we need to do. Let's see what that looks like in a test.

@Test("File creation should go through all three steps before completing")
func fileCreationCompletionHandler() async throws {
  await withCheckedContinuation { continuation in
    let expectedSteps: [FileCreationStep] = [.fileRegistered, .uploadStarted, .uploadCompleted]
    var receivedSteps: [FileCreationStep] = []

    let manager = RemoteFileManager(onStepCompleted: { step in
      receivedSteps.append(step)
    })

    manager.createFile {
      #expect(receivedSteps == expectedSteps)
      continuation.resume(returning: ())
    }
  }
}

This test looks very similar to the test that you saw before, but instead of waiting for a confirmation, we're now calling the withCheckedContinuation function. Inside of the closure that we passed to that function, we perform the exact same work that we performed before.

However, in the createFile function’s completion handler, we resume the continuation only after we've made sure that the received steps from our onStepCompleted closure match with the steps to be expected.

So we're still testing the exact same thing, but this time our test is actually going to work. That's because the continuation will suspend our test until we resume the continuation.

When you're testing completion handler based code, I usually find that I will reach for this instead of reaching for a confirmation because a confirmation does not work for code that does not have something to await.

In Summary

In this post, we explored the differences between continuations and confirmations for testing asynchronous code.

You've learned that Apple's recommended approach for testing closure based asynchronous code is with confirmations. However, in this post, we saw that we have to call our confirm object before the confirmation closure returns, so that means that we need to have something asynchronous that we await for, which isn't always the case.

Then I showed you that if you want to test a more traditional completion handler based API, which is probably what you're going to be doing, you should be using continuations because these allow our tests to suspend.

We can resume a continuation when the asynchronous work that we were waiting for is completed and we’ve asserted the results of our asynchronous work are what we’d like them to be using the #expect or #require macros.

Testing requirements with #require in Swift Testing

In a previous post, I wrote about using the #expect macro to ensure that certain assertions you want to make about your code are true. We looked at testing boolean conditions as well as errors.

In this post, I would like to take a look at a macro that goes hand-in-hand with #expect and that is the #require macro.

The #require macro is used to ensure that certain conditions in your test are met, and to abort your test if these conditions are not met. The key difference between #expect and #require is that #expect will not cause a failed assertion to stop the test.

#require is much stricter. If we find one assertion to be untrue inside of the #require macro, we end the test because we don't think it makes sense to test any further.

In this post, we'll take a look at several applications of the #require macro. For example, we'll use #require to ensure that an optional value can be unwrapped. We'll also see how you can use #require to ensure that a specific error is or is not thrown. And of course, we'll also look at boolean conditions inside of #require.

Let's start by looking at Optional.

Unwrapping optionals with #require

Sometimes in our code we will have optional values. They're pretty much unavoidable in Swift and they're actually a really useful tool. In your test, it is quite likely that you'll want to make sure that a certain value exists before proceeding with your test. One way to do this would be to use the #expect macro and ensure that some property or value is not nil.

However, sometimes you'll want to take your optional value and use it as input for something else or you want to do further testing with that object. In that case, it makes sense to abort your test entirely if the optional happens to be nil.

We can use the #require macro for this, here’s how:

@Test func userIsReturned() async throws {
  let userStore = UserInfoStore()
  let user = User(name: "John")

  userStore.addUser(user: user)

  let returnedUser = try #require(userStore.getUser(withName: "John"), "User store should return the user that was added")
  #expect(returnedUser == user, "User store should return the user that was added")
}

The magic here is on the line where we create our let returnedUser. We use the #require macro and we call it with the try keyword.

That's because if the #require macro fails to unwrap the optional that is returned by getUser, the macro will throw an error and so our test will actually fail. This is quite useful when you really don't want to continue your test if whatever you're trying to require isn't there.

So in this case I want to compare the return user with the one that I've tried to store. I cannot do that if the user isn't there. So I want my test to not just fail when the optional that's returned by getUser is nil, I want this test case to end.

Now let’s imagine that I also want to end my test if the returned user and the stored user aren’t the same…

Checking boolean conditions with #require

In the previous section I used the following to line to make sure that my getUser function returned the correct user:

#expect(returnedUser == user, "User store should return the user that was added")

Notice how I'm using #expect to compare my returned user to my stored user.

This expectation will allow my test to continue running even if the expectation fails. This would allow me to perform multiple assertions on an object. For example, if I were to check whether the user name, the user's ID, and a bunch of other properties match, I would use #expect so that I can perform all assertions and see exactly which ones failed.

In this case I would want my test to fail and end if I didn’t get the right user back.

So I'm comparing the two users like before and I’ve replaced my #expect with #require. Here's what that looks like in a full test.

@Test func userIsReturned() async throws {
  let userStore = UserInfoStore()
  let user = User(name: "John")

  userStore.addUser(user: user)

  let returnedUser = try #require(userStore.getUser(withName: "John"), "User store should return the user that was added")
  try #require(returnedUser == user, "User store should return the user that was added")
  print("this won't run if I got the wrong user")
}

Notice that I had to prefix my #require with the try keyword, just like I had for getting my returned user on the line before.

The reason for that is if I didn't get the right user back and it doesn't match with the user that I just stored, my test will throw an error and end with a failure.

Overall, the APIs for #require and #expect are pretty similar, with the key difference being that #require needs the try keyword and your test ends if a requirement isn't met.

Now that we've seen how we can use this to unwrap optionals and check boolean conditions, the next step is to see how we can use it to check for certain errors being thrown.

Checking errors with #require

If you know how to check for errors with the #expect macro, you basically know how to it do with the #require macro too.

The key difference being once again if a requirement is not met your test case will stop.

If you want to learn more about checking for errors, I urge you to take a look at my blog post on the #expect macro. I don't want to duplicate everything that's in there in this post, so for an in-depth overview, you can take a look at that post.

In this post, I would just like to give you a brief rundown of what it looks like to check for errors with the #require macro.

So first let's see how we can assert that certain function throws an expected error with the #require macro.

I will be using the same example that I used in the previous post. We're going to check that giving an incorrect input to an object will actually throw the error that I want to receive.

@Test func errorIsThrownForIncorrectInput() async throws {
  let input = -1

  try #require(throws: ValidationError.valueTooSmall(margin: 1), "Values between 0 and 100 should be okay") {
    try checkInput(input)
  }
}

In this specific example, it might not make a ton of sense to use #require over #expect. However, if I were to have more code after this assertion and it wouldn't make sense to continue my test if the wrong error was thrown, then it makes total sense for me to use #require because I want to abandon the test because there's no point in continuing on.

Similar to the #expect macro, we can pass a specific error (like I did in the example above) or an error type (like ValidationError.self). If we want to assert that no error is thrown, we could pass Never.self as the error. type to make sure that our function call does not throw.

Similar to the #expect macro, you can use the #require macro to check whether a certain expression throws an error based on a more complicated evaluation.

For all the different overloads that exist on #require, I would like to redirect you to the #expect macro post because they are exactly the same for #require and #expect. The key difference is what happens when the assertion fails: #expect will allow your test to continue, but it will fail with an error on the line where your assertion failed. With #require, your test case will simply end on the line where something that you didn't expect actually happened.

In Summary

Overall, I quite like that Swift testing allows us to have a loose checking for assertions in the #expect macro, where we can validate that certain things are or are not correct without failing the entire test. That would allow you to make a whole bunch of assertions and see which ones fail, fixing one problem at a time (running your test again, fixing the next problem that shows up) is tedious.

The #require macro is really nice when you pretty much rely on something to be returned or something to be true before you can proceed.

For example, unwrapping an optional if you want to use whatever you're trying to unwrap to run further code and perform further assertions. It makes no sense to continue your test because you know that every single assertion that comes after it will fail, so I really like using #require for those kinds of situations and #expect for the ones where I can continue my test to collect more information about the results.

Asserting state with #expect in Swift Testing

I 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:

  1. The first argument is always going to be a Boolean value
  2. 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.

But what if we want to ensure that a certain function call does not throw an error?

For one, you can just call that function in your test:

@Test func noErrorIsThrown() async throws {
  try checkInput(5)
}

This is great, but when you're working with parameterized tests you'd have to write two logic paths in your test. It's not a huge deal but we can do better.

You can check for Never being thrown to check that no error is thrown in your test. Here's how that looks:

@Test func noErrorIsThrown() async throws {
  let input = 5
  #expect(throws: Never.self, "Values between 0 and 100 should be okay") {
    try checkInput(input)
  }
}

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.

Improving test coverage with parameterized tests in Swift testing

When you subscribe to the practice of test-driven development or just writing tests in general you'll typically find that you're going to be writing lots and lots of tests for pretty much everything in your codebase.

This includes testing that varying inputs on the same function or on the same object result in expected behavior. For example, if you have a function that takes user input and you want to make sure that you validate that a user has not entered a number greater than 100 or smaller than 0, you're going to want to test this function with values like -10, 0, 15, 90, 100, and 200 for example.

Writing a test for each input by hand will be quite repetitive and you're going to do pretty much the exact same things over and over. You'll have the same setup code, the same assertions, and the same teardown for every function. The difference is that for some inputs you might expect an error to be thrown, and for other inputs you might expect your function to return a value.

The behavior you’re testing is the same every single time.

If you prefer learning through video, this one's for you:

With Swift testing we can avoid repetition by through parameterized tests.

This means that we can run our tests multiple times with any number of predefined arguments. For example, you could pass all the values I just mentioned along with the error (if any) that you expect your function to throw.

This makes it quite easy for you to add more and more tests and in turn improve your test coverage and improve your confidence that the code does exactly what you want it to. This is a really good way to make sure that you're not accidentally adding bad code to your app because your unit tests simply weren’t extensive enough.

A plain test in Swift testing looks a little bit like this:

@Test("Verify that 5 is valid input")
func testCorrectValue() throws {
  #expect(try Validator.validate(input: 5), "Expected 5 to be valid")
}

The code above shows a very simple test, it passes the number 5 to a function and we expect that function to return true because 5 is a valid value.

In the code below we've added a second test that makes sure that entering -10 will throw an error.

@Test("Verify that -10 is invalid input")
func testTooSmall() throws {
  #expect(throws: ValidationError.valueTooSmall) {
    try Validator.validate(input: -10)
  }
}

As you can see the code is very repetitive and looks pretty much the same.

The only two differences are the input value and the error that is being thrown; no error versus a valueTooSmall error.

Here's how we can parameterize this test:

@Test(
  "Verify input validator rejects values smaller than 0 and larger than 100",
  arguments: [
    (input: -10, expectedError: ValidationError.valueTooSmall),
    (input: 0, expectedError: nil),
    (input: 15, expectedError: nil),
    (input: 90, expectedError: nil),
    (input: 100, expectedError: nil),
    (input: 200, expectedError: ValidationError.valueTooLarge),
  ]
)
func testRejectsOutOfBoundsValues(input: Int, expectedError: ValidationError?) throws {
  if let expectedError {
    #expect(throws: expectedError) {
      try Validator.validate(input: input)
    }
  } else {
    #expect(try Validator.validate(input: input), "Expected \(input) to be valid")
  }
}

We now have a list of values added to our test macro’s arguments. These values are passed to our test as function arguments which means that we can quite easily verify that all of these inputs yield the correct output.

Notice that my list of inputs is a list of tuples. The tuples contain both the input value as well as the expected error (or nil if I don’t expect an error to be thrown). Each value in my tuple becomes an argument to my test function. So if my tuples contain two values, my test should have two arguments.

Inside of the test itself I can write logic to have a slightly different expectation depending on my expected results.

This approach is really powerful because it allows me to easily determine that everything works as expected. I can add loads of input values without changing my test code, and this means that I have no excuse to not have an extensive test suite for my validator.

If any of the input values result in a failing test, Swift Testing will show me exactly which values resulted in a test failure which means that I’ll know exactly where to look for my bug.

In Summary

I think that parameterized tests are probably the feature of Swift testing that I am most excited about.

A lot of the syntax changes around Swift testing are very nice but they don't really give me that much new power. Parameterized testing on the other hand are a superpower.

Writing repetitive tests is a frustration that I've had with XCTest for a long time, and I've usually managed to work around it, but having proper support for it in the testing framework is truly invaluable.

Swift Testing basics explained

Swift 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.