Testing SwiftData with In-Memory Containers
SwiftData tests have a reputation for being flaky, and it's mostly deserved. Tests pass alone and fail in a suite. State bleeds between test cases. Something works in the simulator and crashes in CI. Every one of those symptoms traces back to the same root cause: the container.
The fix is one line:
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: [config])
That gives every test a fresh, isolated database that never touches disk. This post covers how to build a test setup around that properly, and the concurrency rules that make the difference between a suite that's reliable and one you learn to re-run.
I use this pattern throughout the offline storage post, where the models below come from. Here I want to focus purely on the testing mechanics.
Why Not Just Use the Real Container
The default ModelContainer writes to a SQLite file in Application Support. In tests, that means:
- State persists across runs. Test A inserts a landmark, test B counts landmarks and gets one more than it expected. On the next run the count is higher again.
- Order dependence. Your suite passes in the order Xcode happened to run it and fails when that order changes.
- Parallel execution breaks. Swift Testing runs tests in parallel by default. Multiple tests hitting the same SQLite file is a data race with a filesystem in the middle.
- CI differs from local. A clean CI runner has no database; your machine has one from three weeks ago.
In-memory containers solve all four, because each one is a completely separate store that vanishes when it's deallocated.
The Basic Setup
Start with a helper rather than repeating the boilerplate in every test:
import Testing
import SwiftData
@testable import Landmarks
@MainActor
func makeTestContainer() throws -> ModelContainer {
let schema = Schema([
StoredLandmark.self,
StoredReservation.self
])
let config = ModelConfiguration(
isStoredInMemoryOnly: true,
allowsSave: true
)
return try ModelContainer(for: schema, configurations: [config])
}
Then in a test:
@Test @MainActor
func insertingLandmarkPersistsIt() throws {
let container = try makeTestContainer()
let context = container.mainContext
context.insert(StoredLandmark(remoteID: 1, name: "Half Dome"))
try context.save()
let landmarks = try context.fetch(FetchDescriptor<StoredLandmark>())
#expect(landmarks.count == 1)
#expect(landmarks[0].name == "Half Dome")
}
Each test creates its own container. No shared state, no cleanup needed, no ordering assumptions. When the test ends, the container deallocates and the store is gone.
Pass the full schema, not just one model. This is a common shortcut that bites later:
// Fragile - breaks the moment StoredLandmark gains a relationship
try ModelContainer(for: StoredLandmark.self, configurations: [config])
If StoredLandmark has a relationship to StoredReservation and the container doesn't know about StoredReservation, you get a crash at insert time with a message about an unknown entity. Declaring the whole schema in one helper means you fix it once.
Suites With Shared Setup
Swift Testing suites let you build the container once per test instance. Because Swift Testing creates a new instance of the suite type for every test, a stored property gives you per-test isolation for free:
@MainActor
@Suite("Landmark storage")
struct LandmarkStorageTests {
let container: ModelContainer
let context: ModelContext
init() throws {
self.container = try makeTestContainer()
self.context = container.mainContext
}
@Test func insertsLandmark() throws {
context.insert(StoredLandmark(remoteID: 1, name: "Half Dome"))
try context.save()
#expect(try context.fetchCount(FetchDescriptor<StoredLandmark>()) == 1)
}
@Test func startsEmpty() throws {
// Empty - this is a different container from the test above
#expect(try context.fetchCount(FetchDescriptor<StoredLandmark>()) == 0)
}
}
Those two tests can run in parallel, in either order, and both pass. That's the property you're after. If you've come from XCTest, this replaces setUp/tearDown entirely - there's nothing to tear down.
Seeding Test Data
Fixtures get verbose fast. Give yourself a builder with sensible defaults so each test only states what it cares about:
extension StoredLandmark {
static func fixture(
remoteID: Int = 1,
name: String = "Test Landmark",
state: String = "California",
isFavorite: Bool = false
) -> StoredLandmark {
StoredLandmark(
remoteID: remoteID,
name: name,
state: state,
isFavorite: isFavorite
)
}
}
extension ModelContext {
/// Inserts the given models and saves in one step.
func seed(_ models: [any PersistentModel]) throws {
for model in models { insert(model) }
try save()
}
}
Now tests read like the thing they're testing:
@Test func favoritesPredicateFiltersCorrectly() throws {
try context.seed([
.fixture(remoteID: 1, name: "Half Dome", isFavorite: true),
.fixture(remoteID: 2, name: "Bryce Canyon", isFavorite: false),
.fixture(remoteID: 3, name: "Zion", isFavorite: true)
])
let descriptor = FetchDescriptor<StoredLandmark>(
predicate: #Predicate { $0.isFavorite }
)
#expect(try context.fetchCount(descriptor) == 2)
}
Testing Predicates
#Predicate compiles to something SQLite executes, and the set of Swift expressions it supports is narrower than what compiles. Plenty of predicates build fine and then crash or silently misbehave at fetch time. Tests are how you find that before your users do.
@Test func searchMatchesNameCaseInsensitively() throws {
try context.seed([
.fixture(remoteID: 1, name: "Half Dome"),
.fixture(remoteID: 2, name: "BRYCE CANYON")
])
let term = "bryce"
let descriptor = FetchDescriptor<StoredLandmark>(
predicate: #Predicate { $0.name.localizedStandardContains(term) }
)
let results = try context.fetch(descriptor)
#expect(results.count == 1)
#expect(results[0].remoteID == 2)
}
Capture the comparison value in a local (term) before the predicate - referencing a property directly inside #Predicate often fails to build the underlying query. And localizedStandardContains is the one that gets you case- and diacritic-insensitive matching; contains is case-sensitive and will surprise you.
Sort order deserves the same treatment, since it's easy to get right in the simulator's default locale and wrong elsewhere:
@Test func sortsAlphabeticallyByName() throws {
try context.seed([
.fixture(remoteID: 1, name: "Zion"),
.fixture(remoteID: 2, name: "Arches"),
.fixture(remoteID: 3, name: "Bryce Canyon")
])
let descriptor = FetchDescriptor<StoredLandmark>(
sortBy: [SortDescriptor(\.name)]
)
let names = try context.fetch(descriptor).map(\.name)
#expect(names == ["Arches", "Bryce Canyon", "Zion"])
}
The Concurrency Rules
This is where SwiftData tests go wrong in ways that look like SwiftData being unreliable.
ModelContext is not Sendable, and neither are your models. A PersistentModel is bound to the context that created it. Passing one across an actor boundary is undefined behavior even when the compiler lets it through.
The practical rules:
Use @MainActor on tests that touch mainContext. That's what the suite above does. Without it you'll get concurrency warnings under strict checking, and real races under load.
Never share a ModelContext between tasks. If you need background work, create a separate context from the same container:
@Test @MainActor
func backgroundInsertIsVisibleOnMainContext() async throws {
let container = try makeTestContainer()
// A separate context for the background work
let backgroundContext = ModelContext(container)
backgroundContext.insert(StoredLandmark.fixture(remoteID: 99))
try backgroundContext.save()
// The main context sees it after saving, because they share a store
let count = try container.mainContext.fetchCount(
FetchDescriptor<StoredLandmark>()
)
#expect(count == 1)
}
Pass PersistentIdentifier across boundaries, not models. When you need to hand a reference to another context, send the ID and re-fetch:
let id: PersistentIdentifier = landmark.persistentModelID
// Elsewhere, on another context
if let landmark = otherContext.model(for: id) as? StoredLandmark {
landmark.isFavorite = true
try otherContext.save()
}
PersistentIdentifier is Sendable. The model is not. This one distinction prevents most SwiftData concurrency bugs.
Testing Code That Owns a Container
Your service layer shouldn't create its own container - that makes it untestable. Inject it:
struct LandmarkStore {
let container: ModelContainer
@MainActor
func favorites() throws -> [StoredLandmark] {
try container.mainContext.fetch(
FetchDescriptor<StoredLandmark>(
predicate: #Predicate { $0.isFavorite },
sortBy: [SortDescriptor(\.name)]
)
)
}
}
Production passes the real container, tests pass an in-memory one:
@Test @MainActor
func storeReturnsSortedFavorites() throws {
let container = try makeTestContainer()
try container.mainContext.seed([
.fixture(remoteID: 1, name: "Zion", isFavorite: true),
.fixture(remoteID: 2, name: "Arches", isFavorite: true),
.fixture(remoteID: 3, name: "Bryce Canyon", isFavorite: false)
])
let store = LandmarkStore(container: container)
let favorites = try store.favorites()
#expect(favorites.map(\.name) == ["Arches", "Zion"])
}
Same principle as any dependency injection - the container is a dependency, so it comes in from outside. I go deeper on this pattern in Dependency Injection in SwiftUI.
Testing Migrations
Migrations are the one case where an in-memory container isn't enough on its own, because you need a store that already contains old-schema data. Build it in two steps with a temporary file:
@Test
func migrationFromV1PreservesLandmarkNames() throws {
let url = URL.temporaryDirectory.appending(path: "\(UUID()).store")
defer { try? FileManager.default.removeItem(at: url) }
// Write with the old schema
do {
let oldConfig = ModelConfiguration(url: url)
let oldContainer = try ModelContainer(
for: Schema(versionedSchema: LandmarksSchemaV1.self),
configurations: [oldConfig]
)
let context = ModelContext(oldContainer)
context.insert(LandmarksSchemaV1.StoredLandmark(remoteID: 1, name: "Half Dome"))
try context.save()
}
// Reopen with the migration plan
let newConfig = ModelConfiguration(url: url)
let migrated = try ModelContainer(
for: Schema(versionedSchema: LandmarksSchemaV2.self),
migrationPlan: LandmarksMigrationPlan.self,
configurations: [newConfig]
)
let context = ModelContext(migrated)
let landmarks = try context.fetch(FetchDescriptor<LandmarksSchemaV2.StoredLandmark>())
#expect(landmarks.count == 1)
#expect(landmarks[0].name == "Half Dome")
}
The UUID() in the filename keeps parallel tests from colliding, and the defer cleans up. Note the inner scope with braces - it makes sure the first container is released before the second opens the same file.
The Traps
A condensed list of what actually causes flaky SwiftData tests:
Forgetting try context.save(). Inserts live in memory on the context until saved. A fetch on the same context sees unsaved changes, so a test can pass while the behavior is broken in production. Always save before asserting anything about persistence.
Reusing one container across a suite via static let. That reintroduces shared state and undoes the whole point. Use an init() on the suite instead.
Asserting on fetch order without a SortDescriptor. Unsorted fetch order is not guaranteed. A test that passes today will fail when the row count changes.
Testing @Query directly. @Query is a view property wrapper and needs a SwiftUI environment to work. Test the underlying FetchDescriptor instead - it's the part with the logic in it. That's where your predicate bugs live anyway.
allowsSave: false when you meant read-only. Useful for asserting that a code path doesn't write, but it throws on save rather than silently ignoring it. Don't set it on a container you plan to seed.
Wrapping Up
ModelConfiguration(isStoredInMemoryOnly: true)is the foundation. One container per test.- Declare the full
Schemain a shared helper so relationships don't break later. - Use a suite
init()for per-test containers, notstaticproperties. @MainActoron anything touchingmainContext.- Move
PersistentIdentifieracross concurrency boundaries, never models or contexts. - Inject the container into your stores so production and tests differ by one argument.
- Always
save()before asserting, and always sort before asserting on order.
With those in place, SwiftData tests stop being flaky. The framework isn't the problem - the shared on-disk store is, and one configuration flag removes it.