Swift Testing Framework: Apple's Modern Replacement for XCTest


You have hundreds of XCTest methods scattered across your project. Each one starts with test, each one inherits from XCTestCase, and each one runs in an order you never quite control. When a test fails, XCTAssertEqual hands you a generic message that makes you scroll back to the assertion to figure out what actually went wrong. Apple’s Swift Testing framework, introduced alongside Xcode 16, rethinks all of this from scratch — and once you see how @Test, #expect, and parameterized arguments work together, going back to XCTest feels like writing Objective-C in a Swift world.

This post covers the core Swift Testing API surface: @Test, @Suite, #expect, #require, parameterized testing, parallel execution, and withKnownIssue. We won’t cover UI testing or migration tooling — those deserve their own dedicated treatment.

Contents

The Problem

Consider a typical XCTest setup for validating a movie rating system. The boilerplate is substantial, the assertions are opaque, and parameterized scenarios require manual loops or separate methods.

import XCTest

final class MovieRatingTests: XCTestCase {
    var sut: MovieRatingService!

    override func setUp() {
        super.setUp()
        sut = MovieRatingService()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func testValidRatingIsAccepted() {
        let result = sut.submit(rating: 4, for: "Toy Story")
        XCTAssertTrue(result.isSuccess) // What was the actual value?
    }

    func testInvalidRatingIsRejected() {
        let result = sut.submit(rating: 11, for: "Toy Story")
        XCTAssertFalse(result.isSuccess)
    }

    func testRatingClampsToRange() {
        for rating in [-1, 0, 1, 5, 10, 11] {
            let clamped = sut.clamp(rating)
            XCTAssertGreaterThanOrEqual(clamped, 0)
            XCTAssertLessThanOrEqual(clamped, 10)
        }
        // If rating 11 fails, the loop continues -- no isolation
    }
}

Three pain points stand out. First, setUp and tearDown are ceremony that the compiler does not enforce — forget to call super and you have a subtle bug. Second, XCTAssert functions give minimal context on failure: XCTAssertTrue failed tells you nothing about the actual value. Third, looping over test inputs inside a single method means one failure poisons the entire case, and Xcode’s test navigator shows a single red dot instead of highlighting the exact failing input.

Meet Swift Testing

Swift Testing replaces the class-based XCTest pattern with a macro-driven, struct-friendly approach. Tests are free functions (or methods on any type) annotated with @Test. No subclassing required, no naming conventions, no setUp / tearDown lifecycle.

Here is the same movie rating test rewritten with Swift Testing:

import Testing

struct MovieRatingTests {
    let service = MovieRatingService()

    @Test("Valid rating is accepted")
    func validRating() {
        let result = service.submit(rating: 4, for: "Toy Story")
        #expect(result.isSuccess)
    }

    @Test("Invalid rating is rejected")
    func invalidRating() {
        let result = service.submit(rating: 11, for: "Toy Story")
        #expect(!result.isSuccess)
    }
}

Several things changed. The struct owns its own state — the service property is initialized directly, no lifecycle methods needed. The @Test macro accepts a human-readable display name that appears in Xcode’s test navigator, so your method names can be concise rather than descriptive. And #expect captures the entire expression, showing you the actual values on failure instead of a generic “assertion failed.”

Note: Swift Testing requires Xcode 16+ and is available on all Apple platforms, as well as Linux and Windows via Swift 6.0+.

The @Test Macro

The @Test macro is the entry point for defining a test. It accepts several arguments beyond the display name:

@Test("Buzz Lightyear has correct catchphrase",
      .tags(.characterValidation),
      .timeLimit(.minutes(1)))
func buzzCatchphrase() {
    let buzz = Character(name: "Buzz Lightyear",
                         catchphrase: "To infinity and beyond!")
    #expect(buzz.catchphrase.contains("infinity"))
}

The .tags trait lets you categorize tests for filtered execution (run only .characterValidation tests from the command line or Xcode’s test plan). The .timeLimit trait enforces a deadline — if the test does not finish in time, it fails. These traits replace XCTest’s disparate mechanisms (test plans, executionTimeAllowance) with a unified, declarative API.

Organizing Tests with @Suite

When you have related tests, group them with @Suite. A suite is any type annotated with @Suite — typically a struct, but enums and actors work too.

import Testing

@Suite("Pixar Movie Catalog")
struct MovieCatalogTests {
    let catalog = MovieCatalog(studio: .pixar)

    @Suite("Search")
    struct SearchTests {
        let catalog = MovieCatalog(studio: .pixar)

        @Test("Finds movies by title substring")
        func searchByTitle() {
            let results = catalog.search("Nemo")
            #expect(results.contains { $0.title == "Finding Nemo" })
        }

        @Test("Returns empty for unknown title")
        func searchUnknown() {
            let results = catalog.search("Shrek")
            #expect(results.isEmpty)
        }
    }

    @Suite("Sorting")
    struct SortingTests {
        @Test("Sorts by release year ascending")
        func sortByYear() {
            let catalog = MovieCatalog(studio: .pixar)
            let sorted = catalog.sorted(by: .releaseYear)
            #expect(sorted.first?.title == "Toy Story")
        }
    }
}

Suites nest naturally through Swift’s type nesting. Xcode’s test navigator renders them as a hierarchy, making large test targets navigable. Each nested struct gets its own instance — there is no shared mutable state leaking between suites.

Tip: Prefer structs over classes for suites. Structs give you value semantics, which means each @Test method runs against a fresh copy of the suite’s properties. This eliminates the entire category of bugs caused by shared mutable state in XCTest’s class-based model.

Expectations: #expect and #require

Swift Testing provides two expectation macros that replace all seventeen XCTAssert* functions.

#expect — Soft Assertion

#expect evaluates a boolean expression. If it fails, the test records the failure but continues executing. This is equivalent to XCTest’s XCTAssert family, but with dramatically better diagnostics.

@Test("Movie budget validation")
func budgetValidation() {
    let movie = Movie(title: "Inside Out 2", budget: 200_000_000)

    #expect(movie.budget > 0)
    #expect(movie.budget <= 500_000_000)
    #expect(movie.isWithinBudgetRange) // All three are evaluated
}

When #expect fails, Swift Testing captures and displays the expression tree. Instead of XCTAssertEqual failed: ("180000000") is not equal to ("200000000"), you see the full source expression with the actual runtime values interpolated. This removes the guesswork from test failures.

#require — Hard Assertion

#require works like #expect but throws on failure, stopping the test immediately. Use it when subsequent code depends on the asserted condition being true.

@Test("Parse movie from JSON")
func parseMovie() throws {
    let json = """
    {"title": "WALL-E", "year": 2008, "rating": 8.4}
    """
    let data = json.data(using: .utf8)

    let movie = try #require(Movie.decode(from: data))
    // If decode returns nil, the test stops here

    #expect(movie.title == "WALL-E")
    #expect(movie.year == 2008)
}

The try #require pattern replaces the awkward XCTest dance of XCTUnwrap plus a separate assertion. It reads naturally — “require that this value exists, then continue.”

Expecting Errors

Swift Testing handles expected throws with a trailing closure on #expect:

@Test("Rejects duplicate movie entries")
func rejectDuplicate() {
    let catalog = MovieCatalog()
    catalog.add(Movie(title: "Coco", year: 2017))

    #expect(throws: CatalogError.duplicateEntry) {
        try catalog.add(Movie(title: "Coco", year: 2017))
    }
}

This replaces XCTAssertThrowsError and its awkward closure-based error inspection pattern. The expected error type is declared inline, and Swift Testing verifies both that an error is thrown and that it matches the specified type and value.

Parameterized Testing

This is where Swift Testing leaps ahead of XCTest. The @Test(arguments:) parameter lets you run the same test logic against multiple inputs, with each argument producing an independent test case in the navigator.

@Test("Rating is clamped to valid range",
      arguments: [-5, -1, 0, 1, 5, 10, 11, 100])
func ratingClamping(input: Int) {
    let service = MovieRatingService()
    let clamped = service.clamp(input)

    #expect(clamped >= 0)
    #expect(clamped <= 10)
}

Each value in the arguments array becomes its own test case. If input: 11 fails, Xcode highlights that specific case in red while the other seven remain green. Compare this to the XCTest loop approach where a single failure makes the entire method fail.

Multi-Dimensional Arguments

You can parameterize over multiple dimensions using zip or a custom collection of tuples:

@Test("Movie year matches expected era",
      arguments: zip(
          ["Toy Story", "Finding Nemo", "Inside Out", "Soul"],
          [1995, 2003, 2015, 2020]
      ))
func movieYearValidation(title: String, expectedYear: Int) {
    let catalog = PixarCatalog()
    let movie = catalog.find(byTitle: title)

    #expect(movie?.year == expectedYear)
}

The arguments must conform to Sendable since Swift Testing runs parameterized cases in parallel by default. For custom types, conform to both Sendable and CustomTestStringConvertible to get readable names in the test navigator instead of raw memory descriptions.

struct MovieFixture: Sendable, CustomTestStringConvertible {
    let title: String
    let year: Int
    let expectedGenre: Genre

    var testDescription: String { title }
}

@Test("Genre classification",
      arguments: [
          MovieFixture(title: "Monsters, Inc.", year: 2001,
                       expectedGenre: .comedy),
          MovieFixture(title: "Up", year: 2009,
                       expectedGenre: .adventure),
          MovieFixture(title: "Coco", year: 2017,
                       expectedGenre: .musical)
      ])
func genreClassification(fixture: MovieFixture) {
    let classifier = GenreClassifier()
    let genre = classifier.classify(title: fixture.title,
                                     year: fixture.year)
    #expect(genre == fixture.expectedGenre)
}

Apple Docs: @Test(arguments:) — Swift Testing

Advanced Usage

Parallel Execution by Default

Swift Testing runs tests in parallel by default. This is a fundamental design difference from XCTest, where parallel execution was opt-in and limited to separate test bundles. In Swift Testing, every @Test function within a suite can run concurrently with every other.

This means your tests must not depend on shared mutable state. If you have been relying on XCTest’s sequential execution to avoid race conditions in tests, Swift Testing will surface those hidden dependencies immediately.

@Suite(.serialized) // Opt into serial execution when needed
struct DatabaseTests {
    @Test func insertRecord() { /* ... */ }
    @Test func queryRecords() { /* ... */ }
    @Test func deleteRecord() { /* ... */ }
}

The .serialized trait on a @Suite forces sequential execution within that suite. Use it sparingly — if you need it, it is usually a sign that your system under test has shared state that should be refactored. However, integration tests against a real database or file system are a legitimate use case.

Warning: Parallel execution means test methods receive isolated copies of the suite’s properties (since suites should be value types). If you use a class-based suite, you are responsible for managing thread safety yourself.

withKnownIssue

Sometimes you know a test will fail — a bug is filed, a fix is in progress, but you do not want to delete the test or mark it as skipped. withKnownIssue is designed for exactly this scenario.

@Test("Render pipeline handles 4K assets")
func render4KAssets() {
    let pipeline = RenderPipeline()

    withKnownIssue("Crashes on 4K textures -- FB12345678") {
        let result = try pipeline.render(asset: .ultraHD)
        #expect(result.resolution == .fourK)
    }
}

A test wrapped in withKnownIssue is expected to fail. If the assertions inside the closure fail, the test is marked with a known-issue indicator rather than a red failure. The moment the underlying bug is fixed and the assertions start passing, Swift Testing flags the test as an “unexpected success” — prompting you to remove the withKnownIssue wrapper. This is a far more precise tool than XCTest’s XCTExpectFailure, and it prevents known issues from polluting your CI signal.

Conditional Tests

Use .enabled(if:) and .disabled(_:) traits to conditionally run tests based on runtime conditions:

@Test("Metal rendering produces correct output",
      .enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))
func metalRendering() {
    // Skipped on CI where GPU is not available
    let renderer = MetalRenderer()
    #expect(renderer.canRender)
}

@Test("Legacy format parsing",
      .disabled("Blocked by rdar://12345 -- parser rewrite"))
func legacyParsing() {
    // Clearly documented why this test is disabled
}

Tags for Cross-Cutting Concerns

Define tags as static extensions and use them to create logical test groupings that cross suite boundaries:

extension Tag {
    @Tag static var networking: Self
    @Tag static var persistence: Self
    @Tag static var pixarCatalog: Self
}

@Test("Fetch movie poster from CDN",
      .tags(.networking, .pixarCatalog))
func fetchPoster() { /* ... */ }

@Test("Cache poster to disk",
      .tags(.persistence, .pixarCatalog))
func cachePoster() { /* ... */ }

From Xcode’s test navigator or the command line, you can run all tests tagged .pixarCatalog regardless of which suite they belong to. This replaces XCTest’s test plan filtering with a compile-time-checked, code-level mechanism.

Performance Considerations

Swift Testing’s parallel-by-default execution model can significantly reduce total test suite duration. In the WWDC24 session Go Further with Swift Testing, Apple demonstrated suites that completed 2-3x faster than their XCTest equivalents simply by running in parallel without any code changes beyond the migration.

However, parallelism introduces overhead. Each parameterized test case and each parallel suite may spawn a new Swift concurrency task. For suites with thousands of parameterized cases, the task creation overhead can become measurable. Profile with Instruments’ Swift Concurrency template if you observe unexpected slowdowns.

The macro-based #expect also carries a compile-time cost. Swift Testing’s macros perform expression tree capture at build time, which can add to compilation duration in very large test targets. If incremental build times increase noticeably after migration, consider splitting your test target into smaller modules.

Tip: Run your test suite with swift test --parallel on the command line to verify that parallel execution works correctly outside of Xcode. This catches environment-dependent failures that Xcode’s more forgiving sandbox might mask.

When to Use (and When Not To)

ScenarioRecommendation
New test targets in Xcode 16+Use Swift Testing exclusively. No reason to start with XCTest.
Existing XCTest with no shared stateMigrate incrementally. Both coexist in the same target.
UI tests (XCUITest)Stay with XCTest. Swift Testing has no UI testing equivalent.
Performance tests (measure {})Stay with XCTest. No built-in performance measurement API.
Tests needing setUp/tearDownEvaluate whether init on a struct covers your needs.
Parameterized scenariosSwift Testing wins. Individual results per input.
CI on Linux/WindowsSwift Testing works everywhere via Swift 6.0+.

Both frameworks can coexist within the same test target. Apple explicitly supports incremental migration — you do not need to rewrite everything at once. Import Testing alongside XCTest and convert tests one suite at a time.

Apple Docs: Swift Testing — Developer Documentation

Summary

  • @Test replaces XCTestCase subclasses and test-prefixed methods with a macro-driven, struct-friendly approach that works on any Swift type.
  • #expect and #require replace all seventeen XCTAssert* functions with two macros that capture full expression trees for clear failure diagnostics.
  • @Test(arguments:) enables parameterized testing where each input becomes an independent, individually reportable test case — a capability that XCTest never offered.
  • Parallel execution is the default, surfacing hidden shared-state dependencies and reducing total suite runtime.
  • withKnownIssue provides a precise mechanism for tracking expected failures without polluting CI signals, replacing XCTest’s coarser XCTExpectFailure.

Swift Testing represents Apple’s long-term direction for testing in the Swift ecosystem. If you are starting a new project or have the bandwidth to migrate incrementally, the improved diagnostics and parameterized testing alone justify the switch. For a deeper look at advanced patterns like custom traits and confirmation-based async testing, see our upcoming post on advanced Swift Testing patterns.