mokacoding

unit and acceptance testing, automation, productivity

Testing Realm apps

In this post we will consider a couple of different approaches to testing an app's data layer using Realm, looking at the design of the components involved, and doing a simple benchmark of the in-memory vs on-disk approach.

The purpose of this post is not to discredit the approach suggested by the documentation, but rather open a discussion around it.

Realms, and pizza

In case you don't know about it, Realm is a database designed for mobile devices. It has open source binding for iOS and OS X, and Android. It's pretty neat, and works well with Swift.

We won't go in the details of Realm here, but you should really have a look at their docs.

Now, let' imagine a very simple all, PizzaApp 🍕. With PizzaApp you can track your favourite pizzas offline, so even when there's no connection you can always browse them.

This is how a pizza model looks like:

import RealmSwift

class Pizza: Object {
  public dynamic var name = ""
  public var ingredients = List<Ingredient>()
}

And this is how we can store a pizza in the database, or in other words adding it to the realm:

func save(pizza: Pizza) {
  let realm = Realm() // <- the default realm
  realm.write {
    self.realm.add(pizza)
  }
}

Writing tests

One simple way to start testing the save code is to assert that after it has been called the count of Pizza objects in the realm is increased by 1.

class PizzaControllerInMemorySpec: QuickSpec {
  override func spec() {
    describe("PizzaController") {
      beforeEach { /* code to setup a test realm */ }

      afterEach { /* code to tear down a test realm */ }

      it("adds the Pizza to the Realm") {
        expect(testRealm.objects(Pizza).count).to(equal(0))

        let p = Pizza()
        p.name = "Margherita"
        sut.addPizza(p)

        expect(testRealm.objects(Pizza).count).to(equal(1))
      }
    }
  }
}

The testing documentation suggests two way to test the code that interacts with a realm.

The first is to change the Realm.defaultPath to one used only for testing. The problem with this approach is that it assumes that we are always going to use the default Realm, while this may not be the case. We might for example spin up a temporary realm, persist changes on that, and only merge on the main realm if the user confirms the changes.

The second approach solves this issue by suggesting to pass a realm instance to every method that needs to interact with it. Our save method would then have to be changed in save(pizza: Pizza, onRealm realm: Realm). That would mean that the consumer of such API would always need to be aware of the realm. We can do better.

A possible alternative is to have a realm manager/controller/service that can be initialized with a realm instance, and only works with it.

import RealmSwift

class PizzaController {
  let realm: Realm!

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

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

  func addPizza(pizza: Pizza) {
    realm.write {
      self.realm.add(pizza)
    }
  }
}

With this we have the best of both worlds. The normal consumer doesn't need to know about Realm, and can use the PizzaController(). Special consumers, like the unit tests or contexts in which a secondary realm need to be put in place, can use PizzaController(realm: Realm).

When looking at the interface of PizzaController() we immediately see it depends on Realm, there are no hidden dependencies, no surprises. This is one of the simplest form of dependency injection.

Let's now look at how to use an in-memory realm to speed up the unit tests.

import Quick
import Nimble
import RealmSwift
import testing_realm

class PizzaControllerInMemorySpec: QuickSpec {
  override func spec() {
    describe("PizzaController") {
      var testRealm: Realm!
      var sut: PizzaController!

      beforeEach{
        testRealm = Realm(inMemoryIdentifier: "pizza-controller-spec")
        sut = PizzaController(realm: testRealm)
      }

      afterEach {
        testRealm.write {
          testRealm.deleteAll()
        }
      }

      it("adds the Pizza to the Realm") {
        expect(testRealm.objects(Pizza).count).to(equal(0))

        let p = Pizza()
        p.name = "Margherita"
        sut.addPizza(p)

        expect(testRealm.objects(Pizza).count).to(equal(1))
      }
    }
  }
}

Note: the test above is far from being comprehensive, the point we're trying to make is on the setup.

Benchmark: on-disk vs in-memory

Now you could argue that there is no big difference between the test above and one using a realm on disk dedicated to testing.

Let's look at a simple benchmark. On MacBook Pro 2.8 GHz Intel Core i7, 16 GB 1600 MHz DDR3, Flash Storage, running the test suite on an iOS Simulator we get these results:

  • 100 accesses in memory ~0.05 seconds vs on disk ~0.08 seconds
  • 1000 accesses in memory ~0.20 seconds vs on disk ~0.41 seconds
  • 10000 accesses in memory ~1.72 seconds vs on disk ~4.66 seconds

Where 1 access = setup the realm, do one write to the realm, tear down the realm.

So as you can see, the difference in the time becomes relevant only when dealing with more than hundreds of accesses to the realm when testing, which is probably not a realistic scenario.

Nevertheless, since every fraction of second matters, we'd recommend to go with the in-memory realm.

Conclusion

We've seen different approach to designing and testing a component responsible of persisting data on a Realm, and how designing it in a way to accept an in-memory Realm not only allows for faster unit tests, but to more flexible use too.

You can find the example code for PizzaApp on GitHub. Please reach out if you find different results while running the benchmark.

I hope you enjoyed this post, if you have any comments, correction or suggestion please use the form below or tweet me @mokagio.

Finally I really want to thanks the Realm team for the amazing software they are building, the example they are setting as an open source by default company, and the work they're doing in the community, hosting events and sharing the videos so that everyone can enjoy them. Thanks!

Happy coding, and leave the codebase better than you found it.

Want more of these posts?

Subscribe to receive new posts in your inbox.