BS
BleepingSwift
Published on
8 min read
Intermediate

> Getting Started with Swift Testing: Apple's Modern Test Framework

Share:

// What_You_Will_Learn

  • Write tests using the @Test macro and #expect assertions
  • Organize tests into suites with @Suite
  • Use parameterized tests to cover multiple inputs
  • Apply traits for tags, time limits, and conditional execution

Swift Testing is Apple's modern testing framework, introduced at WWDC 2024 alongside Xcode 16. It replaces much of the ceremony around XCTest with a cleaner API built on Swift macros and native concurrency. If you've ever been frustrated by XCTest's 40+ assertion functions or struggled with async test expectations, Swift Testing should feel like a breath of fresh air.

Your First Swift Test

The core of Swift Testing is the @Test macro. Unlike XCTest, you don't need to prefix your function names with test or inherit from a base class:

Swift
import Testing

@Test func additionWorks() {
    #expect(2 + 2 == 4)
}

That's a complete, runnable test. The #expect macro handles assertions and captures the actual evaluated values when something fails, giving you much better diagnostics than XCTest's generic failure messages.

You can also give tests a readable display name:

Swift
@Test("User profile loads successfully")
func loadProfile() async throws {
    let profile = try await ProfileService.fetch(id: "123")
    #expect(profile.name == "Alice")
    #expect(profile.email.contains("@"))
}

Tests can be async, throws, or both. No special setup required.

Organizing Tests with @Suite

Group related tests using @Suite. Any struct, final class, or actor can be a suite:

Swift
@Suite("Authentication")
struct AuthTests {
    let service: AuthService

    init() {
        service = AuthService(environment: .test)
    }

    @Test func validCredentialsSucceed() async throws {
        let result = try await service.login(
            user: "admin",
            password: "secret"
        )
        #expect(result.isAuthenticated)
    }

    @Test func invalidPasswordFails() async throws {
        await #expect(throws: AuthError.invalidCredentials) {
            try await service.login(
                user: "admin",
                password: "wrong"
            )
        }
    }
}

A few things to notice here. The init method replaces XCTest's setUp(). A fresh instance of the suite is created for each test function, so state never leaks between tests. Suites can also be nested for hierarchical organization in Xcode's Test Navigator.

Assertions: #expect vs #require

Swift Testing has just two assertion macros, which replace all 40+ XCTAssert* functions.

#expect is a soft assertion. If it fails, the test records the failure but keeps running. This lets you catch multiple issues in a single test run:

Swift
@Test func userProfile() {
    let user = User(name: "Alice", age: 30)
    #expect(user.name == "Alice")
    #expect(user.age >= 18)
    #expect(user.isActive)
}

#require is a hard assertion that stops the test immediately by throwing. It's also your go-to for unwrapping optionals, replacing XCTUnwrap:

Swift
@Test func parseResponse() throws {
    let data = try #require(response.data)
    let user = try #require(
        JSONDecoder().decode(User.self, from: data) as User?
    )
    #expect(user.name == "Alice")
}

Use #require for preconditions where continuing would be meaningless, and #expect for everything else.

Testing for Errors

Verifying that code throws the right errors is straightforward:

Swift
@Test func divisionByZero() async {
    // Expect a specific error value (requires Equatable)
    #expect(throws: CalculatorError.divisionByZero) {
        try Calculator.divide(10, by: 0)
    }
}

@Test func inputValidation() {
    // Expect any error of a certain type
    #expect(throws: ValidationError.self) {
        try validateEmail("")
    }
}

@Test func customErrorCheck() {
    // Use a closure for more complex error validation
    #expect {
        try riskyOperation()
    } throws: { error in
        guard let appError = error as? AppError else {
            return false
        }
        return appError.code == 422
    }
}

Parameterized Tests

This is where Swift Testing really shines compared to XCTest. Instead of writing separate test functions for each input, pass a collection of arguments:

Swift
@Test("Email validation", arguments: [
    "[email protected]",
    "[email protected]",
    "[email protected]",
])
func validEmails(email: String) {
    #expect(EmailValidator.isValid(email))
}

Each argument becomes a separately reportable test case in Xcode. You can click into individual failures to see exactly which input caused the problem.

For enums, it's even cleaner with CaseIterable:

Swift
enum Theme: CaseIterable {
    case light, dark, system
}

@Test(arguments: Theme.allCases)
func themeAppliesCorrectly(_ theme: Theme) {
    let view = ThemedView(theme: theme)
    #expect(view.backgroundColor != nil)
}

When you pass two collections, Swift Testing creates a Cartesian product of all combinations:

Swift
@Test(arguments: ["USD", "EUR"], [0.0, 99.99, 1000.0])
func formatCurrency(code: String, amount: Double) {
    let formatted = CurrencyFormatter.format(
        amount, currency: code
    )
    #expect(!formatted.isEmpty)
}
// Runs 6 test cases (2 currencies x 3 amounts)

If you want paired inputs instead, use zip:

Swift
@Test(arguments: zip(
    ["[email protected]", "invalid", ""],
    [true, false, false]
))
func emailValidation(email: String, expected: Bool) {
    #expect(EmailValidator.isValid(email) == expected)
}

Traits for Customizing Test Behavior

Traits let you tag, filter, and control tests. They're passed as arguments to @Test or @Suite.

Tags

Tags work across suite boundaries, letting you run subsets of your test suite:

Swift
extension Tag {
    @Tag static var networking: Self
    @Tag static var critical: Self
}

@Test(.tags(.networking))
func fetchUserProfile() async throws {
    let profile = try await api.fetchProfile(id: "123")
    #expect(profile != nil)
}

@Suite(.tags(.critical))
struct PaymentTests {
    @Test func chargeCard() { /* inherits .critical tag */ }
}

Conditional Execution

Swift
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func integrationTest() async throws {
    // Only runs in CI
}

@Test(.disabled("Waiting for backend API"))
func upcomingFeature() { /* skipped */ }

Time Limits and Bug References

Swift
@Test(
    "Upload large file",
    .tags(.networking),
    .timeLimit(.minutes(2)),
    .bug("https://github.com/org/repo/issues/42")
)
func uploadLargeFile() async throws {
    let result = try await uploader.upload(largeFile)
    #expect(result.success)
}

Sequential Execution

Tests run in parallel by default using Swift Concurrency. When tests share mutable state (like a database), force sequential execution with .serialized:

Swift
@Suite(.serialized)
struct DatabaseTests {
    @Test func createRecord() { /* ... */ }
    @Test func readRecord() { /* ... */ }
}

Async Confirmations

For callback-based code, use confirmation() instead of XCTest's XCTestExpectation:

Swift
@Test func notificationPosted() async {
    await confirmation("Login notification") { confirm in
        NotificationCenter.default.addObserver(
            forName: .userDidLogin,
            object: nil,
            queue: .main
        ) { _ in
            confirm()
        }
        LoginManager.performLogin()
    }
}

You can specify an expected count for events that fire multiple times, or use expectedCount: 0 to assert something does not happen.

Migrating from XCTest

Both frameworks can coexist in the same test target, so you can migrate incrementally. Here's a quick translation table:

XCTestSwift Testing
class MyTests: XCTestCase@Suite struct MyTests
func testSomething()@Test func something()
XCTAssertEqual(a, b)#expect(a == b)
XCTAssertNil(value)#expect(value == nil)
XCTUnwrap(optional)try #require(optional)
setUp() / tearDown()init() / deinit
XCTestExpectationconfirmation()

One important rule: don't mix assertion styles within a single test function. Stick to #expect/#require in @Test functions and XCTAssert* in XCTestCase subclasses.

Note that UI testing with XCUIApplication and performance testing with XCTMetric are still XCTest-only. Keep using XCTest for those use cases.

Known Issues

When you know a test has a bug that hasn't been fixed yet, use withKnownIssue to prevent it from failing your test suite:

Swift
@Test func flakyNetworkCall() {
    withKnownIssue("Server throttles under load") {
        let result = try fetchData()
        #expect(result.statusCode == 200)
    }
}

For intermittent issues, pass isIntermittent: true so the test passes when the bug doesn't manifest:

Swift
withKnownIssue("Timeout under load", isIntermittent: true) {
    try await slowOperation()
}

Swift Testing requires Xcode 16+ and works with the Swift 6 toolchain, though you can use it in Swift 5 language mode. Check the Swift Testing documentation for the full API reference and the latest additions in newer Xcode versions.

// Frequently_Asked

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.