9.
Testing the Persistence Layer
Written by Tori Gonda
In most apps you’ll build, you will store data in one way or another. It might be in shared preferences, in a database, or otherwise. No matter which way you’re saving it, you need to be confident it is always working. If a user takes the time to put together content and then loses it because your persistence code broke, both you and your user will have a sad day.
You have the tools for mocking out a persistence layer interface from Chapter 7, “Introduction to Mockito.” In this chapter you will take it a step further, testing that when you interact with a database, it behaves the way you expect.
In this chapter you will learn:
- How to use TDD to have a well-tested RoomDB database.
- Why persistence testing can be difficult.
- Which parts of your persistence layer should you test.
Note: It is helpful, but not necessary to have a basic understanding of RoomDB. To brush up on the basics, check out our tutorial, “Data Persistence with Room”: https://www.raywenderlich.com/69-data-persistence-with-room.
Getting started
To learn about testing the persistence layer you will write tests while building up a RoomDB database for the Wishlist app. This app provides a place where you can keep track of the wishlists and gift ideas for all your friends and loved ones.
To get started, find the starter project included for this chapter and open it up in Android Studio, or continue with the project from Chapter 8, “Integration.”
Build and run the app. You’ll see a blank screen with a button to add a list on the bottom. Clicking the button, you see a field to add a name for someone’s wishlist. You can add a name, but it will be gone next time you open the app! You will implement the persistence layer to save the wishlist in this chapter.
When there are wishlists saved and displayed, you can click on them to show the detail of the items for that list, and add items. By the end of this chapter, this is what the app will look like:
Time to get familiar with the code.
Exploring the project
There are a couple of files you should be familiar with before getting started. Open these files and take a look around:
-
WishlistDao.kt: Is the database access object. You will work on defining the database interactions in this class. This is also the class you will write your tests for. Notice that right now the Dao interactions are stubbed out in
WishlistDaoImpl. - RepositoryImpl.kt: This class should be familiar to you from Chapter 8, “Integration.” It’s the repository that hooks your app up with the database.
- KoinModules.kt: This handles the dependency injection for the app, specifying how to create any dependencies.
-
StringListConverter.kt: This is a helper object to convert lists of
Strings to a singleStringand back again to store in the database.
Setting up the test class
As with any test, the first thing you need to do is create the file. Create WishlistDaoTest.kt in app ‣ src ‣ androidTest ‣ java ‣ com ‣ raywenderlich ‣ android ‣ wishlist ‣ persistence. In it, create an empty class with the @RunWith annotation. The import you want for AndroidJUnit4 is androidx.test.ext.junit.runners.AndroidJUnit4:
@RunWith(AndroidJUnit4::class)
class WishlistDaoTest {
}
In this context, the test runner facilitates loading your test package and the app under test onto a device or emulator, running your tests, and reporting the results. You might recall from the Robolectric discussion in the previous chapter that it can also delegate to Robolectric.
Note: You can use Robolectric for this test, too, if you follow the instructions to set it up from the previous chapter.
Continuing your set up, add the following test rule to your test class:
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
Android Architecture Components uses an asynchronous background executor to do its work. InstantTaskExecutorRule is a rule that swaps out that executor and replaces it with a synchronous one. This will make sure that, when you’re using LiveData, it’s all run synchronously in the tests.
You also need to create the properties to hold your WishlistDatabase and WishlistDao. Add these properties to your test class now:
private lateinit var wishlistDatabase: WishlistDatabase
private lateinit var wishlistDao: WishlistDao
The WishlistDao is what you are performing your tests on. To create an instance of this class, you’ll need an instance of the WishlistDatabase first. You will initialize these in a @Before block in a moment.
Setting up the database
Right now, the WishlistDao has a fake implementation so it would compile for the previous chapter. Because you want to use a real DAO with a real database in this chapter, you need to set that up. Take a moment to make the following changes. Note that the app will not compile until all the following steps are complete.
Start by opening WishlistDatabase.kt and add the following abstract method:
abstract fun wishlistDao(): WishlistDao
This tells the Database to look for and build the WishlistDao.
Then, open WishlistDao.kt and annotate the interface with @Dao to round out the connections. Leave the fake implementation for now so the app can compile. You’ll swap it out for your real one at the end of this chapter.
Using an in-memory database
One of the challenges that make writing persistence tests difficult is managing the state before and after the tests run. You’re testing saving and retrieving data, but you don’t want to end your test run with a bunch of test data on your device or emulator. How can you save data while your tests are running, but ensure that test data is gone when the tests finish? You could consider erasing the whole database, but if you have your own non-test data saved in the database outside of the tests, that would delete too.
Tests must be repeatable. This means you should be able to run a test multiple times with the same result. There’s also a requirement that one test cannot influence the outcome of another. What if you’re testing for an empty database but there are items left over from another test? You will need to clear data between tests.
You can solve this problem by using an in-memory database. RoomDB luckily provides a way to easily create one. Add this to your test class:
@Before
fun initDb() {
// 1
wishlistDatabase = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
WishlistDatabase::class.java).build()
// 2
wishlistDao = wishlistDatabase.wishlistDao()
}
- Here you’re using a RoomDB builder to create an in-memory
WishlistDatabase. Compare this to the database creation you’ll add to KoinModules.kt at the end of this chapter. Information stored in an in-memory database disappears when the tests finish, solving your state issue. - You then use this database to get your
WishlistDao.
Note: If you’re using Robolectric, add
.allowMainThreadQueries()before your.build().
Almost done setting up! After your tests finish, you also need to close your database. Add this to your test class:
@After
fun closeDb() {
wishlistDatabase.close()
}
Now you’re ready to start writing tests.
Writing a test
Test number one is going to test that when there’s nothing saved, getAll() returns an empty list. This is a function for fetching all of the wishlists from the database. Add the following test, using the imports androidx.lifecycle.Observer for Observer, and org.mockito.kotlin.* for mock() and verify():
@Test
fun getAllReturnsEmptyList() {
val testObserver: Observer<List<Wishlist>> = mock()
wishlistDao.getAll().observeForever(testObserver)
verify(testObserver).onChanged(emptyList())
}
This tests the result of a LiveData response similar to how you wrote your tests in Chapter 7, “Introduction to Mockito.” You create a mock Observer, observe the LiveData returned from getAll() with it, and verify the result is an empty list.
Everything looks pretty good, so try to run the test.
Oh no! There’s a compiler error.
Hmmm. The next minimum thing to make this test run is to add a @Query annotation. Add an empty query annotation to getAll():
@Query("")
Try running it with this change. If you are familiar with RoomDB, you may know what’s coming.
The compiler enforces that you include a query in the parameter. That means the next step is to fill in the query as simply as possible. Fill in your query with the following:
@Query("SELECT * FROM wishlist")
Finally, RoomDB requires all the abstract methods in the DAO class to have annotations, so go ahead and add the following incorrect annotations to the other two methods.
@Query("SELECT * FROM wishlist WHERE id != :id")
fun findById(id: Int): LiveData<Wishlist>
@Delete
fun save(vararg wishlist: Wishlist)
Run your test and it finally compiles! But… it’s passing. When practicing TDD you always want to see your tests fail first. You never saw a state where the test was compiling and failing. You were so careful to only add the smallest bits until it compiled. RoomDB made it hard to write something that didn’t work. Maybe the real question is “Should you be testing this?” The answer to this question is important.
Knowing not to test the library
The fact that RoomDB made it hard to write a failing test is a clue. When your tests align closely with a library or framework, you want to be sure you’re testing your code and not the third-party code. If you really want to write tests for that library, you might be able to contribute to the library, if it’s an open-source project. ;]
Sometimes this is a gray line to try to find, and it’s one of the things that makes testing persistence difficult. It’s a part of your code that likely relies heavily on a framework.
It’s a balance to test that your interactions with the framework or library are correct without testing the library itself. Watch out for cases like these and use your best judgment. Over time you’ll gain intuition on which tests are valuable, and what tests are better left to the library’s contributors.
In this case, it’s not up to you to test the RoomDB framework. It’s those writing RoomDB’s responsibility to make sure that when the database is empty, it returns nothing. Instead, you want to test that your logic, your queries, and the code that depends on them are working correctly.
With that in mind, you can move on to test other database interactions.
Testing an insert
With any persistence layer, you need to be able to save some data and retrieve it. That’s exactly what your next test will do. Add this test to your class:
@Test
fun saveWishlistsSavesData() {
// 1
val wishlist1 = Wishlist("Victoria", listOf(), 1)
val wishlist2 = Wishlist("Tyler", listOf(), 2)
wishlistDao.save(wishlist1, wishlist2)
// 2
val testObserver: Observer<List<Wishlist>> = mock()
wishlistDao.getAll().observeForever(testObserver)
// 3
val listClass =
ArrayList::class.java as Class<ArrayList<Wishlist>>
val argumentCaptor = ArgumentCaptor.forClass(listClass)
// 4
verify(testObserver).onChanged(argumentCaptor.capture())
// 5
assertTrue(argumentCaptor.value.size > 0)
}
Here you:
- Create a couple of wishlists and save them to the database. At this point
save()does not exist yet, so there will be an error. - Use your mock
testObserveragain to callgetAll(). - Create an
ArgumentCaptorto capture the value inonChanged(). Using anArgumentCaptorfrom Mockito allows you to make more complex assertions on a value thanequals(). - Use
verifymethod to capture the argument passed to theonChanged()method. - Test that the result from the database is a non-empty list. At this point you care that data was saved and not what was saved, so you’re checking the list size only.
Great! Remember, this is currently the save() function you have in WishlistDao:
@Delete
fun save(vararg wishlist: Wishlist)
You need to have a database interaction annotation for this to compile, as you learned earlier in this chapter. You also want to see this test failing, so you’re using the wrong one, @Delete. Run your test and see it fail.
You know the drill, time to make this test green!
Making your test pass
This one is simple enough to make it pass. Just change the @Delete annotation with an @Insert. Your save() signature should now look like this:
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun save(vararg wishlist: Wishlist)
Using OnConflictStrategy.REPLACE allows the database to override an entry that already exists.
Run your tests, and they should all be green.
Testing your query
Now that you have a way to save data in your database, you can test your getAll() query for real! Add this test:
@Test
fun getAllRetrievesData() {
val wishlist1 = Wishlist("Victoria", emptyList(), 1)
val wishlist2 = Wishlist("Tyler", emptyList(), 2)
wishlistDao.save(wishlist1, wishlist2)
val testObserver: Observer<List<Wishlist>> = mock()
wishlistDao.getAll().observeForever(testObserver)
val listClass =
ArrayList::class.java as Class<ArrayList<Wishlist>>
val argumentCaptor = ArgumentCaptor.forClass(listClass)
verify(testObserver).onChanged(argumentCaptor.capture())
val capturedArgument = argumentCaptor.value
assertTrue(capturedArgument
.containsAll(listOf(wishlist1, wishlist2)))
}
This is almost the same as your previous test except for the final line. In that line, you’re testing that the list result contains the exact wishlists you expect.
Build and run your tests. It may come as a surprise, but they failed! Why is that? Insert a debugger breakpoint on the assertion line and inspect the capturedArgument at that point when you run it again, using the debugger. Huh! Somehow there is a list with an empty string in it.
Good investigating! You found the bug before it reached production. Time to solve the problem.
Fixing the bug
How could this happen? StringListConverter holds the key. Take a look at the object. In stringToStringList(), when there is an empty String saved in the database, as is the case for an empty list, the split function used returns a list with an empty string in it! Now that you know the problem, you can solve it.
Replace the body of stringToStringList() with:
if (!string.isNullOrBlank()) string.split("|").toMutableList()
else mutableListOf()
Now run those tests again and see them pass!
Note: All over while testing you’ll be performing verifications that rely heavily on the
equals()method to perform comparisons.containsAll()is one of them. Much of the time in these cases you are looking for data equality (the properties of both objects are the same) rather than object equality (they reference the same object in memory). Because of this, you want to make sure yourequals()performs the way you expect. In Kotlin, this is often as simple as making your class adata class. This overridesequals()andhashcode()for you to compare the properties. Take caution for the times where this isn’t enough! For example, Kotlin may not compareLists the way you expect. For that reasonequals()andhashcode()are overridden forWishlistin this app. You can see this in Wishlist.kt.
Testing a new query
Moving on. In your database you also need the ability to retrieve an item by id. To create this functionality, start by adding a test for it:
@Test
fun findByIdRetrievesCorrectData() {
// 1
val wishlist1 = Wishlist("Victoria", emptyList(), 1)
val wishlist2 = Wishlist("Tyler", emptyList(), 2)
wishlistDao.save(wishlist1, wishlist2)
// 2
val testObserver: Observer<Wishlist> = mock()
wishlistDao.findById(wishlist2.id).observeForever(testObserver)
verify(testObserver).onChanged(wishlist2)
}
Here, you:
- Create and save some wishlists, same as your other tests.
- Query for a specific wishlist,
wishlist2, and verify the result is correct.
As a reminder, this is what you have in WishlistDao:
@Query("SELECT * FROM wishlist WHERE id != :id")
fun findById(id: Int): LiveData<Wishlist>
Notice it’s intentionally incorrect. It’s searching for a wishlist where the id is not the given id. This is again to make sure you see a failing test.
Run that test and verify it really does fail.
Making the test pass
It’s the last time you’ll do it in this chapter: make that test green! All you need to do is remove that not (!) from the query. It should now look like this:
@Query("SELECT * FROM wishlist WHERE id = :id")
Ready? Run your tests to see them all pass.
Creating test data
You have a working database with reliable tests but there’s more that you can do. There is something you can do to also help save setup time as you write other tests. This tool is called test data creation.
If you look at the tests you’ve written in this chapter, as well as Chapter 8, “Integration,” you see many lines where you’re manually creating a Wishlist. Not only is this tedious, but you’re only testing that your code works for that specific Wishlist.
One way to abstract this work and make your data random is by using a Factory. A Factory object will create instances of your data class with random values for the properties. This will make your tests stronger and easier to write!
Start by creating a WishlistFactory object in your test directory. As this is specific to your persistence right now, a good place to put it is app ‣ src ‣ androidTest ‣ java ‣ com ‣ raywenderlich ‣ android ‣ wishlist ‣ persistence ‣ WishlistFactory.kt:
object WishlistFactory {
}
If you use this in other places as well, you can move this Factory to a more convenient location. Since you’re only using it in this one test right now, this location works great.
You need a way to create random values for your data class properties. A simple way to do this is to create helper methods to create them, one for each type of property you need. Again, you could place these in a reusable location, but because you’re only using them here right now, they can share the WishlistFactory.
In your Wishlist you need two types of data: String and Int. Add these methods to your WishlistFactory:
// 1
private fun makeRandomString() = UUID.randomUUID().toString()
// 2
private fun makeRandomInt() =
ThreadLocalRandom.current().nextInt(0, 1000 + 1)
These are simple, built-in ways to create random values. You can use similar ways to create helpers for Long, Boolean, etc.
Now, to finish the Factory, add a method to create a Wishlist:
fun makeWishlist(): Wishlist {
return Wishlist(
makeRandomString(),
listOf(makeRandomString(), makeRandomString()),
makeRandomInt())
}
You use the random value methods you just created to set the properties, knowing you will likely have a completely different Wishlist every time you create one. They won’t look anything like what’s on your wishlist, but they will be unique. Well, the Wishlist won’t have what you want unless you want a UUID for your birthday.
Using a Factory in your test
You now have an easy way to create test data, so why not use it? Refactor your tests so that each time you create a Wishlist, you use the factory instead. It should look like this in each of your tests:
val wishlist1 = WishlistFactory.makeWishlist()
val wishlist2 = WishlistFactory.makeWishlist()
So clean! Run your tests to make sure they still pass.
Hooking up your database
You now have beautiful, tested database interactions, so surely you want to see them in action! Before you run the app, open up KoinModules.kt and change single<WishlistDao> { WishlistDaoImpl() } to:
single {
Room.databaseBuilder(
get(),
WishlistDatabase::class.java, "wishlist-database"
)
.allowMainThreadQueries()
.build().wishlistDao()
}
All this is doing is hooking up dependency injection to use your real database rather than your fake DAO. You’re ready to build and run the app!
Play around with your fully functioning app! Create some Wishlists and add some items to them. You’ll always know the perfect gift to give now.
See the results:
Optional: Updating your integration test
The integration test, DetailViewModelTest, that you wrote in the last chapter is still using the fake DAO implementation. Wouldn’t it be great to use the real one and delete the fake one?
Open DetailViewModelTest.kt and replace the initialization of the WishlistDao with the following:
private val wishlistDao: WishlistDao = Mockito.spy(
Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
WishlistDatabase::class.java)
.allowMainThreadQueries()
.build().wishlistDao())
This swaps out the fake with the real one! No more imposters here. Go ahead and delete WishlistDaoImpl.
Note: In the above block,
allowMainThreadQueries()is only required if you’re using Robolectric.
Using Dexmaker
One final thing. The WishlistDao implementation that RoomDB provides is a final class, which means you can’t spy on it using Mockito. While in Chapter 7, “Introduction to Mockito” you used the mock-maker-inline extension, you cannot use that in Android tests.
Note: If you’re using Robolectric, add
testImplementation "org.mockito:mockito-inline:3.2.0"as a dependency instead of the below Dexmaker.
You’ll use Dexmaker (https://github.com/linkedin/dexmaker) to accomplish this.
There are other options for mocking final classes in your Android tests you can look into if you prefer:
∙ MockK: https://mockk.io/
∙ DexOpener: https://github.com/tmurakami/dexopener
Start by adding the following dependency to app ‣ build.gradle:
androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito-inline:2.28.1'
Then, because they include duplicate resources, delete these dependencies:
androidTestImplementation 'org.mockito:mockito-android:3.10.0'
You’re all set! Now, run DetailViewModelTest to see your nice green tests.
Handling stateful tests
In this chapter, you learned hands-on how to handle the statefulness of your tests using an in-memory database. You need this set up and tear down to write reliable, repeatable persistence tests, but how do you handle it when you’re using something other than RoomDB for your persistence?
Unfortunately, many libraries don’t have these convenient, built-in testing helpers. Some do, such as Realm, but often you’re left in the dust. In these cases, you’re usually left to clear the persisted data before each test. When doing this, make sure your testing device doesn’t have any data you want to keep for that app!
Key points
- Persistence tests help keep your user’s data safe.
- Statefulness can make persistence tests difficult to write.
- You can use an in-memory database to help handle stateful tests.
- You need to include both set up (
@Before) and tear down (@After) with persistence tests. - Be careful to test your code and not the library or framework you’re using.
- Sometimes you need to write ”broken” code first to ensure that your tests fail.
- You can use Factories to create test data for reliable, repeatable tests.
- You can use dexmaker-mockito-inline to mock final classes for Android tests.
- If the persistence library you’re using doesn’t have built-in strategies for testing, you may need to delete all persisted data before and after each test.
Where to go from here?
You now know how to get started testing your persistence layer in your app. Keep these strategies in mind whenever you’re implementing this layer.
As with all of programming, there are often many ways to do the same thing. For another example of how to test RoomDB, take a look at ”Room DB: Advanced Data Persistence” https://www.raywenderlich.com/5686-room-db-advanced-data-persistence. You may even start to think about how you can use the strategy in this tutorial for the tests you wrote in Chapter 8, “Integration.” ;]
For an example of how to test an SQLite database, take a look at “Testing persistence in the Android ecosystem” https://blog.novoda.com/testing-persistence-in-the-android-ecosystem/.
Moving on to an equally important layer, in the next chapter, Chapter 10, “Testing the Network Layer,” you’ll learn strategies for testing code that relies on API network calls.