- Published on
- 8 min read Intermediate
> Getting Started with Swift Testing: Apple's Modern Test Framework
// What_You_Will_Learn
- Write tests using the @Test macro and #expect assertions
- Organize tests into suites with @Suite
- Use parameterized tests to cover multiple inputs
- Apply traits for tags, time limits, and conditional execution
Swift Testing is Apple's modern testing framework, introduced at WWDC 2024 alongside Xcode 16. It replaces much of the ceremony around XCTest with a cleaner API built on Swift macros and native concurrency. If you've ever been frustrated by XCTest's 40+ assertion functions or struggled with async test expectations, Swift Testing should feel like a breath of fresh air.
Your First Swift Test
The core of Swift Testing is the @Test macro. Unlike XCTest, you don't need to prefix your function names with test or inherit from a base class:
import Testing
@Test func additionWorks() {
#expect(2 + 2 == 4)
}
That's a complete, runnable test. The #expect macro handles assertions and captures the actual evaluated values when something fails, giving you much better diagnostics than XCTest's generic failure messages.
You can also give tests a readable display name:
@Test("User profile loads successfully")
func loadProfile() async throws {
let profile = try await ProfileService.fetch(id: "123")
#expect(profile.name == "Alice")
#expect(profile.email.contains("@"))
}
Tests can be async, throws, or both. No special setup required.
Organizing Tests with @Suite
Group related tests using @Suite. Any struct, final class, or actor can be a suite:
@Suite("Authentication")
struct AuthTests {
let service: AuthService
init() {
service = AuthService(environment: .test)
}
@Test func validCredentialsSucceed() async throws {
let result = try await service.login(
user: "admin",
password: "secret"
)
#expect(result.isAuthenticated)
}
@Test func invalidPasswordFails() async throws {
await #expect(throws: AuthError.invalidCredentials) {
try await service.login(
user: "admin",
password: "wrong"
)
}
}
}
A few things to notice here. The init method replaces XCTest's setUp(). A fresh instance of the suite is created for each test function, so state never leaks between tests. Suites can also be nested for hierarchical organization in Xcode's Test Navigator.
Assertions: #expect vs #require
Swift Testing has just two assertion macros, which replace all 40+ XCTAssert* functions.
#expect is a soft assertion. If it fails, the test records the failure but keeps running. This lets you catch multiple issues in a single test run:
@Test func userProfile() {
let user = User(name: "Alice", age: 30)
#expect(user.name == "Alice")
#expect(user.age >= 18)
#expect(user.isActive)
}
#require is a hard assertion that stops the test immediately by throwing. It's also your go-to for unwrapping optionals, replacing XCTUnwrap:
@Test func parseResponse() throws {
let data = try #require(response.data)
let user = try #require(
JSONDecoder().decode(User.self, from: data) as User?
)
#expect(user.name == "Alice")
}
Use #require for preconditions where continuing would be meaningless, and #expect for everything else.
Testing for Errors
Verifying that code throws the right errors is straightforward:
@Test func divisionByZero() async {
// Expect a specific error value (requires Equatable)
#expect(throws: CalculatorError.divisionByZero) {
try Calculator.divide(10, by: 0)
}
}
@Test func inputValidation() {
// Expect any error of a certain type
#expect(throws: ValidationError.self) {
try validateEmail("")
}
}
@Test func customErrorCheck() {
// Use a closure for more complex error validation
#expect {
try riskyOperation()
} throws: { error in
guard let appError = error as? AppError else {
return false
}
return appError.code == 422
}
}
Parameterized Tests
This is where Swift Testing really shines compared to XCTest. Instead of writing separate test functions for each input, pass a collection of arguments:
@Test("Email validation", arguments: [
"[email protected]",
"[email protected]",
"[email protected]",
])
func validEmails(email: String) {
#expect(EmailValidator.isValid(email))
}
Each argument becomes a separately reportable test case in Xcode. You can click into individual failures to see exactly which input caused the problem.
For enums, it's even cleaner with CaseIterable:
enum Theme: CaseIterable {
case light, dark, system
}
@Test(arguments: Theme.allCases)
func themeAppliesCorrectly(_ theme: Theme) {
let view = ThemedView(theme: theme)
#expect(view.backgroundColor != nil)
}
When you pass two collections, Swift Testing creates a Cartesian product of all combinations:
@Test(arguments: ["USD", "EUR"], [0.0, 99.99, 1000.0])
func formatCurrency(code: String, amount: Double) {
let formatted = CurrencyFormatter.format(
amount, currency: code
)
#expect(!formatted.isEmpty)
}
// Runs 6 test cases (2 currencies x 3 amounts)
If you want paired inputs instead, use zip:
@Test(arguments: zip(
["[email protected]", "invalid", ""],
[true, false, false]
))
func emailValidation(email: String, expected: Bool) {
#expect(EmailValidator.isValid(email) == expected)
}
Traits for Customizing Test Behavior
Traits let you tag, filter, and control tests. They're passed as arguments to @Test or @Suite.
Tags
Tags work across suite boundaries, letting you run subsets of your test suite:
extension Tag {
@Tag static var networking: Self
@Tag static var critical: Self
}
@Test(.tags(.networking))
func fetchUserProfile() async throws {
let profile = try await api.fetchProfile(id: "123")
#expect(profile != nil)
}
@Suite(.tags(.critical))
struct PaymentTests {
@Test func chargeCard() { /* inherits .critical tag */ }
}
Conditional Execution
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func integrationTest() async throws {
// Only runs in CI
}
@Test(.disabled("Waiting for backend API"))
func upcomingFeature() { /* skipped */ }
Time Limits and Bug References
@Test(
"Upload large file",
.tags(.networking),
.timeLimit(.minutes(2)),
.bug("https://github.com/org/repo/issues/42")
)
func uploadLargeFile() async throws {
let result = try await uploader.upload(largeFile)
#expect(result.success)
}
Sequential Execution
Tests run in parallel by default using Swift Concurrency. When tests share mutable state (like a database), force sequential execution with .serialized:
@Suite(.serialized)
struct DatabaseTests {
@Test func createRecord() { /* ... */ }
@Test func readRecord() { /* ... */ }
}
Async Confirmations
For callback-based code, use confirmation() instead of XCTest's XCTestExpectation:
@Test func notificationPosted() async {
await confirmation("Login notification") { confirm in
NotificationCenter.default.addObserver(
forName: .userDidLogin,
object: nil,
queue: .main
) { _ in
confirm()
}
LoginManager.performLogin()
}
}
You can specify an expected count for events that fire multiple times, or use expectedCount: 0 to assert something does not happen.
Migrating from XCTest
Both frameworks can coexist in the same test target, so you can migrate incrementally. Here's a quick translation table:
| XCTest | Swift Testing |
|---|---|
class MyTests: XCTestCase | @Suite struct MyTests |
func testSomething() | @Test func something() |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertNil(value) | #expect(value == nil) |
XCTUnwrap(optional) | try #require(optional) |
setUp() / tearDown() | init() / deinit |
XCTestExpectation | confirmation() |
One important rule: don't mix assertion styles within a single test function. Stick to #expect/#require in @Test functions and XCTAssert* in XCTestCase subclasses.
Note that UI testing with XCUIApplication and performance testing with XCTMetric are still XCTest-only. Keep using XCTest for those use cases.
Known Issues
When you know a test has a bug that hasn't been fixed yet, use withKnownIssue to prevent it from failing your test suite:
@Test func flakyNetworkCall() {
withKnownIssue("Server throttles under load") {
let result = try fetchData()
#expect(result.statusCode == 200)
}
}
For intermittent issues, pass isIntermittent: true so the test passes when the bug doesn't manifest:
withKnownIssue("Timeout under load", isIntermittent: true) {
try await slowOperation()
}
Swift Testing requires Xcode 16+ and works with the Swift 6 toolchain, though you can use it in Swift 5 language mode. Check the Swift Testing documentation for the full API reference and the latest additions in newer Xcode versions.
// Frequently_Asked
// Continue_Learning
Understanding Swift Macros: A Practical Guide
Swift macros generate code at compile time, reducing boilerplate and catching errors early. Learn how to use built-in macros and understand how custom macros work.
Testing Push Notifications on iOS Simulators with xcrun simctl push
Learn how to test push notifications on iOS simulators without needing a device or server setup using xcrun simctl push.
Simulating Device Conditions in Xcode for Real-World Testing
Learn how to use Xcode's Device Conditions feature to simulate thermal state and network conditions to test your app under real-world stress scenarios.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.