Moving from XCTest to Swift Testing

Swift Testing is Apple’s replacement for the objective-C XCTest unit testing framework. Discover how to migrate your existing XCTest suites over to Swift Testing, including how to get some assistance from Xcode’s agentic AI tooling. By Renan Benatti Dias.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Failing Tests on Purpose

Find the test method test_submit_empty_order_throws_empty_order. This test purposefully calls submitOrder(items:) with invalid input so that the method throws an error. The idea of this unit test is to check whether the method throws the correct error when given an invalid order.

When the expected error doesn’t occur, you need a way to tell the framework that this test failed. To do that, you use XCTFail(_:file:line:) to force a failure when the error isn’t thrown.

Note: This is just one of the ways to test a method that you expect to throw an error. You’ll learn other ways to assert that in the next section.

Replace the method declaration with the following code:

@Test func submitEmptyOrderThrowsEmptyOrder() async {

Next, find the code, inside the do clause, that uses XCTFail(_:file:line:) to fail the unit test and replace with the following:

Issue.record(
  "Expected submitOrder() to throw OrderError.emptyOrder"
)

Here, you replace XCTest’s XCTFail(_:file:line:) with Issue.record(_:severity:sourceLocation:). This class method also takes a string with a message explaining why this test failed.

Before you move on, replace the code inside the catch clause with:

#expect(error as? OrderError == .emptyOrder)

Run the unit test.

Note that Issue.record(_:severity:sourceLocation:) does not stop a test execution. If you wish to do so you must either return after calling this method, or you can use require(_:_:sourceLocation:).

While Issue.record works great, Swift Testing has a cleaner way of checking if an error is thrown.

Asserting Error Types

Find the next test method named test_adding_invalid_quantity_throws and take a look at its code.

This test method purposefully makes a similar mistake to the previous one. It calls createOrder(_:size:quantity:) with an invalid quantity of 0.

Here, the test uses XCTAssertThrowsError(_:_:file:line:_:) to assert an error is indeed thrown by createOrder(_:size:quantity:). It also gives you back the error that was thrown in a closure so that you can assert it is OrderError.invalidQuantity(0)

Replace the declaration of the method with the following:

@Test func addingInvalidQuantityThrows() {

Next, replace the code inside the method with:

// 1
#expect(throws: OrderError.invalidQuantity(0)) {
  // 2
  try shop.createOrder(
    .espresso,
    size: .small,
    quantity: 0
  )
}

Here’s how this works:

  1. You use expect(throws:_:sourceLocation:performing:) and tell the macro what error you’re expecting. In this case OrderError.invalidQuantity(0).
  2. Then, you simply call the method that throws this error inside the closure

The macro makes sure the method throws that specific error, and if that doesn’t happen it fails the test.

Run the unit test to make sure it’s passing.

Testing Methods That Should Not Throw Errors

You just tested a method’s thrown error by calling it with invalid input. But, you may also want to test the same method and ensure it never throws an error given valid input.

The next test, test_adding_available_kind_does_not_throw uses XCTAssertNoThrow(_:_:file:line:) for exactly that purpose. It makes sure that the method does not throw an error.

You’ll migrate that now.

Rename the method name to use camel case and remove the test_ prefix from it. Also add the Test(_:) macro to the method.

Next, replace the code of the method with the following:

// 1
#expect(throws: Never.self) {
  // 2
  try shop.createOrder(
    .espresso,
    size: .small,
    quantity: 1
  )
}

Here’s the breakdown:

  1. You’re using expect(throws:_:sourceLocation:performing:) again but this time you’re using the type Never to indicate this method may not throw an error
  2. You call the method with valid inputs

Run the unit test and make sure the method is not throwing an error.

Invalid quantity unit test passing

Very good!

This works great for checking that a method doesn’t throw an error; but, you may also want to check the results of a method.

For the final unit test in this file, you’ll test the submission of an order.

Find test_submit_order_generates_receipt:

and replace the method declaration with the following:

@Test
@MainActor
func submitOrderGeneratesReceipt() async throws {

Next, find the following line:

XCTAssertEqual(receipt.itemCount, 1)

And replace it with:

#expect(receipt.itemCount == 1)

The test calls createOrder(_:size:quantity:) directly. If the method throws an error, the test will stop and fail on that line. After that, it calls submitOrder(items:) with the created order and also waits for its response. Finally, the test checks that the receipt item count is 1.

Run the unit test to make sure everything is correct.

Unit test for getting a receipt passing

Now, all that is left is to remove the XCTest import declaration from the top of the file. Run the entire test suite to make sure everything is passing.

Report of coffee shop tests passing

Great job! You finished migrating all the unit tests of SwiftBrew to use Swift Testing.

Swift Testing is simple and can be adopted gradually in your project. And while you’re done migrating the tests of this project, the framework has a couple more tricks up its sleeve to power up your unit tests.

Using Traits

Swift Testing introduced something XCTest doesn’t have: traits. You can use traits to annotate or modify both individual test methods or an entire suite.

Disabling and Enabling Unit Tests

From time to time, you might come across intermittent or “flaky” unit tests. Those are unit tests that most of the time pass, but for some reason – a race condition, a specific configuration, etc. – they fail now and then. And while the correct course of action would be to debug and find out what’s causing this test to fail, you might not always have the time to do so.

With that in mind, Apple has some handy traits for you to use. Instead of commenting out a unit test and leaving it to fix later, you can use the disabled(_:sourceLocation:) trait to skip a test.

Note: You don’t need to follow along and add this code to your project
@Test(.disabled("Test is failing because of some race condition"))
@MainActor
func brewOrderReportsProgressForEachCoffee() async throws {

disabled(_:sourceLocation:) optionally takes a string, that will be recorded in the test report explaining why this test was skipped.

Unit test being skipped with message

You can also skip a unit test with some pre-condition, like a feature flag:

@Test(.disabled(if: FeatureFlag.isBrewProgressEnabled == false))
@MainActor
func brewOrderReportsProgressForEachCoffee() async throws {

And Swift Testing also has the opposite trait, enabled(if:_:sourceLocation:), that you can use exactly like its counterpart, just with the conditional flipped.