Swift Testing Framework
Apple's modern testing framework replacing XCTest for unit tests. Ships with Xcode 16+ and Swift 6.
Quick Reference
import Testing
@Test func basicTest() {
#expect(2 + 2 == 4)
}
@Test("User can log in with valid credentials")
func loginWithValidCredentials() async throws {
let result = try await AuthService.login(user: "test", pass: "valid")
#expect(result.isSuccess)
}
Core Macros
@Test - Define a test
// Basic test
@Test func simpleTest() { }
// With display name
@Test("Validates email format correctly")
func emailValidation() { }
// Async test
@Test func asyncOperation() async throws { }
// With traits (see Traits section)
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func ciOnlyTest() { }
#expect - Assert conditions
// Boolean expectation
#expect(value == expected)
#expect(array.isEmpty)
#expect(result != nil)
// With custom message
#expect(count > 0, "Count should be positive")
// Throwing expectation
#expect(throws: ValidationError.self) {
try validator.validate(invalidInput)
}
// Specific error expectation
#expect(throws: NetworkError.timeout) {
try await client.fetch(slowEndpoint)
}
// No throw expectation
#expect(throws: Never.self) {
try safeOperation()
}
#require - Fatal assertions
// Unwrap optionals (test fails immediately if nil) let user = try #require(await fetchUser(id: 123)) #expect(user.name == "Alice") // Boolean requirement (test fails immediately if false) try #require(database.isConnected) try #require(file.exists, "Test fixture missing")
Parameterized Tests
Run the same test with multiple inputs:
@Test(arguments: ["hello", "world", "swift"])
func stringIsNotEmpty(input: String) {
#expect(!input.isEmpty)
}
@Test(arguments: [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300)
])
func addition(a: Int, b: Int, expected: Int) {
#expect(a + b == expected)
}
// Zip two collections (paired arguments)
@Test(arguments: zip(["a", "b"], [1, 2]))
func pairedTest(letter: String, number: Int) {
#expect(!letter.isEmpty)
#expect(number > 0)
}
// Cartesian product (all combinations)
@Test(arguments: ["light", "dark"], ["iPhone", "iPad"])
func themeDeviceMatrix(theme: String, device: String) {
// Runs 4 times: light+iPhone, light+iPad, dark+iPhone, dark+iPad
}
Traits
Modify test behavior with traits:
// Disabled test
@Test(.disabled("Known bug: #123"))
func brokenFeature() { }
// Conditional execution
@Test(.enabled(if: ProcessInfo.processInfo.environment["LIVE_TESTS"] != nil))
func liveAPITest() { }
// Bug reference
@Test(.bug("https://github.com/org/repo/issues/456"))
func workaroundTest() { }
// Tags for filtering
@Test(.tags(.ui, .slow))
func renderingTest() { }
// Time limit
@Test(.timeLimit(.minutes(2)))
func longRunningTest() async { }
// Serial execution (no parallelism)
@Test(.serialized)
func databaseMigrationTest() { }
// Combine traits
@Test(
"Network timeout handling",
.tags(.network),
.timeLimit(.seconds(30)),
.bug("https://example.com/issue/789", "Flaky on CI")
)
func timeoutTest() async throws { }
Custom Tags
extension Tag {
@Tag static var ui: Self
@Tag static var slow: Self
@Tag static var network: Self
@Tag static var integration: Self
}
@Test(.tags(.ui, .slow))
func animationTest() { }
Test Suites (@Suite)
Group related tests:
@Suite("User Authentication")
struct AuthTests {
@Test func loginSucceeds() { }
@Test func loginFailsWithBadPassword() { }
@Test func logoutClearsSession() { }
}
// Nested suites
@Suite struct NetworkTests {
@Suite struct RequestTests {
@Test func getRequest() { }
@Test func postRequest() { }
}
@Suite struct ResponseTests {
@Test func parseJSON() { }
@Test func handleError() { }
}
}
// Suite-level traits (apply to all tests)
@Suite(.serialized)
struct DatabaseTests {
@Test func migration() { }
@Test func rollback() { }
}
Setup and Teardown
@Suite struct ViewModelTests {
var viewModel: ViewModel!
// Per-test setup (init runs before each test)
init() {
viewModel = ViewModel()
}
// Per-test teardown
deinit {
viewModel = nil
}
@Test func initialState() {
#expect(viewModel.items.isEmpty)
}
}
// Async setup
@Suite struct AsyncSetupTests {
var database: Database
init() async throws {
database = try await Database.connect()
}
}
Confirmation (Async Event Verification)
Wait for async events/callbacks:
@Test func notificationReceived() async {
await confirmation { confirm in
NotificationCenter.default.addObserver(
forName: .userLoggedIn,
object: nil,
queue: .main
) { _ in
confirm()
}
await loginUser()
}
}
// Expected count
@Test func multipleCallbacks() async {
await confirmation(expectedCount: 3) { confirm in
for _ in 0..<3 {
asyncOperation { confirm() }
}
}
}
Migrating from XCTest
| XCTest | Swift Testing |
|---|---|
class FooTests: XCTestCase | @Suite struct FooTests |
func testBar() | @Test func bar() |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertTrue(x) | #expect(x) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertThrowsError(...) | #expect(throws: Error.self) { } |
XCTUnwrap(optional) | try #require(optional) |
override func setUp() | init() |
override func tearDown() | deinit |
setUpWithError() async | init() async throws |
expectation(description:) | await confirmation { } |
Migration Example
Before (XCTest):
class UserServiceTests: XCTestCase {
var service: UserService!
override func setUp() {
super.setUp()
service = UserService()
}
func testFetchUserReturnsUser() async throws {
let user = try await service.fetch(id: 1)
XCTAssertEqual(user.name, "Alice")
XCTAssertNotNil(user.email)
}
func testFetchInvalidUserThrows() async {
do {
_ = try await service.fetch(id: -1)
XCTFail("Expected error")
} catch {
XCTAssertTrue(error is UserError)
}
}
}
After (Swift Testing):
@Suite struct UserServiceTests {
let service = UserService()
@Test func fetchUserReturnsUser() async throws {
let user = try await service.fetch(id: 1)
#expect(user.name == "Alice")
#expect(user.email != nil)
}
@Test func fetchInvalidUserThrows() async {
#expect(throws: UserError.self) {
try await service.fetch(id: -1)
}
}
}
Running Tests
Xcode
- •Cmd+U: Run all tests
- •Click diamond in gutter: Run single test
- •Test Navigator (Cmd+6): Browse and run
Command Line
# Run all tests swift test # Run specific test swift test --filter "UserServiceTests/fetchUser" # Run tagged tests swift test --filter ".tags(.network)" # Parallel execution (default) swift test --parallel # Serial execution swift test --no-parallel
Best Practices
- •
Use descriptive names -
@Test("Login fails with expired token")over@Test func testLoginFail() - •
Prefer #expect over #require - Use
#requireonly when subsequent assertions depend on the unwrapped value - •
Parameterize repetitive tests - Don't copy-paste tests with different inputs
- •
Tag for CI filtering - Use
.tags(.slow)for tests to skip in fast feedback loops - •
Keep suites focused - One suite per logical unit (ViewModel, Service, etc.)
- •
Avoid shared mutable state - Struct suites with
init()> class with setUp()
Common Gotchas
- •Mixing frameworks: Swift Testing and XCTest can coexist but don't share state
- •No setUp/tearDown: Use
init/deinitinstead (or asyncinit) - •Parallel by default: Tests run concurrently unless
.serializedapplied - •#expect vs #require:
#expectlogs and continues;#requirehalts the test - •Confirmation timeout: Default is 60 seconds; use
timeout:parameter for slower operations