iOS/macOS Testing
This plugin bundles the official Xcode MCP bridge through ./.mcp.json by running xcrun mcpbridge, so Codex-facing test workflows rely on Xcode's own MCP service rather than a third-party wrapper.
Lifecycle Position
Phase 6 (Test). After code is reviewed in Phase 5. Run the smallest relevant test slice first: identify tests with GetTestList, run the narrowest target with RunSomeTests, then broaden to RunAllTests only if the change or failure mode needs it. Use GetBuildLog to inspect failing runs.
Framework Choice
| Framework | When 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 Framework
Basic Test
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
@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
@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
extension Tag {
@Tag static var networking: Self
@Tag static var persistence: Self
}
@Test("API call succeeds", .tags(.networking))
func apiCall() async throws { ... }
XCTest Patterns
Basic XCTest
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
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
@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
@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
@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
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):
@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):
@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.
// 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))
}
Mocking with Protocols
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) }
}
Test Structure — Arrange/Act/Assert
Every test follows three sections:
@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:
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
.serializedor create fresh containers per@Test -
ModelContaineris retained for the lifetime of anyModelContextusing 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
Templates
Test scaffolding in templates/ — copy and adapt:
ViewModelTestTemplate.swift— Swift Testing@Suitepattern with fresh state per test, parameterized@Test, async loadingMockRepositoryTemplate.swift— Protocol-conforming mock with@unchecked Sendablefor actor-safe testing