Unit Testing in Swift: Getting Started with the Swift Testing Framework


XCTest has served iOS developers since 2013. It works, but it was designed before Swift existed — and that shows in every func testSomethingReturnsCorrectValue() method name requirement, every boilerplate XCTAssertEqual that gives you "false is not equal to true" when it fails, and every time you copy-paste the same test five times to cover five different inputs.

Apple introduced the Swift Testing framework at WWDC 2024 (available from Xcode 16 and the Swift 6.0 toolchain, though it works in Swift 5 language mode). It brings Swift-native macros, parameterized tests, descriptive failure messages, and a cleaner API surface. This post covers everything you need to adopt Swift Testing for new code: @Test, @Suite, #expect, #require, parameterized tests, tags, and traits — plus an honest look at where XCTest still belongs.

Note: Swift Testing requires Xcode 16+ (Swift 6.0 toolchain). It works with both Swift 5 and Swift 6 language modes, but is not available in earlier Xcode versions.

Contents

The Problem with XCTest

Consider a FilmService that fetches Pixar films from a remote repository. A typical XCTest suite for it looks like this:

// XCTest — the old way
class FilmServiceTests: XCTestCase {

    var service: FilmService!

    override func setUp() {
        super.setUp()
        service = FilmService(repository: MockFilmRepository())
    }

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

    func testFetchAllFilmsReturnsCorrectCount() throws {
        let expectation = XCTestExpectation(description: "Fetch completes")

        Task {
            let films = try await service.fetchAll()
            XCTAssertEqual(films.count, 25)
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 5)
    }

    func testFetchFilmByIDReturnsCorrectTitle() throws {
        let expectation = XCTestExpectation(description: "Fetch by ID completes")

        Task {
            let film = try await service.fetchByID("toy-story")
            XCTAssertEqual(film?.title, "Toy Story")
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 5)
    }
}

Several issues compound here. Method names must start with test — the framework discovers tests by naming convention, not by annotation. setUp/tearDown are inherited from XCTestCase and the lifecycle is implicit. Testing async code requires XCTestExpectation boilerplate that’s easy to misuse (forget to call fulfill() and your test times out silently). And when XCTAssertEqual(films.count, 25) fails, you get:

XCTAssertEqual failed: ("17") is not equal to ("25")

That tells you the values, but nothing about which condition failed, or the context surrounding the assertion. Swift Testing fixes all of this.

Swift Testing Basics

Swift Testing is built around three macro-driven primitives: @Suite, @Test, and #expect.

Here is the same FilmService test suite rewritten with Swift Testing:

import Testing

@Suite("Pixar Film Service Tests")
struct FilmServiceTests {

    // Properties initialized here are created fresh for each test —
    // no setUp/tearDown needed.
    let service = FilmService(repository: MockFilmRepository())

    @Test("Fetching all films returns the correct count")
    func fetchAllFilmsCount() async throws {
        let films = try await service.fetchAll()
        // Failure message: "films.count (17) is not equal to 25"
        #expect(films.count == 25)
    }

    @Test("Fetching a film by ID returns the correct title")
    func fetchFilmByIDReturnsCorrectTitle() async throws {
        let film = try await service.fetchByID("toy-story")
        #expect(film?.title == "Toy Story")
    }
}

Several things improved immediately. The suite is a struct, not a class — so every test function gets a fresh copy of the suite’s stored properties, meaning isolation is automatic. The @Test annotation marks test functions instead of relying on naming conventions, so you can name functions anything. The string argument to @Suite and @Test becomes a human-readable display name in the Xcode test navigator. And async functions work without any XCTestExpectation setup — async throws is natively supported.

The #expect macro captures the full expression tree at compile time. When films.count == 25 fails, the output is:

Expectation failed: films.count (17) == 25

The macro knows what films.count evaluated to because it captures both sides of the expression at the call site.

Apple Docs: @Suite — Swift Testing

#expect vs #require

Swift Testing provides two assertion macros, and understanding when to use each is important for writing robust tests.

#expect records a failure and continues executing the test. Use it when you want to check multiple conditions and collect all failures in one run:

@Test("Pixar film has valid metadata")
func filmMetadataIsValid() async throws {
    let film = try await service.fetchByID("coco")

    // Both assertions run even if the first fails
    #expect(film?.title == "Coco")
    #expect(film?.year == 2017)
    #expect(film?.director == "Lee Unkrich")
}

#require throws on failure, stopping the test immediately. Use it when subsequent code is meaningless if an earlier assertion fails:

@Test("Fetching Toy Story returns a non-nil film")
func fetchToyStoryIsNonNil() async throws {
    // If this fails, the forced unwrap below would crash —
    // #require stops the test cleanly instead.
    let film = try #require(await service.fetchByID("toy-story"))

    #expect(film.title == "Toy Story")
    #expect(film.year == 1995)
}

The try #require(...) pattern is particularly useful for optional unwrapping: it returns the unwrapped value on success and throws a descriptive error on failure, completely replacing the guard let ... else { XCTFail(...); return } pattern from XCTest.

Apple Docs: #require — Swift Testing

Parameterized Tests

One of the most compelling features in Swift Testing is parameterized testing. In XCTest, testing the same function with multiple inputs meant either a loop inside a single test (which reports as a single failure, losing granularity) or copy-pasting the test function (which is tedious and fragile). Swift Testing solves this with the arguments: parameter on @Test.

struct PixarFilm: Sendable {
    let title: String
    let year: Int
    let director: String
}

@Test("Film year falls within the valid Pixar era", arguments: [
    PixarFilm(title: "Toy Story",    year: 1995, director: "John Lasseter"),
    PixarFilm(title: "Finding Nemo", year: 2003, director: "Andrew Stanton"),
    PixarFilm(title: "WALL·E",       year: 2008, director: "Andrew Stanton"),
    PixarFilm(title: "Up",           year: 2009, director: "Pete Docter"),
    PixarFilm(title: "Coco",         year: 2017, director: "Lee Unkrich")
])
func filmYearIsValid(film: PixarFilm) {
    #expect(film.year >= 1995)
    #expect(film.year <= 2030)
}

Each argument becomes an independent test case in the Xcode test navigator. If Finding Nemo’s year assertion fails, only that case is marked as failed — the others continue running and can pass independently. This dramatically improves feedback when you break a specific edge case.

You can also combine two argument sequences to test all combinations:

@Test("Film can be fetched in all supported locales", arguments:
    ["toy-story", "coco", "soul"],   // film IDs
    ["en", "es", "fr", "ja"]         // locales
)
func filmFetchableInLocale(filmID: String, locale: String) async throws {
    let film = try await service.fetchByID(filmID, locale: locale)
    #expect(film != nil)
}

This generates 12 test cases (3 films × 4 locales) from 7 lines of code.

Note: Arguments must conform to Sendable because tests can run in parallel. Keep your argument types simple structs or use @unchecked Sendable only when you understand the concurrency implications.

Tags and Traits

Test Tags

Tags let you group tests across suites and run subsets of your test plan. Define custom tags as extensions on Tag:

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

Apply them with the .tags() trait:

@Suite("Film Service Tests")
struct FilmServiceTests {

    @Test("Fetching films hits the correct endpoint", .tags(.networking))
    func fetchFilmsHitsCorrectEndpoint() async throws {
        // ...
    }

    @Test("Cached films skip the network", .tags(.networking, .persistence))
    func cachedFilmsAvoidNetwork() async throws {
        // ...
    }
}

You can then run only networking tests from the command line:

swift test --filter "/networking"

Or configure Xcode test plans to include or exclude specific tags — useful for keeping CI fast by skipping slow integration tests during development builds.

Traits

Traits modify how a test behaves. The most useful built-in traits are:

.disabled(_:) — skip a flaky or in-progress test with a documented reason:

@Test("Renders film poster at 4K resolution",
      .disabled("Flaky in CI — tracked in FILM-847"))
func renderFilmPosterAt4K() {
    // ...
}

.timeLimit(_:) — fail a test that runs longer than a threshold:

@Test("Bulk film import completes within acceptable time",
      .timeLimit(.minutes(1)))
func bulkFilmImportPerformance() async throws {
    // ...
}

@available — guard OS-version-specific tests at the annotation level:

@Test("Film recommendations use the on-device ML model")
@available(iOS 18, *)
func filmRecommendationsUseOnDeviceModel() async throws {
    // ...
}

Apple Docs: Trait — Swift Testing

Async Tests

In XCTest, testing async code without async throws support meant XCTestExpectation + waitForExpectations(timeout:). Swift Testing supports async throws functions natively — the test runner handles the async context for you:

@Test("Loading the full Pixar catalog succeeds")
func loadFullCatalog() async throws {
    // No XCTestExpectation. No waitForExpectations.
    // Just async/await as you'd write any other Swift code.
    let catalog = try await service.fetchAll()
    #expect(catalog.count > 0)
}

When testing code that produces a sequence of async values — for example, a FilmViewModel whose @Published films array populates after a Task completes — confirm state after the async work resolves using Task or an async sequence:

@MainActor
@Test("View model populates films after load")
func viewModelPopulatesFilms() async throws {
    let mockRepo = MockFilmRepository(films: [
        PixarFilm(title: "Ratatouille", year: 2007, director: "Brad Bird"),
        PixarFilm(title: "Brave",       year: 2012, director: "Mark Andrews")
    ])
    let viewModel = FilmListViewModel(repository: mockRepo)

    await viewModel.loadFilms()

    #expect(viewModel.films.count == 2)
    #expect(!viewModel.isLoading)
}

Marking the test @MainActor ensures the viewModel property accesses are on the main actor, matching the isolation of most SwiftUI view models.

Advanced Usage

Suite Lifecycle with init and deinit

For setup and teardown that must run once per test (not per suite), initialize state in the struct’s init and tear it down in a custom deinit-equivalent by conforming to a custom pattern. Because Swift Testing suites are structs and each test gets a new instance, stored properties are automatically fresh. For class-based lifecycle (when you need expensive shared state), use a class suite:

@Suite("Pixar Film Database Integration Tests")
final class FilmDatabaseTests {
    let db: FilmDatabase

    init() async throws {
        // Runs before each test — set up an in-memory database
        db = try await FilmDatabase.inMemory()
        try await db.seed(with: PixarFilm.allCases)
    }

    deinit {
        // Runs after each test — clean up resources
        db.close()
    }

    @Test("Database contains seeded films")
    func databaseContainsSeedFilms() async throws {
        let count = try await db.filmCount()
        #expect(count == PixarFilm.allCases.count)
    }
}

Warning: Using class for a suite means instances are shared across parallel test runs in some configurations. Prefer struct suites unless you specifically need shared initialization.

Testing Expected Throws

Use #expect(throws:) to verify that a function throws a specific error type — a pattern that previously required do/catch boilerplate in XCTest:

enum FilmError: Error, Equatable {
    case notFound(id: String)
    case networkUnavailable
}

@Test("Fetching an unknown film ID throws notFound")
func fetchUnknownFilmThrows() async {
    await #expect(throws: FilmError.notFound(id: "monsters-university-2")) {
        _ = try await service.fetchByID("monsters-university-2")
    }
}

If the closure doesn’t throw, or throws a different error, #expect(throws:) records a failure with a clear message describing what was expected versus what was thrown.

Running Specific Tests from the Command Line

You can run a filtered subset of tests without opening Xcode:

# Run all tests in FilmServiceTests
swift test --filter FilmServiceTests

# Run a specific test by display name
swift test --filter "Fetching all films returns the correct count"

# Run all tests tagged with networking
swift test --filter "/networking"

This is especially useful in CI pipelines where you want to run only the fast unit tests on every commit and defer slow integration tests to nightly runs.

XCTest and Swift Testing Coexistence

Both frameworks can live in the same test target. You don’t need to migrate everything at once:

// Legacy XCTest suite — leave it as-is
class LegacyFilmCacheTests: XCTestCase {
    func testCacheHitReturnsCachedValue() { /* ... */ }
}

// New Swift Testing suite in the same target
@Suite("Film Rendering Tests")
struct FilmRenderingTests {
    @Test("Renders poster at correct aspect ratio")
    func rendersPosterAtCorrectAspectRatio() { /* ... */ }
}

Both run when you press ⌘U. The test navigator shows XCTest suites under the old hierarchy and Swift Testing suites under the new one.

When to Use (and When Not To)

ScenarioRecommendation
Writing a new unit test for business logicSwift Testing — cleaner API, better failure messages
Adding tests to an existing XCTest suiteStick with XCTest to avoid mixing
Testing UI with XCUIApplicationXCTest — Swift Testing does not support UI tests
Host app tests (require a running app target)XCTest — Swift Testing doesn’t support host apps
Running tests from a Swift PackageSwift Testing — first-class SPM support
Parameterized inputs (many input/output pairs)Swift Testing — arguments: replaces test loops
Async codeBoth work; Swift Testing requires zero extra boilerplate
Targeting Swift < 5.9 or Xcode < 16XCTest — Swift Testing is unavailable

Note: As of Xcode 16, Swift Testing does not yet support XCUITest-style launch-and-interact UI tests. Apple’s documentation notes this is intentional: UI automation has different constraints than unit testing. For UI tests, XCTest remains the correct choice.

Summary

  • Swift Testing uses @Suite, @Test, and #expect/#require macros instead of class inheritance and naming conventions.
  • #expect continues after failure; #require stops the test — choose based on whether subsequent assertions depend on the checked condition.
  • Parameterized tests (arguments:) replace test loops and copy-paste, giving each input its own pass/fail result in the test navigator.
  • Tags let you organize and filter test runs without restructuring your test files.
  • Async tests require no XCTestExpectation boilerplate — async throws functions work natively.
  • XCTest and Swift Testing coexist in the same target — migrate incrementally.

With your unit tests covering business logic, the next natural layer is testing the views that present that logic. Testing SwiftUI Views: View Models, Snapshots, and Integration Tests covers how to test @Observable view models, snapshot rendered output with swift-snapshot-testing, and write integration tests that exercise entire user flows.