swift-testing
SKILL.md
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: "test@example.com")
#expect(account.email == "test@example.com")
}
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: [
"test@example.com",
"user@domain.org",
"name@company.co.uk"
])
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"
Official Resources
Weekly Installs
6
Repository
bluewaves-creat…s-skillsGitHub Stars
1
First Seen
Jan 26, 2026
Security Audits
Installed on
opencode6
claude-code5
codex5
gemini-cli5
continue4
qwen-code4