swift-testing
Swift Testing framework with @Test macro, #expect, #require, test organization, and Xcode Playgrounds. Use when user asks about testing, unit tests, @Test, #expect, #require, test suites, or Xcode Playgrounds.
SKILL.md
Full skill instructions
Swift Testing Framework
Comprehensive guide to the modern Swift Testing framework, test organization, assertions, and Xcode Playgrounds for iOS 26 development.
Prerequisites
- Swift 6.0+ (included in Xcode 16+)
- Xcode 26+ recommended
Framework Overview
Swift Testing vs XCTest
| Feature | Swift Testing | XCTest |
|---|---|---|
| Test marking | @Test macro | Method naming test* |
| Assertions | #expect, #require | XCTAssert* |
| Test organization | Structs, actors, classes | XCTestCase subclass |
| Parallelism | Parallel by default | Process-based |
| Setup/Teardown | init/deinit | setUp/tearDown |
Import
import Testing
@Test Macro
Basic Test
import Testing
@Test
func additionWorks() {
let result = 2 + 2
#expect(result == 4)
}
Test with Display Name
@Test("User can create account with valid email")
func createAccountWithValidEmail() async throws {
let account = try await AccountService.create(email: "[email protected]")
#expect(account.email == "[email protected]")
}
Async Tests
@Test
func fetchUserReturnsData() async throws {
let user = try await userService.fetch(id: "123")
#expect(user.name == "John Doe")
}
Throwing Tests
@Test
func invalidEmailThrows() throws {
#expect(throws: ValidationError.invalidEmail) {
try validate(email: "not-an-email")
}
}
Assertions
#expect
Basic expectations:
@Test
func basicExpectations() {
let value = 42
// Equality
#expect(value == 42)
// Inequality
#expect(value != 0)
// Boolean
#expect(value > 0)
// With message
#expect(value == 42, "Value should be 42")
}
#expect with Expressions
@Test
func expressionExpectations() {
let array = [1, 2, 3]
#expect(array.count == 3)
#expect(array.contains(2))
#expect(!array.isEmpty)
let optional: String? = "hello"
#expect(optional != nil)
}
#require
Unwrap optionals and fail fast:
@Test
func requireUnwrapping() throws {
let optional: String? = "hello"
// Unwrap or fail test
let value = try #require(optional)
#expect(value == "hello")
}
@Test
func requireCondition() throws {
let array = [1, 2, 3]
// Fail if condition is false
try #require(array.count > 0)
let first = try #require(array.first)
#expect(first == 1)
}
Testing Throws
@Test
func throwingBehavior() {
// Expect any error
#expect(throws: (any Error).self) {
try riskyOperation()
}
// Expect specific error type
#expect(throws: NetworkError.self) {
try fetchData()
}
// Expect specific error value
#expect(throws: NetworkError.timeout) {
try fetchWithTimeout()
}
}
@Test
func noThrow() {
// Expect no error
#expect(throws: Never.self) {
safeOperation()
}
}
Custom Failure Messages
@Test
func customMessages() {
let user = User(name: "Alice", age: 25)
#expect(user.age >= 18, "User must be an adult, but age was \(user.age)")
}
Test Organization
Test Suites with Structs
@Suite("User Authentication Tests")
struct AuthenticationTests {
@Test("Valid credentials succeed")
func validLogin() async throws {
let result = try await auth.login(user: "test", pass: "password")
#expect(result.success)
}
@Test("Invalid credentials fail")
func invalidLogin() async throws {
let result = try await auth.login(user: "test", pass: "wrong")
#expect(!result.success)
}
}
Nested Suites
@Suite("API Tests")
struct APITests {
@Suite("User Endpoints")
struct UserEndpoints {
@Test func getUser() async { }
@Test func createUser() async { }
}
@Suite("Post Endpoints")
struct PostEndpoints {
@Test func getPosts() async { }
@Test func createPost() async { }
}
}
Using Actors for Isolation
@Suite
actor DatabaseTests {
var database: TestDatabase
init() async throws {
database = try await TestDatabase.create()
}
@Test
func insertWorks() async throws {
try await database.insert(User(name: "Test"))
let count = try await database.count(User.self)
#expect(count == 1)
}
}
Setup and Teardown
@Suite
struct DatabaseTests {
let database: Database
init() async throws {
// Setup - called before each test
database = try await Database.createInMemory()
try await database.migrate()
}
deinit {
// Teardown - called after each test
// Note: async cleanup should be done differently
}
@Test
func testInsert() async throws {
try await database.insert(item)
#expect(try await database.count() == 1)
}
}
Parameterized Tests
Basic Parameters
@Test("Validation", arguments: [
"[email protected]",
"[email protected]",
"[email protected]"
])
func validEmails(email: String) {
#expect(isValidEmail(email))
}
Multiple Arguments
@Test("Addition", 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 Arguments
@Test(arguments: zip(
["hello", "world", "test"],
[5, 5, 4]
))
func stringLength(string: String, expectedLength: Int) {
#expect(string.count == expectedLength)
}
Custom Types as Arguments
struct TestCase: CustomTestStringConvertible {
let input: String
let expected: Int
var testDescription: String {
"'\(input)' should have length \(expected)"
}
}
@Test("String lengths", arguments: [
TestCase(input: "hello", expected: 5),
TestCase(input: "", expected: 0),
TestCase(input: "Swift", expected: 5)
])
func stringLength(testCase: TestCase) {
#expect(testCase.input.count == testCase.expected)
}
Test Traits
.serialized
Run tests sequentially:
@Suite(.serialized)
struct OrderDependentTests {
@Test func step1() { }
@Test func step2() { }
@Test func step3() { }
}
.disabled
Skip tests:
@Test(.disabled("Known bug, see issue #123"))
func brokenFeature() {
// Won't run
}
@Test(.disabled(if: isCI, "Flaky on CI"))
func flakyTest() {
// Conditionally disabled
}
.enabled
Conditionally enable:
@Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_SLOW_TESTS"] != nil))
func slowIntegrationTest() async throws {
// Only runs when environment variable is set
}
.tags
Organize with tags:
extension Tag {
@Tag static var critical: Self
@Tag static var slow: Self
@Tag static var integration: Self
}
@Test(.tags(.critical))
func criticalFeature() { }
@Test(.tags(.slow, .integration))
func slowIntegrationTest() async { }
.timeLimit
Set execution limit:
@Test(.timeLimit(.seconds(5)))
func mustCompleteQuickly() async throws {
// Fails if takes more than 5 seconds
}
.bug
Reference known issues:
@Test(.bug("https://github.com/org/repo/issues/123", "Expected failure"))
func knownIssue() {
// Test expected to fail
}
Parallel Execution
Default Parallel
Tests run in parallel by default:
@Suite
struct ParallelTests {
// These run concurrently
@Test func test1() async { }
@Test func test2() async { }
@Test func test3() async { }
}
Serial When Needed
@Suite(.serialized)
struct SerialTests {
static var sharedState = 0
@Test func first() {
Self.sharedState = 1
#expect(Self.sharedState == 1)
}
@Test func second() {
Self.sharedState = 2
#expect(Self.sharedState == 2)
}
}
Mocking and Test Doubles
Protocol-Based Mocking
protocol UserService {
func fetch(id: String) async throws -> User
}
struct MockUserService: UserService {
var userToReturn: User?
var errorToThrow: Error?
func fetch(id: String) async throws -> User {
if let error = errorToThrow {
throw error
}
guard let user = userToReturn else {
throw TestError.notConfigured
}
return user
}
}
@Suite
struct UserViewModelTests {
@Test
func fetchUserSuccess() async throws {
var mockService = MockUserService()
mockService.userToReturn = User(id: "1", name: "Test")
let viewModel = UserViewModel(service: mockService)
try await viewModel.loadUser(id: "1")
#expect(viewModel.user?.name == "Test")
}
}
Spy for Verification
final class SpyUserService: UserService {
var fetchCallCount = 0
var lastFetchedId: String?
func fetch(id: String) async throws -> User {
fetchCallCount += 1
lastFetchedId = id
return User(id: id, name: "Test")
}
}
@Test
func loadsUserOnAppear() async throws {
let spy = SpyUserService()
let viewModel = UserViewModel(service: spy)
await viewModel.loadUser(id: "123")
#expect(spy.fetchCallCount == 1)
#expect(spy.lastFetchedId == "123")
}
Testing SwiftUI
Testing Observable ViewModels
@Observable
class CounterViewModel {
var count = 0
func increment() {
count += 1
}
}
@Suite
struct CounterViewModelTests {
@Test
func incrementIncreasesCount() {
let viewModel = CounterViewModel()
viewModel.increment()
#expect(viewModel.count == 1)
}
@Test
func multipleIncrements() {
let viewModel = CounterViewModel()
viewModel.increment()
viewModel.increment()
viewModel.increment()
#expect(viewModel.count == 3)
}
}
Testing Async ViewModels
@Observable
@MainActor
class UserListViewModel {
var users: [User] = []
var isLoading = false
private let service: UserService
init(service: UserService) {
self.service = service
}
func loadUsers() async {
isLoading = true
defer { isLoading = false }
users = (try? await service.fetchAll()) ?? []
}
}
@Suite
struct UserListViewModelTests {
@Test
@MainActor
func loadUsersPopulatesArray() async {
var mock = MockUserService()
mock.usersToReturn = [User(id: "1", name: "Alice")]
let viewModel = UserListViewModel(service: mock)
await viewModel.loadUsers()
#expect(viewModel.users.count == 1)
#expect(viewModel.isLoading == false)
}
}
Migration from XCTest
Side-by-Side
Both frameworks can coexist:
// XCTest
import XCTest
class LegacyTests: XCTestCase {
func testOldStyle() {
XCTAssertEqual(2 + 2, 4)
}
}
// Swift Testing
import Testing
@Test
func newStyle() {
#expect(2 + 2 == 4)
}
Mapping Assertions
| XCTest | Swift Testing |
|---|---|
XCTAssertTrue(x) | #expect(x) |
XCTAssertFalse(x) | #expect(!x) |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertNotNil(x) | try #require(x) |
XCTAssertThrowsError | #expect(throws:) |
XCTUnwrap(x) | try #require(x) |
What to Keep in XCTest
- Performance tests (
measure {}) - UI tests (XCUITest)
- Existing stable test suites
Xcode Playgrounds
#Playground Macro (iOS 26)
import SwiftUI
#Playground {
let greeting = "Hello, Playgrounds!"
print(greeting)
}
#Playground("SwiftUI Preview") {
struct ContentView: View {
var body: some View {
Text("Hello, World!")
}
}
ContentView()
}
Named Playground Blocks
#Playground("Data Processing") {
let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map { $0 * 2 }
print(doubled)
}
#Playground("API Simulation") {
struct User: Codable {
let name: String
}
let json = #"{"name": "Alice"}"#
let user = try? JSONDecoder().decode(User.self, from: json.data(using: .utf8)!)
print(user?.name ?? "Unknown")
}
SwiftUI in Playgrounds
#Playground("Interactive UI") {
struct Counter: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") {
count += 1
}
}
}
}
Counter()
}
Best Practices
1. Descriptive Test Names
// GOOD
@Test("User cannot login with expired token")
func expiredTokenLogin() { }
// AVOID
@Test
func test1() { }
2. One Assertion Focus
// GOOD: Focused test
@Test
func userNameIsCapitalized() {
let user = User(name: "alice")
#expect(user.displayName == "Alice")
}
// AVOID: Multiple unrelated assertions
@Test
func userTests() {
let user = User(name: "alice")
#expect(user.displayName == "Alice")
#expect(user.email != nil)
#expect(user.createdAt <= Date())
}
3. Use Structs for Test Suites
// GOOD: Struct-based, each test gets fresh instance
@Suite
struct UserTests {
let service = UserService()
@Test func fetch() { }
@Test func create() { }
}
4. Parameterize Repetitive Tests
// GOOD: Parameterized
@Test(arguments: ["", " ", " "])
func emptyStringsAreInvalid(input: String) {
#expect(!isValid(input))
}
// AVOID: Duplicated tests
@Test func emptyIsInvalid() { #expect(!isValid("")) }
@Test func spaceIsInvalid() { #expect(!isValid(" ")) }
@Test func spacesAreInvalid() { #expect(!isValid(" ")) }
5. Use Tags for Organization
extension Tag {
@Tag static var unit: Self
@Tag static var integration: Self
@Tag static var slow: Self
}
// Filter in Xcode or command line
// swift test --filter "unit"
