How to plan a migration to Swift 6

Swift 6 has been available to us for the better part of a year now, and more and more teams are considering or looking at migrating to the Swift 6 language mode. This typically involves trying to turn on the language mode or turning on strict concurrency, seeing a whole bunch of warnings or errors, and then deciding that today is not the day to proceed with this migration.

Today I would like to propose an approach to how you can plan your migration in a way that won’t scare you out of attempting the migration before you’ve even started.

Before you go through this entire post expecting me to tell you how to go to Swift 6 within a matter of days or weeks, I'm afraid I'm going to have to disappoint you.

Migrating to Swift 6, for a lot of apps, is going to be a very slow and lengthy process and it's really a process that you don't want to rush.

Taking an initial inventory

Before you start to migrate your codebase, I would highly recommend that you take inventory of the state of your codebase. This means that you should take a look at how modularized your codebase is, which dependencies you have in your codebase, and maybe most importantly how much concurrency you’re really using right now. Find out how often you’re explicitly, and purposefully you’re leaving the main thread. And try to understand how much of your code will run on the main thread.

You should also look at your team and figure out how up-to-date your team is, how comfortable they are with Swift concurrency already. In the end, the entire team will need to be able to work on and with your Swift 6 codebase.

On a code level, it's essential to understand how much concurrency you actually need because Swift concurrency is, by design, going to introduce a lot of concurrency into your app where maybe you don't actually need all of that concurrency. That’s why it’s so important for you to figure the amount of concurrency you’ll require beforehand by analyzing what you have now.

For example, if you have a view and you have a view model, and that view model talks to another layer, then probably you are doing most of the work on the main thread right now.

Once you hit your networking layer, your network calls will run somewhere else, and when your networking related functions invoke their callbacks, those will typically run on a background thread, and then you come back to the main thread to update your UI.

In this scenario, you don't need a lot of concurrency; in fact, I would say that you don't need concurrency beyond what URLSession provides at all. So once you’re adopting Swift Concurrency, you’ll want to understand how you can structure your code to not leave the main thread for every async call.

You might already have adopted async-await, and that's completely fine—it probably means that you do have more concurrency than you actually need. Every nonisolated async function that you write will, by default, run on a background thread. You don’t always need this; you’ll most likely want to explicitly isolate some of your work to the main actor to prevent leveraging concurrency in places where it’s simply not benefitting you.

You'll also want to make sure that you understand how dependent or how coupled your codebase is because the more coupling you have and the less abstractions and modularization you have, the more complexities you might run into. Understanding your codebase deeply is a prerequisite to moving to Swift 6.

Once you understand your codebase, you might want to look at modularizing. I would say this is the best option. It does make migrating a little bit easier.

So let's talk about modularization next.

Modularizing your codebase

When you migrate to Swift 6, you'll find that a lot of objects in your code are being passed from one place to another, and when you start to introduce concurrency in one part of the code, you’re essentially forced to migrate anything that depends on that part of your codebase.

Having a modularized codebase means that you can take your codebase and migrate it over time. You can move component by component, rather than being forced to move everything all at once.

You can use features like @preconcurrency to make sure that your app can still use your Swift 6 modules without running into all kinds of isolation or sendability warnings until your app also opts in to strict concurrency.

If you don't want to modularize your codebase or you feel your codebase is way too small to be modularized, that's completely fine. I'm just saying that the smaller your components are, the easier your migration is going to be.

Once you know the state your codebase is in and you feel comfortable with how everything is, it's time to turn on strict concurrency checks.

Turning on strict concurrency checks

Before you turn on Swift 6 language mode, it is recommended to turn on strict concurrency checking for the modules that you want to migrate. You can do this for both SPM and in Xcode for your app target.

I would recommend to do this on a module by module basis.

So if you want to refactor your models package first, turn on strict concurrency checks for your model package, but not yet for your app. Turning on strict concurrency for only one module allows you to work on that package without forcing your app to opt into all of the sendability and isolation checks related to the package you’re refactoring.

Being able to migrate one package at a time is super useful because it makes everything a lot easier to reason about since you’re reasoning about smaller bits of your code.

Once you have your strict concurrency checks turned on you're going to see a whole bunch of warnings for the packages and targets where you've enabled strict concurrency and you can start solving them. For example, it’s likely that you'll run into issues like main actor isolated objects to sendable closures.

You'll want to make sure that you understand these errors before you proceed.

You want to make sure that all of your warnings are resolved before you turn on Swift 6 language mode, and you want to make sure that you've got a really good sense of how your code is supposed to work.

The hardest part in solving your strict concurrency warnings is that making the compiler happy sometimes just isn't enough. You'll frequently want to make sure that you actually reason about the intent of your code rather than just making the compiler happy.

Consider the following code example:

func loadPages() {
  for page in 0..<10 {
    loadPage(page) { result in 
      // use result
    }
  }
}

We're iterating over a list of numbers and we're making a bunch of network calls. These network calls happen concurrently and our function doesn't wait for them all to complete. Now, the quickest way to migrate this over to Swift concurrency might be to write an async function and a for loop that looks like this:

func loadPages() async throws {
  for page in 0..<10 {
    let result = try await loadPage(page)
    // use result
  }
}

The meaning of this code has now changed entirely. We're making network calls one by one and the function doesn't return until every call is complete. If we do want to introduce Swift concurrency here and keep the same semantics we would have to create unstructured tasks for every single network call or we could use a task group and kick off all our network calls in parallel that way.

Using a task group would change the way this function works, because the function would have to wait for the task group to complete rather than just letting unstructured tasks run. In this refactor, it’s crucial to understand what structured concurrency is and when it makes sense to create unstructured tasks.

You're having to think about what the intent of the code is before you migrate and then also how and if you want to change that during your migration. If you want to keep everything the same, it's often not enough to keep the compiler happy.

While teaching Teams about Swift Concurrency, I found it really important to know exactly which tools you have out there and to think about how you should be reasoning about your code.

Once you've turned on Swift Concurrency checks, it's time to make sure that your entire team knows what to do.

Ensuring your team has all the knowledge they need

I've seen several companies attempt migrations to SwiftUI, Swift Data, and Swift Concurrency. They often take approaches where a small team does all the legwork in terms of exploring and learning these technologies before the rest of the team is requested to learn them too and to adopt them. However, this often means that there's a small team inside of the company that you could consider to be experts. They'll have had access to resources, they'll have had time to train, and once they come up with the general big picture of how things should be done, the rest of the team kind of has to learn on the job. Sometimes this works well, but often this breaks down because the rest of the team simply needs to fully understand what they're dealing with before they can effectively learn.

So I always recommend if you want to migrate over to Swift Concurrency have your team enroll in one of my workshops or use my books or my course or find any other resource that will teach the team everything they need to know. It's really not trivial to pick up Swift Concurrency, especially not if you want to go into strict concurrency mode. Writing async-await functions is relatively easy, but understanding what happens is really what you need if you're planning to migrate and go all-in on concurrency.

Once you've decided that you are going to go for Swift 6 and did you want to level up your team's concurrency skills make sure you actually give everybody on the team a chance to properly learn!

Migrating from the outside in

Once you've started refactoring your packages and it's time to start working on your app target I found that it really makes sense to migrate from the outside in. You could also work from the inside out and in the end, it really depends on where you want to start. That said, I often like to start in the view layer once all the back-end stuff is done because it helps me determine at which point in the app I want to leave the main actor (or when yo apply a main actor annotation to stay on the main actor).

For example, if you’re using MVVM and you have a view model that holds a bunch of functions, where should these functions run?

This is where the work that you did up front comes into play because you already know that in the old way of doing things the view model would run its functions on the main thread. I would highly recommend that you do not change this. If your view model used to run on the main thread which is pretty much standard, keep it that way.

You'll want to apply a main actor annotation to your view model class.

This is not a bad thing by any means, it's not a hack either. It's a way for you to ensure that you're not switching isolation contexts all the time. You really don't need a ton of concurrency in your app code.

Apple is even considering introducing some language mode for Swift where everything is going to be on the main actor by default.

So for you to default your view models and maybe some other objects in your code base to the main actor simply makes a lot of sense. Once you start migrating like this you'll figure out that you really didn't need that much concurrency which you already should know because that's what you figured out early on into process.

This is also where you start to encounter warnings that are related to sendability and isolation contexts. Once you start to see these warnings and errors, you decide that the model should or shouldn't be sendable depending on whether the switch of isolation context that’s causing the warning is expected.

You can solve sendability problems with actors. That said, making things actors is usually not what you're looking for especially when it's related to models.

However, when you’re dealing with a reference type that has mutable state, that's where you might introduce actors. It’s all about figuring out whether you were expecting to use that type in multiple isolation contexts.

Having to deeply reason about every error and warning can sometimes feel tedious because it really slows you down. You can easily make something sendable, you could easily make something an actor, and it wouldn't impact your code that much. But you are introducing a lot of complexity into your codebase when you're introducing isolation contexts and when you're introducing concurrency.

So again, you really want to make sure that you limit the amount of concurrency in your app. You typically don't need a lot of concurrency inside an application. I can't stress this enough.

Pitfalls, caveats, and dangers

Migrating to Swift 6 definitely comes with its dangers and uncertainties. If you're migrating everything all at once, you're going to be embarking on a huge refactor that will involve touching almost every single object in your code. If you introduce actors where they really shouldn't belong, you suddenly have everything in your code becoming concurrent because interacting with actors is an asynchronous proces.

If you didn't follow the steps in this blog post, you're probably going to have asynchronous functions all over the place, and they might be members of classes or your view or anything else. Some of your async functions are going to be isolated to the main actor, but most of them will be non-isolated by default, which means that they can run anywhere. This also means that if you pass models or objects from your view to your few model to some other place that you're skipping isolation contexts all the time. Sometimes this is completely fine, and the compiler will figure out that things are actually safe, but in a lot of cases, the compiler is going to complain about this, and you will be very frustrated about this because you have no idea what's wrong.

There's also the matter of interacting with Apple's code. Not all of Apple's code is necessarily Swift 6 compatible or Swift 6 friendly. So you might find yourself having to write workarounds for interacting with things like a CLLocationManagerDelegate or other objects that come from Apple's frameworks. Sometimes it's trivial to know what to do once you fully understand how isolation works, but a lot of the times you're going to be left guessing about what makes the most sense.

This is simply unavoidable, and we need Apple to work on their code and their annotations to make sure that we can adopt Swift 6 with full confidence.

At the same time, Apple is looking at Swift as a language and figuring out that Swift 6 is really not in the place where they want it to be for general adoption.

If you're adopting Swift 6 right now, there are some things that might change down the line. You have to be willing to deal with that. If you're not willing to deal with that, I would recommend that you go for strict concurrency and don't go all-in on Swift 6 because things might change down the line and you don't want to be doing a ton of work that turns out to be obsolete. A couple versions of Swift down the line, and we're probably talking months, not years, before this happens.

In Summary

Overall, I think adopting Swift 6 is a huge undertaking for most teams. If you haven't started already and you're about to start now, I would urge you to take it slow - take it easy and make sure that you understand what you're doing as much as possible every step of the way.

Swift concurrency is pretty complicated, and Apple is still actively working on improving and changing it because they're still learning about things that are causing problems for people all the time. So for that reason, I'm not even sure that migrating to Swift 6 should be one of your primary goals at this point in time.

Understanding everything around Swift 6 I think is extremely useful because it does help you to write better and safer code. However, I do believe that sticking with the Swift 5 language mode and going for strict concurrency is probably your safest bet because it allows you to write code that may not be fully Swift 6 compliant but works completely fine (at least you can still compile your project even if you have a whole bunch of warnings).

I would love to know your thoughts and progress on migrating to Swift 6. In my workshops I always hear really cool stories about companies that are working on their migration and so if you have stories about your migration and your journey with Swift 6, I would love to hear that.

Expand your learning with my books

Practical Core Data header image

Learn everything you need to know about Core Data and how you can use it in your projects with Practical Core Data. It contains:

  • Twelve chapters worth of content.
  • Sample projects for both SwiftUI and UIKit.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more

What’s new in Swift 6.1?

The Xcode 16.3 beta is out, which includes a new version of Swift. Swift 6.1 is a relatively small release that comes with bug fixes, quality of life improvements, and some features. In this post, I’d like to explore two of the new features that come with Swift 6.1. One that you can start using immediately, and one that you can opt-in on if it makes sense for you.

The features I’d like to explore are the following:

  1. Changes to Task Groups in Swift 6.1
  2. Changes to member visibility for imported code

We’ll start by looking at the changes in Concurrency’s TaskGroup and we’ll cover member visibility after.

Swift 6.1 and TaskGroup

There have been a couple of changes to concurrency in Swift 6.1. These were mainly small bug fixes and improvements but one improvement stood out to me and that’s the changes that are made to TaskGroup. If you're not familiar with task groups, go ahead and read up on them on my blog post right here.

Normally, a TaskGroup is created as shown below where we create a task group and specify the type of value that every child task is going to produce:

await withTaskGroup(of: Int.self) { group in
  for _ in 1...10 {
    group.addTask {
      return Int.random(in: 1...10)
    }
  }
}

Starting in Swift 6.1, Apple has made it so that we no longer have to explicitly define the return type for our child tasks. Instead, Swift can infer the return type of child tasks based on the first task that we add to the group.

That means that the compiler will useaddGroup it finds to determine the return type for all your child tasks.

In practice, that means that the code below is the equivalent of what we saw earlier:

await withTaskGroup { group in
  for _ in 1...10 {
    group.addTask {
      return Int.random(in: 1...10)
    }
  }
}

Now, as you might expect, this doesn't change the fact that our task groups have to return the same type for every child task.

The code above shows you how you can use this new return type inference in Swift 6.1. If you accidentally do end up with different return types for your child task like the code below shows, the compiler will present us with an error that will tell you that the return type of your call to addTask is incorrect.

await withTaskGroup { group in
  for _ in 1...10 {
    group.addTask {
      return Int.random(in: 1...10)
    }
  }

  group.addTask {
    // Cannot convert value of type 'String' to closure result type 'Int'
    return "Hello, world"
  }
}

Now, if you find that you do want to have multiple return types, I have a blog post on that. That approach still works. We can still use an enum as a return type for our task group for our child tasks, and that definitely still is a valid way to have multiple return types in a task group.

I’m quite happy with this change because having to specify the return type for my child tasks always felt a little tedious so it’s great to see the compiler take this job in Swift 6.1.

Next, let’s take a look at the changes to imported member visibility in Swift 6.1.

Imported member visibility in Swift 6.1

In Swift, we have the ability to add extensions to types to enhance or augment functionality that we already have. For example, you could add an extension to an Int to represent it as a currency string or something similar.

If I'm building an app where I'm dealing with currencies and purchases and handling money, I might have two packages that are imported by my app. Both packages could be dealing with currencies in some way shape or form and I might have an extension on Int that returns a String which is a currency string as I mentioned earlier.

Here's what that could look like.

// CurrencyKit
func price() -> String {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.locale = Locale.current

    let amount = Double(self) / 100.0 // Assuming the integer represents cents
    return formatter.string(from: NSNumber(value: amount)) ?? "$\(amount)"
}

// PurchaseParser
func price() -> String {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.locale = Locale.current

    let amount = Double(self) / 100.0 // Assuming the integer represents cents
    return formatter.string(from: NSNumber(value: amount)) ?? "$\(amount)"
}

The extension shown above exists in both of my packages, and the return types of these extensions are the exact same (i.e., strings). This means that I can have the following two files in my app, and it's going to be just fine.

// FileOne.swift
import PurchaseParser

func dealsWithPurchase() {
    let amount = 1000
    let purchaseString = amount.price()
    print(purchaseString)
}

// FileTwo.swift
import CurrencyKit

func dealsWithCurrency() {
    let amount = 1000
    let currencyString = amount.price()
    print(currencyString)
}

The compiler will know how to figure out which version of price should be used based on the import in my files and things will work just fine.

However, if I have two extensions on integer with the same function name but different return types, the compiler might actually get confused about which version of the extension I intended to use.

Consider the following changes to PurchaseParser's price method:

func price() -> Double {
    let formatter = NumberFormatter()
    formatter.numberStyle = .currency
    formatter.locale = Locale.current

    let amount = Double(self) / 100.0 // Assuming the integer represents cents
    return amount
}

Now, price returns a Double instead of a String. In my app code, I am able to use this extension from any file, even if that file doesn’t explicitly import PurchaseParser. As a result, the compiler isn’t sure what I mean when I write the following code in either of the two files that you saw earlier:

let amount = 1000
let currencyString = amount.price()

Am I expecting currencyString to be a String or am I expecting it to be a Double?

To help the compiler, I can explicitly type currencyString as follows:

let amount = 1000
let currencyString: String = amount.price()

This will tell the compiler which version of price should be used, and my code will work again. However, it’s kind of strange in a way that the compiler is using an extension on Int that’s defined in a module that I didn’t even import in this specific file.

In Swift 6.1, we can opt into a new member visibility mode. This member visibility mode is going to work a little bit more like you might expect.

When I import a specific module like CurrencyKit, I'm only going to be using extensions that were defined on CurrencyKit. This means that in a file that only imports CurrencyKit I won’t be able to use extensions defined in other packages unless I also import those. As a result, the compiler won’t be confused about having multiple extensions with the method name anymore since it can’t see what I don’t import.

Opting into this feature can be done by passing the corresponding feature flag to your package, here's what that looks like when you’re in a Swift package:

.executableTarget(
    name: "AppTarget",
    dependencies: [
        "CurrencyKit",
        "PurchaseParser"
    ],
    swiftSettings: [
        .enableExperimentalFeature("MemberImportVisibility")
    ]
),

In Xcode this can be done by passing the feature to the “Other Swift Flags” setting in your project settings. In this post I explain exactly how to do that.

While I absolutely love this feature, and I think it's a really good change in Swift, it does not solve a problem that I've had frequently. However, I can definitely imagine myself having that problem, so I'm glad that there's now a fix for that that we can opt into. Hopefully, this will eventually become a default in Swift.

In Summary

Overall, Swift 6.1 is a pretty lightweight release, and it has some nice improvements that I think really help the language be better than it was before.

What are your thoughts on these changes in Swift 6.1, and do you think that they will impact your work in any way at all?

Why you should keep your git commits small and meaningful

When you're using Git for version control, you're already doing something great for your codebase: maintaining a clear history of changes at every point in time. This helps you rewind to a stable state, track how your code has evolved, and experiment with new ideas without fully committing to them right away.

However, for many developers, Git is just another tool they have to use for work. They write a lot of code, make commits, and push their changes without giving much thought to how their commits are structured, how big their branches are, or whether their commit history is actually useful.

Why Commit Hygiene Matters

As projects grow in complexity and as you gain experience, you'll start seeing commits as more than just a step in pushing your work to GitHub. Instead, commits become checkpoints—snapshots of your project at specific moments. Ideally, every commit represents a logical stopping point where the project still compiles and functions correctly, even if a feature isn’t fully implemented. This way, you always have a reliable fallback when exploring new ideas or debugging issues.

Now, I’ll be honest—I’m not always perfect with my Git hygiene. Sometimes, I get deep into coding, and before I realize it, I should have committed ages ago. When working on something significant, I try to stage my work in logical steps so that I still have small, meaningful commits. If you don’t do this, the consequences can be frustrating—especially for your teammates.

The Pain of Messy Commits

Imagine you're debugging an issue, and you pinpoint that something broke between two commits. You start looking at the commit history and find something like:

  • wip (Work in Progress)
  • fixing things
  • more updates

None of these tell you what actually changed. Worse, if those commits introduce large, sweeping changes across the codebase, you’re left untangling a mess instead of getting helpful insights from Git’s history.

How Small Should Commits Be?

A good rule of thumb: your commits should be small but meaningful. A commit doesn’t need to represent a finished feature, but it should be a logical step forward. Typically, this means:

  • The project still builds (even if the feature is incomplete).
  • The commit has a clear purpose (e.g., “Refactor JSON parsing to use Decodable”).
  • If you’re adding a function, consider adding its corresponding test in the same commit.

For example, let’s say you’re refactoring JSON parsing to use Decodable and updating your networking client:

  1. Commit 1: Add the new function to the networking client.
  2. Commit 2: Add test scaffolding (empty test functions and necessary files).
  3. Commit 3: Write the actual test.
  4. Commit 4: Implement the feature.
  5. Commit 5: Rename a model or refactor unrelated code (instead of bundling this into Commit 4).

By structuring commits this way, you create a clear and understandable history. If a teammate needs to do something similar, they can look at your commits and follow your process step by step.

The Balance Between Clean Commits and Productivity

While good commit hygiene is important, don’t obsess over it. Some developers spend as much time massaging their Git history as they do writing actual code. Instead, strive for a balance: keep your commits clean and structured, but don’t let perfectionism slow you down.

You really don’t have to pick apart your changes just so you can have the cleanest commits ever. For example, if you’ve fixed a typo in a file that you were working on, you don’t have to make a separate commit for that if it means having to stage individual lines in a file.

On the other hand, if fixing that typo meant you also changed a handful of other files, you might want to put some extra work into splitting that commit up.

Commit Messages: Crafting a Meaningful Story

In addition to the size of your commits, your commit messages also matter. A good commit message should be concise but informative. Instead of vague messages like fix or updated code, consider something more descriptive, like:

  • Refactored JSON parsing to use Decodable
  • Fixed memory leak in caching logic
  • Added unit test for network error handling

By keeping your commit messages clear, you help yourself and others understand the progression of changes without having to dig into the code.

Rewriting Git History When Necessary

Sometimes, you may want to clean up your Git history before merging a branch. This is where tools like interactive rebase come in handy. Using git rebase -i HEAD~n, you can:

  • Squash multiple small commits into one.
  • Edit commit messages.
  • Reorder commits for better readability.

However, be cautious when rewriting history—once commits are pushed to a shared branch, rebasing can cause conflicts for your teammates.

Rebasing on the command line can be tricky but luckily most GUIs will have ways to perform interactive rebasing too. I personally use interactive rebasing a lot since I like rebasing my branches on main instead of merging main into my features. Merge commits aren’t that useful to have in my opinion and rebasing allows me to avoid them.

In Summary

In the end, it’s all about making sure that you end up having a paper trail that makes sense and that you have a paper trail that can actually be useful when you find yourself digging through history to see what you did and why.

The reality is, you won’t do this often. But when you do, you’ll feel glad that you took the time to keep your commits lean. By keeping your commits small, writing meaningful messages, and leveraging Git’s powerful tools, you ensure that your version control history remains a valuable resource rather than a tangled mess.

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.