ios-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseiOS/macOS Testing
iOS/macOS 测试
Lifecycle Position
生命周期阶段
Phase 6 (Test). After code is reviewed in Phase 5. Run tests via or XcodeBuildMCP /.
/testtest_macostest_sim阶段6(测试)。在阶段5完成代码评审后,通过或XcodeBuildMCP的/命令运行测试。
/testtest_macostest_simFramework Choice
框架选择
| Framework | When to Use |
|---|---|
Swift Testing ( | New test code. Preferred for modern projects. |
XCTest ( | Legacy tests, UI tests, performance tests. |
Both frameworks can coexist in the same target.
| 框架 | 使用场景 |
|---|---|
Swift Testing ( | 新测试代码。现代项目的首选方案。 |
XCTest ( | 遗留测试、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 tests in parallel by default. This causes crashes with SwiftData:
@SuiteThe Problem: Multiple tests creating separate in-memory instances simultaneously leads to Core Data coordinator conflicts → .
ModelContainerFatal error: This model instance was destroyed by calling ModelContext.resetTwo 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 ):
.serializedswift
@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: must outlive any or 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.
ModelContainerModelContext@Modelswift
// 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默认会并行运行测试,这会导致SwiftData出现崩溃:
@Suite问题: 多个测试同时创建独立的内存实例会引发Core Data协调器冲突 → 。
ModelContainerFatal 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 — 每个测试创建新容器(无需也安全):
.serializedswift
@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仅属于当前测试 — 无跨测试干扰
// ...
}关键规则: 的生命周期必须长于任何从它派生的或对象。如果辅助函数创建了容器但仅返回上下文或模型对象,容器会在辅助函数返回时被ARC释放 — 所有模型实例都会失效。
ModelContainerModelContext@Modelswift
// 错误示例 — 辅助函数返回时容器被销毁
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()
- Example:
- 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 or create fresh containers per
.serialized@Test - is retained for the lifetime of any
ModelContainerusing itModelContext - Async tests use (Swift Testing) or expectations (XCTest)
async throws - 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 - 的生命周期覆盖所有使用它的
ModelContainerModelContext - 异步测试使用(Swift Testing)或预期(XCTest)
async throws - 覆盖边界情况:空输入、nil值、临界条件
- 测试相互隔离 — 测试间无共享可变状态
- 关键路径覆盖率达80%以上
Templates
模板
Test scaffolding in — copy and adapt:
templates/- — Swift Testing
ViewModelTestTemplate.swiftpattern with fresh state per test, parameterized@Suite, async loading@Test - — Protocol-conforming mock with
MockRepositoryTemplate.swiftfor actor-safe testing@unchecked Sendable
templates/- — Swift Testing的
ViewModelTestTemplate.swift模式,每个测试使用全新状态,支持参数化@Suite与异步加载@Test - — 符合协议的Mock,带有
MockRepositoryTemplate.swift以支持actor安全测试@unchecked Sendable