SwiftData Testing: Unit Tests, In-Memory Stores, and Migration Testing

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.

Read more

Start now free