ios-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

iOS/macOS Testing

iOS/macOS 测试

Lifecycle Position

生命周期阶段

Phase 6 (Test). After code is reviewed in Phase 5. Run tests via
/test
or XcodeBuildMCP
test_macos
/
test_sim
.
阶段6(测试)。在阶段5完成代码评审后,通过
/test
或XcodeBuildMCP的
test_macos
/
test_sim
命令运行测试。

Framework Choice

框架选择

FrameworkWhen to Use
Swift Testing (
@Test
,
#expect
)
New test code. Preferred for modern projects.
XCTest (
XCTestCase
,
XCTAssert*
)
Legacy tests, UI tests, performance tests.
Both frameworks can coexist in the same target.
框架使用场景
Swift Testing (
@Test
,
#expect
)
新测试代码。现代项目的首选方案。
XCTest (
XCTestCase
,
XCTAssert*
)
遗留测试、UI测试、性能测试。
这两个框架可以在同一个目标中共存。

Swift Testing Framework

Swift Testing 框架

Basic Test

基础测试

swift
import Testing
@testable import MyApp

@Suite("User Model Tests")
struct UserTests {
    @Test("Creating user with valid email succeeds")
    func createUserWithValidEmail() {
        let user = User(name: "Alice", email: "alice@example.com")
        #expect(user.name == "Alice")
        #expect(user.email == "alice@example.com")
    }

    @Test("Creating user with empty name throws")
    func createUserWithEmptyName() {
        #expect(throws: ValidationError.emptyName) {
            try User(name: "", email: "alice@example.com")
        }
    }
}
swift
import Testing
@testable import MyApp

@Suite("User Model Tests")
struct UserTests {
    @Test("Creating user with valid email succeeds")
    func createUserWithValidEmail() {
        let user = User(name: "Alice", email: "alice@example.com")
        #expect(user.name == "Alice")
        #expect(user.email == "alice@example.com")
    }

    @Test("Creating user with empty name throws")
    func createUserWithEmptyName() {
        #expect(throws: ValidationError.emptyName) {
            try User(name: "", email: "alice@example.com")
        }
    }
}

Parameterized Tests

参数化测试

swift
@Test("Email validation", arguments: [
    ("valid@test.com", true),
    ("no-at-sign", false),
    ("@no-local.com", false),
    ("user@.com", false),
])
func emailValidation(email: String, isValid: Bool) {
    #expect(Email.isValid(email) == isValid)
}
swift
@Test("Email validation", arguments: [
    ("valid@test.com", true),
    ("no-at-sign", false),
    ("@no-local.com", false),
    ("user@.com", false),
])
func emailValidation(email: String, isValid: Bool) {
    #expect(Email.isValid(email) == isValid)
}

Async Tests

异步测试

swift
@Test("Fetching items returns non-empty list")
func fetchItems() async throws {
    let service = ItemService(repository: MockRepository())
    let items = try await service.fetchAll()
    #expect(!items.isEmpty)
}
swift
@Test("Fetching items returns non-empty list")
func fetchItems() async throws {
    let service = ItemService(repository: MockRepository())
    let items = try await service.fetchAll()
    #expect(!items.isEmpty)
}

Tags and Organization

标签与组织

swift
extension Tag {
    @Tag static var networking: Self
    @Tag static var persistence: Self
}

@Test("API call succeeds", .tags(.networking))
func apiCall() async throws { ... }
swift
extension Tag {
    @Tag static var networking: Self
    @Tag static var persistence: Self
}

@Test("API call succeeds", .tags(.networking))
func apiCall() async throws { ... }

XCTest Patterns

XCTest 实践模式

Basic XCTest

基础XCTest

swift
import XCTest
@testable import MyApp

final class UserServiceTests: XCTestCase {
    var sut: UserService!
    var mockRepository: MockUserRepository!

    override func setUp() {
        super.setUp()
        mockRepository = MockUserRepository()
        sut = UserService(repository: mockRepository)
    }

    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }

    func testFetchUsersReturnsExpectedCount() async throws {
        mockRepository.stubbedUsers = [User.sample, User.sample2]
        let users = try await sut.fetchUsers()
        XCTAssertEqual(users.count, 2)
    }
}
swift
import XCTest
@testable import MyApp

final class UserServiceTests: XCTestCase {
    var sut: UserService!
    var mockRepository: MockUserRepository!

    override func setUp() {
        super.setUp()
        mockRepository = MockUserRepository()
        sut = UserService(repository: mockRepository)
    }

    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }

    func testFetchUsersReturnsExpectedCount() async throws {
        mockRepository.stubbedUsers = [User.sample, User.sample2]
        let users = try await sut.fetchUsers()
        XCTAssertEqual(users.count, 2)
    }
}

Async Expectations

异步预期

swift
func testPublisherEmitsValue() {
    let expectation = expectation(description: "Value received")
    let cancellable = viewModel.$items
        .dropFirst()
        .sink { items in
            XCTAssertFalse(items.isEmpty)
            expectation.fulfill()
        }
    viewModel.loadItems()
    wait(for: [expectation], timeout: 5.0)
    cancellable.cancel()
}
swift
func testPublisherEmitsValue() {
    let expectation = expectation(description: "Value received")
    let cancellable = viewModel.$items
        .dropFirst()
        .sink { items in
            XCTAssertFalse(items.isEmpty)
            expectation.fulfill()
        }
    viewModel.loadItems()
    wait(for: [expectation], timeout: 5.0)
    cancellable.cancel()
}

Testing @Observable Models

测试@Observable模型

swift
@Test("ViewModel loads items on fetch")
func viewModelLoadsItems() async {
    let viewModel = ItemListViewModel(repository: MockRepository(items: Item.samples))
    await viewModel.fetch()
    #expect(viewModel.items.count == 3)
    #expect(viewModel.isLoading == false)
}
swift
@Test("ViewModel loads items on fetch")
func viewModelLoadsItems() async {
    let viewModel = ItemListViewModel(repository: MockRepository(items: Item.samples))
    await viewModel.fetch()
    #expect(viewModel.items.count == 3)
    #expect(viewModel.isLoading == false)
}

Testing SwiftData Models

测试SwiftData模型

swift
@Test("Inserting model persists correctly")
func insertModel() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: Item.self, configurations: config)
    let context = container.mainContext

    let item = Item(title: "Test", createdAt: .now)
    context.insert(item)
    try context.save()

    let fetched = try context.fetch(FetchDescriptor<Item>())
    #expect(fetched.count == 1)
    #expect(fetched.first?.title == "Test")
}
swift
@Test("Inserting model persists correctly")
func insertModel() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: Item.self, configurations: config)
    let context = container.mainContext

    let item = Item(title: "Test", createdAt: .now)
    context.insert(item)
    try context.save()

    let fetched = try context.fetch(FetchDescriptor<Item>())
    #expect(fetched.count == 1)
    #expect(fetched.first?.title == "Test")
}

Testing Relationships and Cascading Deletes

测试关系与级联删除

swift
@Test("Deleting parent cascades to children")
func cascadeDelete() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: Folder.self, Document.self, configurations: config)
    let context = container.mainContext

    let folder = Folder(name: "Work")
    let doc = Document(title: "Notes", folder: folder)
    context.insert(folder)
    try context.save()

    context.delete(folder)
    try context.save()

    let docs = try context.fetch(FetchDescriptor<Document>())
    #expect(docs.isEmpty)
}
swift
@Test("Deleting parent cascades to children")
func cascadeDelete() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: Folder.self, Document.self, configurations: config)
    let context = container.mainContext

    let folder = Folder(name: "Work")
    let doc = Document(title: "Notes", folder: folder)
    context.insert(folder)
    try context.save()

    context.delete(folder)
    try context.save()

    let docs = try context.fetch(FetchDescriptor<Document>())
    #expect(docs.isEmpty)
}

SwiftData Testing Safety

SwiftData测试安全

Swift Testing runs
@Suite
tests in parallel by default. This causes crashes with SwiftData:
The Problem: Multiple tests creating separate in-memory
ModelContainer
instances simultaneously leads to Core Data coordinator conflicts →
Fatal error: This model instance was destroyed by calling ModelContext.reset
.
Two Safe Patterns:
Option 1 — Serialize the suite (recommended when tests share setup patterns):
swift
@MainActor
@Suite("MyModel Tests", .serialized)  // Forces sequential execution
struct MyModelTests {
    private func makeContainer() throws -> ModelContainer {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        return try ModelContainer(for: MyModel.self, configurations: config)
    }

    @Test("insert persists")
    func insert() throws {
        let container = try makeContainer()
        let context = container.mainContext
        // container stays alive for the duration of this test
        // ...
    }
}
Option 2 — Fresh container per test (safe without
.serialized
):
swift
@Test("insert persists")
func insert() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: MyModel.self, configurations: config)
    let context = container.mainContext
    // container is local to this test — no cross-test interference
    // ...
}
Critical Rule:
ModelContainer
must outlive any
ModelContext
or
@Model
objects derived from it. If a helper function creates a container and returns only a context or a model object, the container is ARC-deallocated when the helper returns — and all model instances become invalid.
swift
// WRONG — container dies when helper returns
func makeContext() throws -> ModelContext {
    let container = try ModelContainer(...)  // local → deallocated on return
    return container.mainContext  // orphaned context
}

// RIGHT — return the container (or have the owner retain it)
func makeContainer() throws -> ModelContainer {
    return try ModelContainer(for: MyModel.self,
        configurations: ModelConfiguration(isStoredInMemoryOnly: true))
}
Swift Testing默认会并行运行
@Suite
测试,这会导致SwiftData出现崩溃:
问题: 多个测试同时创建独立的内存
ModelContainer
实例会引发Core Data协调器冲突 →
Fatal error: This model instance was destroyed by calling ModelContext.reset
两种安全模式:
选项1 — 序列化测试套件(当测试共享设置模式时推荐):
swift
@MainActor
@Suite("MyModel Tests", .serialized)  // 强制顺序执行
struct MyModelTests {
    private func makeContainer() throws -> ModelContainer {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        return try ModelContainer(for: MyModel.self, configurations: config)
    }

    @Test("insert persists")
    func insert() throws {
        let container = try makeContainer()
        let context = container.mainContext
        // container在整个测试期间保持存活
        // ...
    }
}
选项2 — 每个测试创建新容器(无需
.serialized
也安全):
swift
@Test("insert persists")
func insert() throws {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try ModelContainer(for: MyModel.self, configurations: config)
    let context = container.mainContext
    // container仅属于当前测试 — 无跨测试干扰
    // ...
}
关键规则:
ModelContainer
的生命周期必须长于任何从它派生的
ModelContext
@Model
对象。如果辅助函数创建了容器但仅返回上下文或模型对象,容器会在辅助函数返回时被ARC释放 — 所有模型实例都会失效。
swift
// 错误示例 — 辅助函数返回时容器被销毁
func makeContext() throws -> ModelContext {
    let container = try ModelContainer(...)  // 局部变量 → 返回后被释放
    return container.mainContext  // 上下文成为孤儿
}

// 正确示例 — 返回容器(或让持有者保留它)
func makeContainer() throws -> ModelContainer {
    return try ModelContainer(for: MyModel.self,
        configurations: ModelConfiguration(isStoredInMemoryOnly: true))
}

Mocking with Protocols

基于协议的Mocking

swift
protocol ItemRepository: Sendable {
    func fetchAll() async throws -> [Item]
    func save(_ item: Item) async throws
}

// Production
final class RemoteItemRepository: ItemRepository {
    func fetchAll() async throws -> [Item] { /* network call */ }
    func save(_ item: Item) async throws { /* network call */ }
}

// Test mock
final class MockItemRepository: ItemRepository {
    var stubbedItems: [Item] = []
    var savedItems: [Item] = []

    func fetchAll() async throws -> [Item] { stubbedItems }
    func save(_ item: Item) async throws { savedItems.append(item) }
}
swift
protocol ItemRepository: Sendable {
    func fetchAll() async throws -> [Item]
    func save(_ item: Item) async throws
}

// 生产环境实现
final class RemoteItemRepository: ItemRepository {
    func fetchAll() async throws -> [Item] { /* 网络请求 */ }
    func save(_ item: Item) async throws { /* 网络请求 */ }
}

// 测试用Mock
final class MockItemRepository: ItemRepository {
    var stubbedItems: [Item] = []
    var savedItems: [Item] = []

    func fetchAll() async throws -> [Item] { stubbedItems }
    func save(_ item: Item) async throws { savedItems.append(item) }
}

Test Structure — Arrange/Act/Assert

测试结构 — 准备/执行/断言

Every test follows three sections:
swift
@Test("Item title updates correctly")
func updateTitle() {
    // Arrange
    let item = Item(title: "Old")

    // Act
    item.title = "New"

    // Assert
    #expect(item.title == "New")
}
每个测试都遵循三个部分:
swift
@Test("Item title updates correctly")
func updateTitle() {
    // 准备(Arrange)
    let item = Item(title: "Old")

    // 执行(Act)
    item.title = "New"

    // 断言(Assert)
    #expect(item.title == "New")
}

Test Naming

测试命名

  • Swift Testing: Use the display name string:
    @Test("Descriptive behavior")
  • XCTest: Method name pattern:
    test<Unit>_<Condition>_<ExpectedResult>()
    • Example:
      testUserService_EmptyEmail_ThrowsValidationError()
  • Swift Testing: 使用显示名称字符串:
    @Test("描述性的行为")
  • XCTest: 方法名模式:
    test<单元>_<条件>_<预期结果>()
    • 示例:
      testUserService_EmptyEmail_ThrowsValidationError()

Checklist Before Submitting Tests

提交测试前检查清单

  • Tests follow Arrange/Act/Assert
  • Each test verifies one behavior
  • Test names describe the expected behavior
  • Mocks use protocol-based dependency injection
  • No real network calls or file system access
  • SwiftData tests use
    isStoredInMemoryOnly: true
  • SwiftData test suites use
    .serialized
    or create fresh containers per
    @Test
  • ModelContainer
    is retained for the lifetime of any
    ModelContext
    using it
  • Async tests use
    async throws
    (Swift Testing) or expectations (XCTest)
  • Edge cases covered: empty input, nil values, boundary conditions
  • Tests are isolated — no shared mutable state between tests
  • Critical paths have 80%+ coverage
  • 测试遵循准备/执行/断言结构
  • 每个测试仅验证一种行为
  • 测试名称描述预期行为
  • Mock使用基于协议的依赖注入
  • 无真实网络请求或文件系统访问
  • SwiftData测试使用
    isStoredInMemoryOnly: true
  • SwiftData测试套件使用
    .serialized
    或为每个
    @Test
    创建新容器
  • ModelContainer
    的生命周期覆盖所有使用它的
    ModelContext
  • 异步测试使用
    async throws
    (Swift Testing)或预期(XCTest)
  • 覆盖边界情况:空输入、nil值、临界条件
  • 测试相互隔离 — 测试间无共享可变状态
  • 关键路径覆盖率达80%以上

Templates

模板

Test scaffolding in
templates/
— copy and adapt:
  • ViewModelTestTemplate.swift
    — Swift Testing
    @Suite
    pattern with fresh state per test, parameterized
    @Test
    , async loading
  • MockRepositoryTemplate.swift
    — Protocol-conforming mock with
    @unchecked Sendable
    for actor-safe testing
templates/
目录下提供测试脚手架 — 可复制并适配:
  • ViewModelTestTemplate.swift
    — Swift Testing的
    @Suite
    模式,每个测试使用全新状态,支持参数化
    @Test
    与异步加载
  • MockRepositoryTemplate.swift
    — 符合协议的Mock,带有
    @unchecked Sendable
    以支持actor安全测试