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
- Swift Testing Basics
#expectvs#require- Parameterized Tests
- Tags and Traits
- Async Tests
- Advanced Usage
- When to Use (and When Not To)
- Summary
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
Sendablebecause tests can run in parallel. Keep your argument types simple structs or use@unchecked Sendableonly 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
classfor a suite means instances are shared across parallel test runs in some configurations. Preferstructsuites 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)
| Scenario | Recommendation |
|---|---|
| Writing a new unit test for business logic | Swift Testing — cleaner API, better failure messages |
| Adding tests to an existing XCTest suite | Stick with XCTest to avoid mixing |
Testing UI with XCUIApplication | XCTest — 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 Package | Swift Testing — first-class SPM support |
| Parameterized inputs (many input/output pairs) | Swift Testing — arguments: replaces test loops |
| Async code | Both work; Swift Testing requires zero extra boilerplate |
| Targeting Swift < 5.9 or Xcode < 16 | XCTest — 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/#requiremacros instead of class inheritance and naming conventions. #expectcontinues after failure;#requirestops 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
XCTestExpectationboilerplate —async throwsfunctions 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.