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
- Meet Swift Testing
- Organizing Tests with @Suite
- Expectations: #expect and #require
- Parameterized Testing
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
@Testmethod 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 --parallelon 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)
| Scenario | Recommendation |
|---|---|
| New test targets in Xcode 16+ | Use Swift Testing exclusively. No reason to start with XCTest. |
| Existing XCTest with no shared state | Migrate 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/tearDown | Evaluate whether init on a struct covers your needs. |
| Parameterized scenarios | Swift Testing wins. Individual results per input. |
| CI on Linux/Windows | Swift 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
@TestreplacesXCTestCasesubclasses andtest-prefixed methods with a macro-driven, struct-friendly approach that works on any Swift type.#expectand#requirereplace all seventeenXCTAssert*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.
withKnownIssueprovides a precise mechanism for tracking expected failures without polluting CI signals, replacing XCTest’s coarserXCTExpectFailure.
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.