The SwiftData test that crashed with no error message
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
I added a small persistence layer to my music app this week - a store that records which album releases you’ve marked “Not Interested” so they stop showing up. Nothing exotic: a SwiftData model, a struct that wraps a ModelContext, four methods (dismiss, undo, clear, read). I wrote five unit tests. All five crashed.
Not failed. Crashed. The output was about as helpful as it gets:
✗ DismissalStoreTests / dismissPersistsKey(): Crash
✗ DismissalStoreTests / dismissIsIdempotent(): Crash
✗ DismissalStoreTests / undoRemovesKey(): Crash
...
No message. No stack trace worth reading. Just “Crash” next to every test in the suite.
The part that made it confusing
Here’s what threw me: I had a different test, in a different file, that used the exact same store against the exact same in-memory SwiftData container. And it passed. Green. So the store worked. The model worked. The in-memory container worked. But the moment I tested the store on its own, everything blew up.
When the same code passes in one place and crashes in another, the difference isn’t the code - it’s the context around it. So I lined the two up.
The passing test built its container inline and held onto it:
let container = try ModelContainer(for: DismissedRelease.self,
configurations: config)
let store = SwiftDataDismissalStore(context: container.mainContext)
// ... container stays in scope for the whole test
The crashing tests used a helper to cut the boilerplate:
private func makeStore() throws -> SwiftDataDismissalStore {
let container = try ModelContainer(for: DismissedRelease.self,
configurations: config)
return SwiftDataDismissalStore(context: container.mainContext)
}
Spot it? The helper returns the store and throws the container away. As soon as makeStore() returns, nothing holds a reference to container, so it deallocates. The store is still alive - it’s holding container.mainContext - but the context is now pointing at a backing store that no longer exists. The first fetch or save walks off a cliff.
The thing I’d internalized wrong
I’d been thinking of ModelContext as the thing that owns the data. It isn’t. The container owns the store; the context is just a workspace that talks to it. And a context does not keep its container alive. Hand a service container.mainContext and then drop the container, and you’ve built a use-after-free in slow motion.
The reason it never showed up in the actual app is that there the container lives forever - it’s attached to the SwiftUI scene with .modelContainer(...) and stays alive as long as the app is running. You only see this when a container is a local variable that falls out of scope, which is exactly what a tidy little test helper encourages.
The fix
Keep the container alive for as long as anything uses its context. In Swift Testing, the clean way is a stored property on the test, set up in init (which runs fresh per test):
@MainActor
struct DismissalStoreTests {
let container: ModelContainer // retained for the test's lifetime
let store: SwiftDataDismissalStore
init() throws {
container = try ModelContainer(for: DismissedRelease.self,
configurations: config)
store = SwiftDataDismissalStore(context: container.mainContext)
}
}
Five crashes became five passes, no other change.
The broader rule I took away: whatever owns a context-wrapping service has to own, or outlive, the container too. And if a service is meant to outlive whoever created it, don’t hand it a bare context - hand it the container and let it make its own context. A context is a borrowed thing. Treat it like one.
A small Swift 6 footnote
While wiring this up I hit a second, unrelated papercut. I tried to give a test helper a default store:
func loadedVM(store: any DismissalStoring = InMemoryDismissalStore()) async -> ...
and got “call to main actor-isolated initializer in a synchronous nonisolated context.” My store’s init is @MainActor, and default argument values are evaluated in a nonisolated context, so you can’t call a main-actor initializer there. Default the parameter to nil and build the real thing inside the function body, where the isolation actually applies. Minor, but it’s the kind of thing that reads like a deep concurrency problem and is really just “move this one expression three lines down.”
Related reading
When a filter set quietly becomes a feature
The Not Interested store only ever kept keys. Then it had to become a screen, and the model needed to carry its own description.
Racing the main actor: when await is the race condition
@MainActor eliminates data races, not interleaving races. A generation token, a stale-failure guard, and a deterministic race test with no sleeps.
My mocks couldn't fail: error injection and a Swift 6 isolation crash
Every catch branch was dead code in tests. A 15-line failure seam fixed that, and then one unannotated test suite took down the whole runner.