Introduction
In September 2024, Apple released the macOS Sequoia 15, alongside Xcode 16. This new version of the IDE brings some new cool stuff, such as Swift 6 and the open-source unit testing framework Swift Testing, the latter being the subject of today's post.
The motivation behind introducing a new native way of writing tests in Swift is to overcome limitations of XCTest. Let's explore how Swift Testing tries to achieve it, and understand where and specially when you should start using it.
What Problems Does Swift Testing Solve?
Verbose and Boilerplate-heavy Syntax in XCTest
When creating tests with XCTest, a lot of boilerplate is needed in order to create any kind of test, even the simplest ones:
import XCTest
final class SumTest: XCTestCase {
var calculator: Calculator?
override func setUpWithError() throws {
// Instantiate the needed objects
calculator = Calculator()
}
override func tearDownWithError() throws {
// Deallocate objects to free space in DevOps environments
calculator = nil
}
func testSum() throws {
// Unwraps optional value
let calculator = try XCTUnwrap(self.calculator)
// Assert that the `sum` function calculates correctly
XCTAssertEqual(calculator.sum(number1: 2, number2: 2), 4)
}
}
With Swift Testing, your test cases can be global functions, excluding the need of it placing it inside any kind of scope:
import Testing
@Test func sum() {
let calculator = Calculator()
#expect(calculator.sum(number1: 2, number2: 2) == 4)
}
Let's break down the piece of code above:
- In the first line, we're importing the
Testing
package, that is the Swift Testing framework package. That allow us to use the@Test
property wrapper and the#expect
macro. - In the
sum
function declaration, we are adding the@Test
wrapper. That makes Xcode understand that this function is a Test Case, and allows us to execute the test. - The
#expect
macro is very similar to XCTestXCTAssert()
function. But differently from there, there's no variations such asXCTAssertEquals
,XCTAssertNil
, and etc. The reason for that is to simplify and improve semantics: reading expect 2 + 2 to be equals 4 is more "human" natural than assert equals: 2 + 2, 4. More about this topic in the next section.
Assertion semantics
As we previously saw, the #expect
macro works similar to XCTAssert
function. Like the older function, the new macro has a similar behavior: when an expectation fails, the test continues executing until it completes or throws an error:
struct Person {
var name: String = ""
init(name: String) {
// missing property setting here
}
}
@Test func example() {
let person: Person? = Person(name: "John")
#expect(person?.name == "John") // Here you'll see a failing expectation
#expect(person != nil) // Swift Test will continue to verifying the expectations, and will pass this one
}
If you need to stop test execution when a specific expectation is not met, you can use the #require
macro. Unlike #expect
, this is a throwing function, which requires a try
statement when calling it. It can also return a value when receiving an optional as argument.
@Test func example() throws {
let person: Person? = Person(name: "John")
try #require(person?.name == "John") // Here the execution will stop after failing the requirement
#expect(person != nil)
}
Note that the testing function now needs to be annotated with throws
since we are using a try
statement, and now it will stop the execution when not matching a requirement.
The #require
macro can also be used to unwrap optional values, failing the test if the value is nil
, similar to what XCTUnwrap
does:
@Test func example() throws {
let company = companyFactory.build()
// Company's owner is an Optional type. If its value is `nil`, the test will fail pointing exactly why
let owner = try #require(company.owner)
#expect(owner.name == "John")
}
Naming, conditional tests and behaviors
Giving meaningful names to your tests
It is a known good practice to give rich and descriptive names to your tests. That's because when reading a report, it's easier to identify what exactly in your app is not right when tests are failing:
import XCTest
final class ExampleTests: XCTestCase {
func testThatWhenSearchingWithAirplaneModeOnErrorMessageShouldAppear() throws {
// ...
}
}
Even though it's a good practice, these big function names can be difficult to read in a codebase.
With Swift Testing you can solve this problem by setting the displayName
attribute in the @Test
macro:
@Test("Error message is displayed when searching with airplane mode on")
func noInternetMessageOnSearch() throws {
// ...
}
This is how you'll see each of the tests in the Xcode Test Navigator:
Additionally, Swift Testing eliminates the need of starting your function with the test
prefix, giving you more flexibility.
Conditionals
During the software development process, it's not uncommon to have tests that you temporarily don't want them to be executed. In XCTest you don't have any specific tool to make a single test case not being ran, so you need to use strategies as commenting code, or adding a return
to the very first line of the test case, for example.
import XCTest
final class ExampleTests: XCTestCase {
func testUnfinishedFeature() throws {
// This feature is not completed yet and the test is failing
return
// Test implementation here
}
}
With Swift Testing, you can use Traits to handle this kind of situation in a more elegant way. Let's explore some of them:
1. .enabled(if: Bool)
You can use this Trait to create conditional logic that decides whether the test case should or shouldn't be executed:
@Test("New Screen loads correctly", .enabled(if: Features.newScreenEnabled))
func newScreen() throws {
// Test implementation
}
In this case above, we have an unfinished feature, and we already have a test case for it (if you're following TDD for example, that's very common). If you want to merge your code in a stable branch, but don't want the unfinished test case to be run in the DevOps environment, the enabled(if:)
trait can be very useful.
2. .disabled()
With the .disabled()
trait you can simply disable a test. This is useful when you have a failing test that isn't a priority to fix right away, so you can disable to your tests pass, and then fix it later:
@Test(.disabled()) func myMinorFailingTest() throws {
// Test implementation
}
The .disabled()
function also accepts a String
argument to add a comment about why you're disabling this test, and it's very recommendable to use it:
@Test(.disabled("This is affecting less than 1% of the users, will be fixed in the next release"))
func myMinorFailingTest() throws {
// ...
Another excellent practice you could follow when disabling tests, is using the .bug()
trait. This one is used to identify a known bug that will be addressed in future, and is already reported in some bug tracker tool:
@Test(
.disabled("Affecting few users"),
.bug("github.com/MyProjectRepo/issues/99999", "Incorrect message being displayed")
)
func myMinorFailingTest() throws {
Running your tests with a disabled test case like that should display to you a successful output:
◇ Test run started.
↳ Testing Library Version: 102 (arm64-apple-ios13.0-simulator)
◇ Suite ExampleTests started.
◇ Test example() started.
✘ Test myMinorFailingTest() skipped: "Affecting few users"
✔ Test example() passed after 0.001 seconds.
✔ Suite ExampleTests passed after 0.001 seconds.
✔ Test run with 2 tests passed after 0.001 seconds.
Look that the myMinorFailingTest()
appears there as a skipped test, along the reason for it.
3. @available(...)
Another reason to avoid running tests is having features that requires specific OS versions, in a moment that you cannot guarantee that all the environments that run tests already uses that version. For this scenario, instead of a Trait, you can use a different macro with the @Test
one:
@Test
@available(macOS 15, *)
func useNewAPIs() {
// ...
}
Using this macro is better than, for example, adding a guard #available(...) else
to your test implementation. That's because the @available
macro will not check the OS availability in runtime, as the guard
statement, and you'll have clearer logs and test outputs.
Behaviors
Another challenge you may have while creating tests is creating multiple similar test cases. That happens because sometimes you have an algorithm that, depending on the input, it may create different results, so you decide to test all the different possible inputs:
final class ExampleTests: XCTestCase {
func testThatColorHelperCorrectlyDecodesFromRGB() throws {
let pattern = "RGB(255, 0, 0)"
let color = ColorHelper.from(string: pattern)
XCTAssertEqual(color, .red)
}
func testThatColorHelperCorrectlyDecodesFromCMYK() throws {
let pattern = "CMYK(0, 100, 100, 0)"
let color = ColorHelper.from(string: pattern)
XCTAssertEqual(color, .red)
}
func testThatColorHelperCorrectlyDecodesFromHSV() throws {
let pattern = "HSV(0, 100, 100)"
let color = ColorHelper.from(string: pattern)
XCTAssertEqual(color, .red)
}
func testThatColorHelperCorrectlyDecodesFromHSL() throws {
let pattern = "HSL(0, 100, 50)"
let color = ColorHelper.from(string: pattern)
XCTAssertEqual(color, .red)
}
func testThatColorHelperCorrectlyDecodesFromHex() throws {
let pattern = "#FF0000"
let color = ColorHelper.from(string: pattern)
XCTAssertEqual(color, .red)
}
With several tests like that, if you need to change anything in the ColorHelper
, you'll need to update all your test cases. Maybe you're thinking that you could optimize the code above with a forEach
:
func testThatColorHelperCorrectlyDecodesFromAllPatterns() throws {
[
"RGB(255, 0, 0)",
"CMYK(0, 100, 100, 0)",
"HSV(0, 100, 100)",
"HSL(0, 100, 50)",
"#FF0000"
].forEach {
let color = ColorHelper.from(string: $0)
XCTAssertEqual(color, .red)
}
}
The problem with this approach is that when failing, you don't know which pattern is making the ColorHelper
not decode the color properly, so you need to debug and investigate it. Also, after adding a possible a fix to it, you'll need to re-run the test for all inputs. If your test case perform some heavy task, you may need to wait for a long time to see if your fix works.
Swift Testing then brings the parameterized tests, a way to optimize this situation. To use it, you need to use the arguments
Trait, and you also need to add an argument to your test function, like that:
@Test(arguments: [
"RGB(255, 0, 0)",
"CMYK(0, 100, 100, 0)",
"HSV(0, 100, 100)",
"HSL(0, 100, 50)",
"#FF0000"
])
func colorHelper(pattern: String) {
let color = ColorHelper.from(string: pattern)
#expect(color == .red)
}
When running this test, you see the following result in Xcode:
It shows us that the test is passing for all arguments, except "#FF0000"
. So we can analyze the ColorHelper.from
function and investigate why. After finding and fixing the problem, you can re-run the test only for that argument:
After seeing a successful result, you can run it for all arguments again, and then ship your code.
Performance
There's yet another advantage of using parameterized tests: Swift Testing uses Swift Concurrency to execute tests with different arguments in parallel, optimizing resources and time.
Hierarchy
Suites
Even though it's not needed, having your tests inside some scope is possible with Swift Testing. These scopes are called Suite
s. For creating one, you just need to add your test case functions to a class
, struct
or even an actor
. But you can also explicitly add a @Suite
macro, that we'll cover when it's useful more ahead. Let's see a simple example with a struct
:
struct CalculatorTests {
let calculator = Calculator()
@Test func testSum() {
#expect(calculator.sum(number1: 2, number2: 3) == 5)
}
@Test func testMultiply() {
#expect(calculator.multiply(number1: 2, number2: 3) == 6)
}
}
In this example, CalculatorTests
is a Suite
. Doing it allows you making two improvements: execute all tests inside the Suite
at once, and sharing dependencies across different test cases (in this case the Calculator
object) through stored properties, as you would do with XCTest
. The difference here, with the possibility of adding them to a struct
, you have an improvement in memory management, since struct
s are deallocated in the moment they get out of scope, different from reference types. I have a dedicated post about how Swift handles memory here, you can read it to go further in this subject.
Suite
s in Swift Testing can have stored properties, as we saw in our example, and they can also use init
and deinit
(this last only in reference types) to perform needed tasks before and after each test case (similar to XCTest
setUp
and tearDown
functions). This part is important: for each test case, a new Suite
is instantiated, avoiding two tests sharing a same state.
actor SuiteWithTwoTests {
init() {
print("SuiteWithTwoTests was initialized")
}
deinit {
print("SuiteWithTwoTests was deinitialized")
}
@Test func testOne() {
#expect(true)
}
@Test func testTwo() {
#expect(true)
}
}
Output of the tests above:
◇ Test run started.
↳ Testing Library Version: 102 (arm64-apple-ios13.0-simulator)
◇ Suite SuiteWithTwoTests started.
◇ Test testOne() started.
◇ Test testTwo() started.
SuiteWithTwoTests was initialized
SuiteWithTwoTests was initialized
SuiteWithTwoTests was deinitialized
SuiteWithTwoTests was deinitialized
✔ Test testOne() passed after 0.001 seconds.
✔ Test testTwo() passed after 0.001 seconds.
✔ Suite SuiteWithTwoTests passed after 0.001 seconds.
✔ Test run with 2 tests passed after 0.001 seconds.
Swift Testing allows you to create nested hierarchy as well, by creating scopes inside other scopes:
struct DateTests {
@Test func formatting() {
// ...
}
struct Coding {
@Test func encodeDate() {
// ...
}
@Test func decodeDate() {
// ...
}
}
}
With this nested Suite
s, you can execute only the inner ones by executing the Coding
suite, if you want, or maybe run all tests at once by executing the DateTests
.
Tags
Suite
s are not the only way for grouping your test cases with Swift Testing. You can also have tags
: it allows you adding a same tag to multiple tests in different Suite
s. For that, you should first create a tag, extending the Testing.Tag
struct, adding a static property with the @Tag
property wrapper:
extension Tag {
@Tag static var formatting: Tag
}
struct DateTests {
@Test(.tags(.formatting)) func formatting() {
// ...
}
}
struct CurrencyTest {
@Test(.tags(.formatting)) func formatting() {
// ...
}
}
With tags
, you can execute all the tests inside a tag
at once:
Swift Testing also allows you to add tags
to an entire Suite
. For that, you need to use the @Suite
macro:
struct DateTests {
@Test func formatting() {
#expect(true)
}
@Suite(.tags(.coding))
struct Coding {
@Test func encodeDate() {
#expect(true)
}
@Test func decodeDate() {
#expect(true)
}
}
}
struct CurrencyTest {
@Test func formatting() {
#expect(true)
}
@Suite(.tags(.coding))
struct Coding {
@Test func encodeMoney() {
#expect(true)
}
@Test func decodeMoney() {
#expect(true)
}
}
}
With the code above, we are telling Swift Testing that all the test cases of DateTests.Coding
are tagged coding
, such as all the CurrencyTests.Coding
ones. This way, Xcode allows us to run all the encode/decode tests, without running the formatting ones:
Comparison: Swift Testing vs. XCTest
Let's recap all the differences between Swift Testing and XCTest.
Discovery
- XCTest: Name begins with "Test"
-
Swift Testing:
@Test
macro
Supported types
-
XCTest: instance methods of a
XCTestCase
class object - Swift Testing: instance methods of classes, structs or actors, static methods and global functions
Traits
- XCTest: not supported
- Swift Testing: used to conditionally enable or disable test cases, track known bugs (either per-test or per-suite) and run parameterized tests
Parallel execution
- XCTest: Launch multi process in macOS or simulators
- Swift Testing: Uses Swift Concurrency, running it in-process and supports physical devices such as iPhones and Apple Watches
Continuity after failing
-
XCTest: By default, it stops execution on failure, but allows you to control this behavior by changing the
continueAfterFailure
property value -
Swift Testing: Have assertion functions with different behaviors, with
#expect
continuing after failing and#require
stopping the execution
Migrating to Swift Testing
If you liked Swift Testing so far, you can already start using it in your project, either creating the new test cases in the new framework or refactoring the existing ones into it.
If your project doesn't support unit tests yet, to start using Swift Testing you should add a Testing Target to it. For that, in Xcode go to File > New > Target, select your platform and then "Unit Testing Bundle". Make sure not to select "UI Testing Bundle":
In the next screen, fill in your project info and select Swift Testing as your "Testing System". In Xcode 16 it will be the default.
In the new folder that will be created with the same name as your target, an example test file will be created. You can add more test files to this folder and start creating your Swift Testing test cases.
If you already support Unit Tests in your project with XCTest, this step is not needed: you can add Swift Testing tests to the same target you already have.
To create your first test, just create a new file, usually with the "Tests" suffix, import Testing
and start adding test cases to it:
import Testing
@testable import MyAppModule // <- Replace here
@Test("Calculator sum function sums two numbers correctly") func sum() {
// ...
}
Importing your app's module is important so the testing target can have visibility into your app's code. Replace "MyAppModule" with the name of your app's module. The @testable
macro is also important, so the testing target can have visibility to internal
properties/functions.
When rewriting XCTest tests to Swift Testing, Apple recommends creating global @Test
functions when you have XCTest classes with only one test case, instead of creating a Suite
with one single test:
Convert from:
// DateParserTests.swift
import XCTest
final class DateParserTests: XCTestCase {
override func setUpWithError() throws {
// ...
}
override func tearDownWithError() throws {
// ...
}
func testThatDateIsCorrectlyParsed() throws {
// ...
}
}
To:
// DateParserTest.swift
import Testing
@Test("Date is correctly parsed") func parseDate() {
// ...
}
Limitations
Even though Swift Testing brings several new cool features, some limitations should be considered before adopting it.
Swift Testing is available only for projects written in Swift 6 using Xcode 16. Also, it doesn't support UI automation(XCUIApplication
) neither performance API (XCTMetric
) yet, so for these kind of tests you should still using XCTest.
Swift Testing, as the name suggests, works only with Swift, so it does not support testing code written in Objective-C
.
Running from Terminal
Swift Testing is highly integrated with Swift Package Manager (SPM). When creating a SPM package, you can easily run the package tests from terminal, with the following command:
swift test
Then, you should see the results like this:
[13/13] Linking LibWithSwiftTestingPackageTests
Build complete! (45.55s)
Test Suite 'All tests' started at 2025-01-31 11:30:49.785.
Test Suite 'All tests' passed at 2025-01-31 11:30:49.795.
Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.010) seconds
◆ Test run started.
⮑ Testing Library Version: 102 (arm64e-apple-macos13.0)
◆ Test example() started.
✅ Test example() passed after 0.001 seconds.
✅ Test run with 1 test passed after 0.001 seconds.
Conclusion
Swift Testing is a cool new framework, and today we explored its features and differences from the existing XCTest framework for unit testing. More than just learn how to use a new technology, it's important to understand why it exists and which problems it solves. Also, it's important to understand pros and cons to evaluate whether migrating makes sense.
In general, Swift Testing brings far more advantages than disadvantages in my opinion, and I'm excited for a future where it is the default testing platform for most projects.
Also, Swift Testing is an open-source project, so you can fork its repo and start contributing yourself!
Top comments (0)