Swift Testing Killer Features: Beyond Cleaner Syntax

Swift Testing Killer Features: Beyond Cleaner Syntax
Swift Testing isn't just cleaner syntax—it has features that XCTest simply doesn't have. Let's explore the capabilities that make it worth adopting.
Follow along with the code: iOS-Practice on GitHub
Parameterized Tests
This is the feature I use most. Run the same test with different inputs:
@Test("Fetches weather for various cities", arguments: ["NYC", "LA", "Tokyo", "London", "Paris"])
func fetchesForCity(city: String) async throws {
let weather = try await sut.getWeather(for: city)
#expect(weather.city == city)
}
This runs 5 times, once per city. Each run appears separately in Xcode's test navigator—if Tokyo fails, you see "Tokyo" failed, not just "fetchesForCity failed."
In XCTest, you'd need 5 separate test methods or a loop that hides which case failed.
Multiple Parameters
You can test combinations too:
@Test(arguments: [
(city: "NYC", temp: 72),
(city: "LA", temp: 85),
(city: "Tokyo", temp: 68)
])
func weatherMatchesExpected(city: String, temp: Int) async throws {
let result = try await service.fetch(city)
#expect(result.temperature == temp)
}
Tags: Filter Your Test Runs
Define custom tags and apply them to tests:
extension Tag {
@Tag static var networking: Self
@Tag static var slow: Self
@Tag static var critical: Self
}
@Test(.tags(.networking))
func fetchFromAPI() async { }
@Test(.tags(.networking, .slow))
func downloadLargeFile() async { }
@Test(.tags(.critical))
func userAuthentication() { }
Run only networking tests in CI, or skip slow tests during local development. Tags propagate to nested tests too.
Test Suites: Organize with Nested Structs
Group related tests hierarchically:
@Suite("Weather Manager")
struct WeatherManagerTests {
@Suite("Caching")
struct CachingTests {
@Test func cachesFreshData() { }
@Test func expiresOldData() { }
}
@Suite("Error Handling")
struct ErrorTests {
@Test func handlesNetworkError() { }
@Test func handlesCityNotFound() { }
}
}
This creates a tree structure in Xcode's test navigator. Suites share setup through their init(), and nested suites inherit parent setup.
Traits: Control Test Behavior
Disabled Tests
@Test(.disabled("API not deployed yet"))
func newFeature() async { }
@Test(.disabled(if: isCI, "Flaky on CI"))
func flakyTest() { }
Time Limits
@Test(.timeLimit(.seconds(5)))
func mustBeFast() async { }
If the test takes longer than 5 seconds, it fails. Great for catching performance regressions.
Bug References
Link tests to issue trackers:
@Test(.bug("https://github.com/org/repo/issues/123"))
func regressionTest() { }
@Test(.bug(id: "JIRA-456"))
func trackedIssue() { }
Confirmations: Better Async Expectations
For callback-based APIs, use confirmation instead of XCTest's expectation:
// XCTest
func testCallback() {
let exp = expectation(description: "called")
service.fetch { _ in
exp.fulfill()
}
wait(for: [exp], timeout: 5)
}
// Swift Testing
@Test func callback() async {
await confirmation { confirm in
service.fetch { _ in
confirm()
}
}
}
Expect multiple callbacks? Just specify the count:
@Test func multipleCallbacks() async {
await confirmation(expectedCount: 3) { confirm in
service.fetchAll { _ in
confirm()
}
}
}
When to Use Which
| Scenario | Recommendation |
|---|---|
| New test files | Swift Testing |
| Parameterized tests | Swift Testing (XCTest can't) |
| UI tests (XCUITest) | XCTest only |
| Performance tests | XCTest (measure blocks) |
| Pre-Xcode 16 support | XCTest only |
| Existing test suite | Migrate gradually |
Interview tip: "I use Swift Testing for new tests because parameterized tests and tags let me write less code while testing more scenarios. The test navigator shows each parameter combination separately, so I immediately see which specific case failed."