SwiftData Testing: Unit Tests, In-Memory Stores, and Migration Testing
SwiftData, introduced in iOS 17, is Apple's modern replacement for Core Data. It uses Swift macros and a declarative syntax — but testing it correctly requires some setup. The default @Model persistence store writes to disk, which makes tests slow and stateful. The solution: in-memory ModelContainer for every test.
This guide covers how to set up in-memory SwiftData stores for testing, test queries and relationships, and verify model migrations.
Requirements
- iOS 17.0+, macOS 14.0+
- Xcode 15+
The Core Testing Problem
SwiftData's default configuration persists to SQLite on disk. Test code like this is broken:
// ❌ BAD: Tests share state, order-dependent, slow
func testAddUser() throws {
let container = try ModelContainer(for: User.self) // Real disk storage
let context = ModelContext(container)
let user = User(name: "Alice")
context.insert(user)
try context.save()
let users = try context.fetch(FetchDescriptor<User>())
XCTAssertEqual(users.count, 1)
// Data persists across tests — next test starts with 1 user already in db!
}In-Memory Configuration
Always use isStoredInMemoryOnly: true for tests:
// ✅ GOOD: Isolated, fast, in-memory
func makeTestContainer() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
return try ModelContainer(for: User.self, Task.self, configurations: config)
}Test Setup Patterns
Using XCTest setUp
import XCTest
import SwiftData
@testable import MyApp
class UserModelTests: XCTestCase {
var container: ModelContainer!
var context: ModelContext!
override func setUpWithError() throws {
container = try makeTestContainer()
context = ModelContext(container)
}
override func tearDownWithError() throws {
context = nil
container = nil
}
func testCreateUser() throws {
let user = User(name: "Alice", email: "alice@example.com")
context.insert(user)
try context.save()
let users = try context.fetch(FetchDescriptor<User>())
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users.first?.name, "Alice")
}
}Using Swift Testing @Suite
import Testing
import SwiftData
@testable import MyApp
@Suite struct UserModelTests {
var container: ModelContainer
var context: ModelContext
init() throws {
container = try makeTestContainer()
context = ModelContext(container)
}
@Test func createUser() throws {
let user = User(name: "Alice", email: "alice@example.com")
context.insert(user)
try context.save()
let users = try context.fetch(FetchDescriptor<User>())
#expect(users.count == 1)
#expect(users.first?.name == "Alice")
}
}Defining Your Models for Testing
// Models.swift
import SwiftData
import Foundation
@Model
final class User {
var id: UUID
var name: String
var email: String
var createdAt: Date
@Relationship(deleteRule: .cascade)
var tasks: [Task] = []
init(name: String, email: String) {
self.id = UUID()
self.name = name
self.email = email
self.createdAt = Date()
}
}
@Model
final class Task {
var id: UUID
var title: String
var isCompleted: Bool
var dueDate: Date?
var owner: User?
init(title: String, dueDate: Date? = nil) {
self.id = UUID()
self.title = title
self.isCompleted = false
self.dueDate = dueDate
}
}Testing CRUD Operations
@Suite struct CRUDTests {
var container: ModelContainer
var context: ModelContext
init() throws {
container = try makeTestContainer()
context = ModelContext(container)
}
@Test func insertAndFetch() throws {
let user = User(name: "Alice", email: "alice@example.com")
context.insert(user)
try context.save()
let fetched = try context.fetch(FetchDescriptor<User>())
#expect(fetched.count == 1)
#expect(fetched.first?.name == "Alice")
}
@Test func updateUser() throws {
let user = User(name: "Alice", email: "alice@example.com")
context.insert(user)
try context.save()
user.name = "Alice Smith"
try context.save()
let fetched = try context.fetch(FetchDescriptor<User>())
#expect(fetched.first?.name == "Alice Smith")
}
@Test func deleteUser() throws {
let user = User(name: "Alice", email: "alice@example.com")
context.insert(user)
try context.save()
context.delete(user)
try context.save()
let fetched = try context.fetch(FetchDescriptor<User>())
#expect(fetched.isEmpty)
}
@Test func insertMultipleUsers() throws {
let users = (1...5).map { i in
User(name: "User \(i)", email: "user\(i)@example.com")
}
users.forEach { context.insert($0) }
try context.save()
let fetched = try context.fetch(FetchDescriptor<User>())
#expect(fetched.count == 5)
}
}Testing Predicates and Filtering
@Test func fetchWithPredicate() throws {
context.insert(User(name: "Alice", email: "alice@example.com"))
context.insert(User(name: "Bob", email: "bob@example.com"))
context.insert(User(name: "Carol", email: "carol@example.com"))
try context.save()
let descriptor = FetchDescriptor<User>(
predicate: #Predicate { user in
user.name.contains("A")
}
)
let filtered = try context.fetch(descriptor)
#expect(filtered.count == 1)
#expect(filtered.first?.name == "Alice")
}
@Test func fetchWithSortDescriptor() throws {
context.insert(User(name: "Charlie", email: "c@example.com"))
context.insert(User(name: "Alice", email: "a@example.com"))
context.insert(User(name: "Bob", email: "b@example.com"))
try context.save()
let descriptor = FetchDescriptor<User>(
sortBy: [SortDescriptor(\.name, order: .forward)]
)
let sorted = try context.fetch(descriptor)
#expect(sorted.map(\.name) == ["Alice", "Bob", "Charlie"])
}
@Test func fetchWithLimit() throws {
for i in 1...10 {
context.insert(User(name: "User \(i)", email: "u\(i)@example.com"))
}
try context.save()
var descriptor = FetchDescriptor<User>()
descriptor.fetchLimit = 3
let limited = try context.fetch(descriptor)
#expect(limited.count == 3)
}Testing Relationships
@Test func cascadeDeleteRemovesTasks() throws {
let user = User(name: "Alice", email: "alice@example.com")
context.insert(user)
let task1 = Task(title: "Buy groceries")
let task2 = Task(title: "Write tests")
task1.owner = user
task2.owner = user
user.tasks = [task1, task2]
context.insert(task1)
context.insert(task2)
try context.save()
// Verify tasks exist
let tasksBefore = try context.fetch(FetchDescriptor<Task>())
#expect(tasksBefore.count == 2)
// Delete user — cascade should delete tasks
context.delete(user)
try context.save()
let tasksAfter = try context.fetch(FetchDescriptor<Task>())
#expect(tasksAfter.isEmpty)
}
@Test func relationshipFetchedCorrectly() throws {
let user = User(name: "Alice", email: "alice@example.com")
let task = Task(title: "Write tests")
task.owner = user
user.tasks = [task]
context.insert(user)
context.insert(task)
try context.save()
let fetchedUser = try context.fetch(FetchDescriptor<User>()).first!
#expect(fetchedUser.tasks.count == 1)
#expect(fetchedUser.tasks.first?.title == "Write tests")
}Testing Count Queries
@Test func countActiveUsers() throws {
context.insert(User(name: "Alice", email: "alice@example.com"))
context.insert(User(name: "Bob", email: "bob@example.com"))
try context.save()
let count = try context.fetchCount(FetchDescriptor<User>())
#expect(count == 2)
// Count with predicate
let descriptor = FetchDescriptor<User>(
predicate: #Predicate { $0.name == "Alice" }
)
let aliceCount = try context.fetchCount(descriptor)
#expect(aliceCount == 1)
}Testing Model Migrations
SwiftData uses VersionedSchema and SchemaMigrationPlan for migrations. Test them:
// Schemas/V1.swift
enum UserSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [UserV1.self] }
@Model final class UserV1 {
var name: String
init(name: String) { self.name = name }
}
}
// Schemas/V2.swift — added email field
enum UserSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [UserV2.self] }
@Model final class UserV2 {
var name: String
var email: String // New field
init(name: String, email: String = "") {
self.name = name
self.email = email
}
}
}// Test the migration
class MigrationTests: XCTestCase {
func testV1toV2MigrationPreservesNames() throws {
// Create V1 container and insert data
let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
let v1Container = try ModelContainer(
for: UserSchemaV1.UserV1.self,
configurations: v1Config
)
let v1Context = ModelContext(v1Container)
v1Context.insert(UserSchemaV1.UserV1(name: "Alice"))
try v1Context.save()
// Migrate to V2
let migrationPlan = UserMigrationPlan.self // Your migration plan
let v2Config = ModelConfiguration(isStoredInMemoryOnly: true)
let v2Container = try ModelContainer(
for: UserSchemaV2.UserV2.self,
migrationPlan: migrationPlan,
configurations: v2Config
)
let v2Context = ModelContext(v2Container)
let users = try v2Context.fetch(FetchDescriptor<UserSchemaV2.UserV2>())
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users.first?.name, "Alice")
XCTAssertEqual(users.first?.email, "") // Default value applied
}
}Testing SwiftUI Views with SwiftData
For SwiftUI views that use @Query, inject a test container via the environment:
// In tests
func testUserListView() throws {
let container = try makeTestContainer()
let context = ModelContext(container)
context.insert(User(name: "Alice", email: "alice@example.com"))
try context.save()
let view = UserListView()
.modelContainer(container)
// Use ViewInspector or run UI tests to verify
}Conclusion
SwiftData testing requires one key setup: always use isStoredInMemoryOnly: true in your ModelConfiguration. Once you have an in-memory container, testing CRUD operations, predicates, relationships, and migrations follows standard patterns. The new Swift Testing framework's @Suite with init() setup makes it especially clean — each test gets a fresh, isolated in-memory database automatically.