Realm iOS Testing: In-Memory Databases and Mocking Realm Objects in Swift

Realm iOS Testing: In-Memory Databases and Mocking Realm Objects in Swift

Realm's object graph persistence is tightly coupled to its runtime — you can't just pass in a mock dictionary. Testing Realm-backed iOS code requires either an in-memory Realm configuration (fast, isolated, no file cleanup) or a purpose-built mock layer. This guide covers both approaches, plus migration testing, async Realm patterns, and structuring your code so Realm is replaceable in tests.

The Problem with Testing Realm Code

Realm stores data in a binary .realm file. Tests that hit the real file on disk cause several issues:

  • Tests share state if the file isn't cleaned between runs
  • Parallel test execution on the same file causes conflicts
  • You can't inspect write transaction state without a live Realm instance
  • Tests become slow and order-dependent

The solution is in-memory Realm configurations — Realm instances that behave identically to disk-backed ones, except data lives in memory and disappears when all references are released.

Setup: In-Memory Realm

import RealmSwift

extension Realm.Configuration {
    static func makeTestConfiguration(identifier: String = UUID().uuidString) -> Realm.Configuration {
        Realm.Configuration(
            inMemoryIdentifier: identifier,
            schemaVersion: 1 // match your production schema version
        )
    }
}

Use this in test cases:

import XCTest
import RealmSwift

final class TaskRepositoryTests: XCTestCase {
    var realm: Realm!
    var repository: TaskRepository!

    override func setUp() {
        super.setUp()
        realm = try! Realm(configuration: .makeTestConfiguration())
        repository = TaskRepository(realm: realm)
    }

    override func tearDown() {
        try! realm.write { realm.deleteAll() }
        realm = nil
        repository = nil
        super.tearDown()
    }
}

Using UUID().uuidString as the identifier means each test class gets a fresh in-memory database. You can also use a fixed identifier if you want the database to persist across test methods within a class.

Defining Realm Models for Testing

class Task: Object {
    @Persisted(primaryKey: true) var id: String = UUID().uuidString
    @Persisted var title: String = ""
    @Persisted var isCompleted: Bool = false
    @Persisted var createdAt: Date = Date()
}

Testing CRUD Operations

final class TaskRepositoryTests: XCTestCase {
    // ... setUp/tearDown above

    func testCreateTask() throws {
        let task = Task()
        task.title = "Write unit tests"

        try repository.save(task)

        let saved = realm.object(ofType: Task.self, forPrimaryKey: task.id)
        XCTAssertNotNil(saved)
        XCTAssertEqual(saved?.title, "Write unit tests")
        XCTAssertFalse(saved?.isCompleted ?? true)
    }

    func testUpdateTask() throws {
        let task = Task()
        task.title = "Initial title"
        try repository.save(task)

        try repository.update(taskId: task.id, title: "Updated title")

        let updated = realm.object(ofType: Task.self, forPrimaryKey: task.id)
        XCTAssertEqual(updated?.title, "Updated title")
    }

    func testDeleteTask() throws {
        let task = Task()
        try repository.save(task)
        XCTAssertNotNil(realm.object(ofType: Task.self, forPrimaryKey: task.id))

        try repository.delete(taskId: task.id)

        XCTAssertNil(realm.object(ofType: Task.self, forPrimaryKey: task.id))
    }

    func testFetchAllReturnsAllTasks() throws {
        let titles = ["Task A", "Task B", "Task C"]
        for title in titles {
            let task = Task()
            task.title = title
            try repository.save(task)
        }

        let all = repository.fetchAll()
        XCTAssertEqual(all.count, 3)
    }

    func testFetchCompletedOnlyReturnsCompleted() throws {
        let completed = Task(); completed.title = "Done"; completed.isCompleted = true
        let pending = Task(); pending.title = "Pending"; pending.isCompleted = false
        try repository.save(completed)
        try repository.save(pending)

        let results = repository.fetchCompleted()
        XCTAssertEqual(results.count, 1)
        XCTAssertEqual(results.first?.title, "Done")
    }
}

The Repository Pattern: Making Realm Testable

The key to testable Realm code is injecting the Realm instance rather than accessing it as a singleton:

class TaskRepository {
    private let realm: Realm

    init(realm: Realm) {
        self.realm = realm
    }

    func save(_ task: Task) throws {
        try realm.write {
            realm.add(task, update: .modified)
        }
    }

    func fetchAll() -> Results<Task> {
        realm.objects(Task.self)
    }

    func fetchCompleted() -> Results<Task> {
        realm.objects(Task.self).where { $0.isCompleted == true }
    }

    func update(taskId: String, title: String) throws {
        guard let task = realm.object(ofType: Task.self, forPrimaryKey: taskId) else { return }
        try realm.write {
            task.title = title
        }
    }

    func delete(taskId: String) throws {
        guard let task = realm.object(ofType: Task.self, forPrimaryKey: taskId) else { return }
        try realm.write {
            realm.delete(task)
        }
    }
}

For production, pass try! Realm(). For tests, pass try! Realm(configuration: .makeTestConfiguration()).

Mocking Realm with a Protocol

When you can't use in-memory Realm (e.g., legacy code with singleton access), define a protocol:

protocol RealmProtocol {
    func objects<Element: Object>(_ type: Element.Type) -> Results<Element>
    func add(_ object: Object, update: Realm.UpdatePolicy)
    func delete(_ object: Object)
    func write(_ block: () throws -> Void) throws
}

extension Realm: RealmProtocol {}

Then create a mock:

class MockRealm: RealmProtocol {
    var savedObjects: [Object] = []
    var deletedObjects: [Object] = []

    func objects<Element: Object>(_ type: Element.Type) -> Results<Element> {
        // Results<T> is hard to mock directly — use in-memory Realm instead
        fatalError("Use in-memory Realm for query testing")
    }

    func add(_ object: Object, update: Realm.UpdatePolicy) {
        savedObjects.append(object)
    }

    func delete(_ object: Object) {
        deletedObjects.append(object)
    }

    func write(_ block: () throws -> Void) throws {
        try block()
    }
}

Note: Results<T> is a live Realm query — it's not constructible outside Realm, so this pattern is best for testing write operations. For read testing, in-memory Realm is superior.

Testing Realm Queries

For complex query logic, test directly against in-memory Realm:

func testSortedByCreationDate() throws {
    let old = Task(); old.title = "Old"; old.createdAt = Date(timeIntervalSince1970: 1000)
    let new = Task(); new.title = "New"; new.createdAt = Date()
    try repository.save(old)
    try repository.save(new)

    let sorted = realm.objects(Task.self).sorted(byKeyPath: "createdAt", ascending: false)
    XCTAssertEqual(sorted.first?.title, "New")
}

func testFilterByTitle() throws {
    let task = Task(); task.title = "Buy groceries"
    try repository.save(task)

    let results = realm.objects(Task.self).where { $0.title.contains("grocery") }
    XCTAssertEqual(results.count, 0)

    let caseless = realm.objects(Task.self).filter("title CONTAINS[c] %@", "groceries")
    XCTAssertEqual(caseless.count, 1)
}

Testing Migrations

Test migration blocks by configuring an older schema version against an in-memory Realm:

func testMigrationAddsDefaultPriority() throws {
    // Simulate schema version 0 data
    let oldConfig = Realm.Configuration(
        inMemoryIdentifier: "migration-test",
        schemaVersion: 0
    )

    // Create a Realm without the migration (old schema)
    // In real migration tests, you'd use a pre-seeded .realm file

    let newConfig = Realm.Configuration(
        inMemoryIdentifier: "migration-test",
        schemaVersion: 1,
        migrationBlock: { migration, oldSchemaVersion in
            if oldSchemaVersion < 1 {
                migration.enumerateObjects(ofType: Task.className()) { _, newObject in
                    newObject?["priority"] = "normal" // default value for new field
                }
            }
        }
    )

    let realm = try Realm(configuration: newConfig)
    XCTAssertNotNil(realm)
    // Migration ran without throwing — success
}

For production migration testing, keep versioned .realm fixture files in your test bundle and apply migrations against them.

Testing Async Realm (Swift Concurrency)

RealmSwift supports async/await with actor-isolated access:

@MainActor
func testAsyncFetch() async throws {
    let config = Realm.Configuration.makeTestConfiguration()
    let realm = try await Realm(configuration: config)

    try await realm.asyncWrite {
        let task = Task()
        task.title = "Async task"
        realm.add(task)
    }

    let tasks = realm.objects(Task.self)
    XCTAssertEqual(tasks.count, 1)
    XCTAssertEqual(tasks.first?.title, "Async task")
}

Mark the test method async throws and use await Realm(configuration:) for actor-safe initialization.

Testing Realm Notifications

Realm objects support change notifications. Test them with expectations:

func testTaskChangeNotification() throws {
    let task = Task()
    task.title = "Original"
    try realm.write { realm.add(task) }

    let expectation = XCTestExpectation(description: "Change notification received")
    var token: NotificationToken?

    token = task.observe { change in
        if case .change(_, let properties) = change {
            let titleChange = properties.first { $0.name == "title" }
            XCTAssertNotNil(titleChange)
            XCTAssertEqual(titleChange?.newValue as? String, "Updated")
            expectation.fulfill()
        }
    }

    try realm.write { task.title = "Updated" }
    wait(for: [expectation], timeout: 1.0)
    token?.invalidate()
}

Summary

Scenario Approach
CRUD operations In-memory Realm with injected configuration
Write-only behavior MockRealm conforming to RealmProtocol
Complex queries In-memory Realm with seeded data
Migration correctness Fixture .realm files + migration block
Async/actor-isolated Realm await Realm(configuration:) in async test
Notifications observe() with XCTestExpectation

The in-memory Realm pattern covers 90% of cases. Keep Realm injection in your initializers, avoid singletons, and your persistence layer becomes straightforward to test.

Read more