5.
Data Layer — Caching
Written by Ricardo Costeira
In this chapter, you’ll complete the data layer you started in the previous chapter by adding caching capabilities to it. At the end of the chapter, you’ll pull everything together by assembling the repository.
In the process, you’ll learn:
- How to cache data.
- Another way of mapping data to the domain layer.
- More about dependency management.
- How to bundle everything in the repository.
- How to test the repository.
You’ll do this by working on the PetSave app.
Cache Data Models
The models are in the materials for this chapter, but they still need some work. You’ll use Room to create the caching system. Since Room is an abstraction over SQLite, you have to establish the relationships between database entities. SQLite is a relational database engine, after all.
Start by expanding common.data.cache.model. Inside, you’ll find two other packages, one for each domain entity. This kind of structure didn’t exist in the network code because you had to adapt to whatever the server sends. With caching, you have full control over the data.
Look at this entity relationship diagram:
Using database jargon:
- One organization has many animals.
- One animal has many photos.
- One animal has many videos.
- Many animals have many tags.
The organization to animals relationship is there for completeness only; you won’t implement it.
If you’re wondering about the last relationship, it’s easy to understand. Tags are attributes of the animal. They include descriptions like “cute”, “playful” and “intelligent”. So, an animal can have many tags, and a tag can be associated with many animals. Hopefully, more than one animal has the “cute” tag. :]
Expand the cachedanimal package. In it, you’ll find a few different files that correspond to most of the tables in the diagram above.
Now, open CachedPhoto.kt. The @Entity
annotation specifies the table name, while @PrimaryKey
defines the primary key — which is photoId
, in this case. Having autoGenerate = true
as a @PrimaryKey
parameter tells Room that it might need to generate a new ID for new entries. It checks photoId
’s value to determine if it needs to do so. If the value is zero, Room treats it as not set, creates a new ID and inserts it with the new entry. That’s why photoId
has the default value of zero.
Adding Foreign Keys
Before creating relationships, you need to add the foreign keys that allow you to establish them. Complete the @Entity
above the class with:
@Entity(
tableName = "photos",
foreignKeys = [
ForeignKey(
entity = CachedAnimalWithDetails::class, // 1
parentColumns = ["animalId"], // 2
childColumns = ["animalId"], // 3
onDelete = ForeignKey.CASCADE // 4
)
],
indices = [Index("animalId")] // 5
)
This annotation defines a lot of stuff for you. It:
- Specifies the entity that the foreign key belongs to:
CachedAnimalWithDetails
, in this case. Although you haveAnimal
andAnimalWithDetails
as domain entities, there’s noCachedAnimal
in the database. Having two sources of truth for the same thing goes against database normalization, so you should avoid it. - Defines the column that matches the foreign key in the parent table,
CachedAnimalWithDetails
. - Defines the column in this table where you find the foreign key.
- Instructs Room to delete the entity if the parent entity gets deleted.
- Sets the foreign key as an indexed column.
Setting columns as indices is a way for Room to locate data more quickly. Here, you set animalId
as an index because it’s a foreign key. If you don’t set a foreign key as an index, changing the parent table might trigger an unneeded full table scan on the child table, which slows your app down. Fortunately, Room throws a compile-time warning if you don’t index the key.
Having indices speeds up SELECT
queries. On the other hand, it slows down INSERT
s and UPDATE
s. This is nothing to worry about with PetSave, as the app will mostly read from the database.
Setting Up Relationships With Room
Starting on version 2.4, Room gives you two possible approaches for establishing relationships between entities:
- Using intermediate data classes (already available before 2.4).
- Using multimap return types.
Intermediate data classes allow you to be explicit about the relationships between entities through annotations at the expense of, well, having more classes to maintain. What you gain in return is that you avoid having to write complex SQL queries by making your query methods return these intermediate classes. Multimap return types allows your query methods to simply return a mapping of your entities instead of having to create an intermediate data class type for it. The catch here is that the SQL associated with said method will do most of the work.
You can go with whichever option you prefer. Google recommends multimap… so we’re going with data classes, not only due to (way) simpler SQL, but also to follow the screaming architecture workflow. It’s a lot easier to spot what kind of entities your database can return when you’ve named your classes properly. Besides, are you even an Android developer if you don’t ignore half of Google’s recommendations? :]
Setting Up Your One-to-Many Relationships
The @Entity
annotations in CachedVideo
and CachedAnimalWithDetails
already adhere to the diagram in Figure 5.1, which means that everything’s ready to set up your one-to-many relationships. To model these with Room, you have to:
- Create one class for the parent and another for the child entity. You already have these.
- Create a data class representing the relationship.
- Have an instance of the parent entity in this data class, annotated with
@Embedded
. - Use the
@Relation
annotation to define the list of child entity instances.
The data class that models the relationship already exists. It’s called CachedAnimalAggregate
. You’ll use this class later to map cached data into domain data since it holds all the relevant information.
Open CachedAnimalAggregate.kt and annotate photos
and videos
in the constructor like this:
data class CachedAnimalAggregate(
@Embedded // 1
val animal: CachedAnimalWithDetails,
@Relation( // 2
parentColumn = "animalId",
entityColumn = "animalId"
)
val photos: List<CachedPhoto>,
@Relation(
parentColumn = "animalId",
entityColumn = "animalId"
)
val videos: List<CachedVideo>,
// ...
) {
// ...
}
In this code you used:
-
@Embedded
for theanimal
property of typeCachedAnimalWithDetails
. -
@Relation
for thephotos
andvideos
properties of typesList<CachedPhoto>
andList<CachedVideo>
, respectively. By specifying the keys of both the parent (CachedAnimalAggregate through the embedded animal property) and the children (photos and videos), Room can use this annotation to handle the relationships for you.
You’ve now prepared all your one-to-many relationships. But before proceeding, let’s talk about multimaps for a second.
So, multimaps allow Room to return a mapping of entities, rather than a custom class that defines the relationship between said entities, at the expense of a more complex SQL query. For instance, imagine that you want all animals that have photos, along with the corresponding photos. You’d have something like:
@Query(
"SELECT * FROM animals " +
"JOIN photos ON animals.animalId = photos.animalId"
)
abstract fun loadUserAndBookNames(): Map<CachedAnimalWithDetails, List<CachedPhoto>>
That’s all fine and dandy, but now try to get the same result that CachedAnimalAggregate
gives you — an animal with (or without) photos, videos and tags — through a multimap. Go ahead, I’ll wait. Not that easy, huh?
Multimaps work fine for the simpler cases, but if you find yourself in despair over a multimap with an extremely complex SQL statement, just go with an extra class. Be kind to yourself!
With that out of the way, on to the next relationship you need to handle.
Setting Up the Many-to-Many Relationship
Now, you’ll handle the many-to-many relationship, which you need to model a little differently from the one-to-many case. Here, you need to create a:
- Class for each entity (already done).
- Third class to cross-reference the two entities by their primary keys.
- Class that models the way you want to query the entities. So, either an animal class with a list of tags, or a tag class with a list of animals.
In this case, you want an animal class with a list of tags. You already have that with CachedAnimalAggregate
, so add the following above tags
in the constructor, like this:
data class CachedAnimalAggregate(
// ...
@Relation( // 1
parentColumn = "animalId", // 2
entityColumn = "tag", // 2
associateBy = Junction(CachedAnimalTagCrossRef::class) // 3
)
val tags: List<CachedTag>
) {
// ...
}
In this code, you:
- Define a many-to-many relation with
@Relation
. - Define the parent and child entities, although they’re not exactly parent and child. Again, this just defines the way you want to query the entities.
- Use
associateBy
to create the many-to-many relationship with Room. You set it to aJunction
that takes the cross-reference class as a parameter. As you can see from the entity relationship diagram, the cross-reference class isCachedAnimalTagCrossRef
.
You now need to define the cross reference through the CachedAnimalTagCrossRef
class.
Implementing the Cross-Reference Table
All that’s missing now is to deal with the cross-reference table. Open CachedAnimalTagCrossRef.kt. You need to annotate the class as a Room entity:
@Entity(
primaryKeys = ["animalId", "tag"], // 1
indices = [Index("tag")] // 2
)
data class CachedAnimalTagCrossRef(
val animalId: Long,
val tag: String
)
Two things to note here:
- You’re defining a composite primary key with
animalId
andtag
. So, the primary key of this table is always a combination of the two columns. - While primary keys are indexed by default, you’re explicitly indexing
tag
, andtag
only. You need to index both, because you use both to resolve the relationship. Otherwise, Room will complain.
What’s happening here?
It has to do with how SQLite works. A query can use a composite index or a subset of that index, as long as the subset matches the original index from the beginning. So in this case, since the index is (animalId, tag)
, both (animalId)
and (animalId, tag)
are valid. If you change the primary key above to ["tag", "animalId"]
, then you’d have to index animalId
instead of tag
:
@Entity(
primaryKeys = ["tag", "animalId"], // HERE
indices = [Index("animalId")]
)
data class CachedAnimalTagCrossRef(
val animalId: Long,
val tag: String
)
Anyway, you’re adding a new Room entity, so you need to inform Room about it. Open PetSaveDatabase.kt in the cache package. Add CachedAnimalTagCrossRef
to the list of entities already there:
@Database(
entities = [
CachedVideo::class,
CachedTag::class,
CachedAnimalWithDetails::class,
CachedOrganization::class,
CachedAnimalTagCrossRef::class // HERE
],
version = 1
)
abstract class PetSaveDatabase : RoomDatabase()
Rebuild your project and run the app. Everything works as expected!
This concludes the work on the models. However, you still need to map them to the domain.
Another Way of Data Mapping
When you worked with the API, you created specialized classes for data mapping. Remember reading about using static or member functions to achieve the same goal? Well, open CachedPhoto.kt and look at the companion object:
@Entity(tableName = "photos")
data class CachedPhoto(
// ...
) {
companion object {
fun fromDomain( // HERE
animalId: Long,
photo: Media.Photo
): CachedPhoto {
val (medium, full) = photo
return CachedPhoto(
animalId = animalId,
medium = medium,
full = full
)
}
}
// ...
}
In this code, fromDomain
returns a CachedPhoto
instance, which it builds from a domain Photo
and the corresponding animalId
. It has to be a companion object function due to dependencies. To make it a class member function, you’d have to add it to Photo
, which would make the domain aware of the data layer.
You could also achieve the same result with an extension function, as long as it extends CachedPhoto
. In the end, both options boil down to static functions. The extension function does have the advantage of extending CachedPhoto
’s behavior without changing the class. However, people like to keep the extension in the same file for convenience.
Picking one or the other in this simple case is mostly a matter of preference. Choosing the companion object means keeping the mapping behavior close to the class. It’s a simple class, and its job is to be a DTO, so there’s no particular reason to hide the mapping.
Anyway, look below the companion and you’ll find CachedPhoto
’s only function:
@Entity(tableName = "photos")
data class CachedPhoto(
// ...
) {
// ...
fun toDomain(): Media.Photo = Media.Photo(medium, full) // HERE
}
This does the reverse of the other function, creating a domain model out of the cache DTO. Simple. :]
Cache models have toDomain
and fromDomain
functions, while API models only have toDomain
. That’s because you won’t send anything to the API, so there’s no need to translate domain models into API DTOs.
OK, this wraps up model mapping. Time to get yourself some of those sweet SQL statements that everyone loves! The DAOs (data access objects) are waiting.
Caching Data With Room
Think back to when you implemented the API interface. You added the API method to meet the data needs for the Animals near you feature. Now, it’s just a matter of accessing the data. :]
Room uses DAOs to manage data access. Typically, you’ll want one DAO per domain entity since they’re the objects whose identity matters. You already have OrganizationsDao
but you need to add one for animals.
Create a new file with name AnimalsDao.kt in common.data.cache.daos and add the following code:
@Dao // 1
abstract class AnimalsDao { // 2
@Transaction // 3
@Query("SELECT * FROM animals") // 4
abstract fun getAllAnimals(): Flowable<List<CachedAnimalAggregate>> // 5
}
In this code, you:
- Use
@Dao
to tell Room that this abstraction will define the operations you want to use to access the data in its database. - Create
AnimalsDao
as an abstract class because you’ll need to add some concrete methods. Room also supports interfaces in case you just need operations that you can specify solely through annotations. - Use
@Transaction
to tell Room to run the specific operation in a single transaction. Room uses a buffer for table row data,CursorWindow
. If a query result is too large, this buffer can overflow, resulting in corrupted data. Using@Transaction
avoids this. It also ensures you get consistent results when you query different tables for a single result. - Define the SQL query to retrieve all the animals with
@Query
. - Declare
getAllAnimals()
as the function to invoke to fetch all the animals and their corresponding photos, videos and tags. This operation returns RxJava’sFlowable
. This creates a stream that will infinitely emit new updates. That way, the UI always has live access to the latest cached data. It returns a list ofCachedAnimalAggregate
, which is the class with all the information you need to produce a domainAnimalWithDetails
.
When you build the app, Room will create all the code you need to fetch all the animals from the database. Of course, you also need a way to insert the data.
Inserting New Animals
You’ve now gotten all the animals. To insert new ones, add the following code in AnimalsDao.kt:
@Dao
abstract class AnimalsDao {
// ...
@Insert(onConflict = OnConflictStrategy.REPLACE) // 1
abstract suspend fun insertAnimalAggregate( // 2
// 3
animal: CachedAnimalWithDetails,
photos: List<CachedPhoto>,
videos: List<CachedVideo>,
tags: List<CachedTag>
)
}
In this code:
-
You annotate the method declaration with
@Insert
. This tells Room that it’s a database insertion. SettingonConflict
toOnConflictStrategy.REPLACE
makes Room replace any rows that match the new ones. There’s no@Transaction
annotation because Room already runs inserts within a transaction. -
By default, Room won’t let you perform I/O operations on the UI thread. If you did, you’d most likely block the thread, which means freezing your app! A great way to avoid this is by using
suspend
functions. Since an insert is a one-shot operation, you don’t need anything fancy like reactive streams. Plus, using thesuspend
modifier here makes Room run the insert on a background thread. -
You can’t insert
CachedAnimalAggregate
because it’s not a Room entity. However, you can decompose it into its@Entity
-annotated components and pass them into this method. Since they’re all Room entities, Room will know how to insert them.
The previous operation allows you to insert a single entity. In practice you usually need to insert many of them. In the next paragraph you’ll see how.
Mapping the API Results to the Cache
After parsing the results from the API, you need to map them to the cache. Since you’ll get a list of animals from the API, you’ll end up with a list of CachedAnimalAggregate
after the mapping. The last method will handle the decomposing mentioned above.
In the same AnimalsDao.kt file, add the following code:
@Dao
abstract class AnimalsDao {
// ...
suspend fun insertAnimalsWithDetails(animalAggregates: List<CachedAnimalAggregate>) {
for (animalAggregate in animalAggregates) {
insertAnimalAggregate(
animalAggregate.animal,
animalAggregate.photos,
animalAggregate.videos,
animalAggregate.tags
)
}
}
}
Here, the method goes through the list and calls insertAnimalAggregate
for each one. This is why you’re using an abstract class. Although interfaces can also have method implementations, they can be extended. This way, since the method doesn’t have the open
modifier, it’s clear that it should not be extended in any way. It’s just a matter of better conveying the code’s intent.
Each iteration of this method’s for
loop will trigger the Flowable
from getAllAnimals
. Worst case, this can cause some backpressure in the stream. This isn’t a problem — Room’s backpressure strategy keeps only the latest event, which is what you want in the end. Still, it’s something important to be aware of.
You don’t need to declare any more methods for now. Open the PetSaveDatabase.kt file in the common.data.cache package and add the following code:
@Database(
// ...
)
abstract class PetSaveDatabase : RoomDatabase() {
// ...
abstract fun animalsDao(): AnimalsDao // HERE
}
By adding animalsDao()
, you tell Room that AnimalsDao
is also available so it’ll provide a way to access it.
Build the app. Go back to AnimalsDao
now and you’ll see that the IDE displays a little green icon on the left, meaning that something implemented the abstract methods.
Room automagically did all the hard work for you!
Updating the Cache to Handle the Data
You now need to update the cache implementation to handle animal data. Open Cache.kt under common.data.cache. This is the interface that exposes the caching methods for the repository to use. In terms of abstraction level, it’s on par with PetFinderApi
. The difference is that you have to implement it manually because Room only creates the lower-level stuff for you.
Cache
already has code for organizations. The code for animals is the DAO equivalent of the methods you just created.
Add the following code:
interface Cache {
// ...
fun getNearbyAnimals(): Flowable<List<CachedAnimalAggregate>>
suspend fun storeNearbyAnimals(animals: List<CachedAnimalAggregate>)
}
Now, open RoomCache.kt in the common.data.cache package that contains the class that implements Cache
. Notice how the name is the interface’s name prefixed with the mechanism you’ll use to handle caching. Always try to name interface implementations based on their purpose and/or functionality. Suffixing interface implementations with Impl
doesn’t give you any information about them.
Next, update its code, like this:
class RoomCache @Inject constructor(
private val animalsDao: AnimalsDao, // 1
private val organizationsDao: OrganizationsDao
) : Cache {
// ...
override fun getNearbyAnimals(): Flowable<List<CachedAnimalAggregate>> { // 2
return animalsDao.getAllAnimals()
}
override suspend fun storeNearbyAnimals(animals: List<CachedAnimalAggregate>) { // 3
animalsDao.insertAnimalsWithDetails(animals)
}
}
In this code, you:
- Add the primary constructor parameter of type
AnimalsDao
. - Implement
getNearbyAnimals()
, which delegates the operation toanimalsDao
by invokinggetAllAnimals()
on it. - Do the same for
storeNearbyAnimals()
, delegating again toanimalsDao
, but this time invokinginsertAnimalsWithDetails
.
These simply wrap the DAO calls with more domain-friendly names.
And… you’re done! Build and run to ensure everything still works.
You just injected an AnimalsDao
instance. However, you can’t annotate abstract classes with @Inject
. Even if you could, you’d still want Room’s implementation of it, and not Hilt’s. That said, you don’t have a way to provide an instance yet. But not for long!
Managing Cache Dependencies With Hilt
Open CacheModule.kt in common.data.di. It’s already a Dagger module, but it’s missing some provider methods:
PetSaveDatabase
AnimalsDao
Cache
You’ll work on the methods in that order. Update the code like this:
@Module
@InstallIn(SingletonComponent::class)
abstract class CacheModule {
companion object {
@Provides
@Singleton // 1
fun provideDatabase(
@ApplicationContext context: Context // 2
): PetSaveDatabase {
return Room.databaseBuilder( // 3
context,
PetSaveDatabase::class.java,
"petsave.db"
)
.build()
}
@Provides
fun provideAnimalsDao(
petSaveDatabase: PetSaveDatabase
): AnimalsDao = petSaveDatabase.animalsDao() // 4
// ...
}
}
A few different things are happening here:
- Due to all the SQLite setup, creating a Room database is expensive. Ideally, you create a reference and reuse it, instead. For that reason, you annotate the method with
@Singleton
. - This
@ApplicationContext
is another one of Hilt’s useful features. You don’t need to use Dagger’s@BindsInstance
to provide aContext
anymore. Instead, you annotate aContext
with@ApplicationContext
and Hilt automatically injects it for you. You could also use@ActivityContext
, but here you want the context for the application because you want the database to have the lifetime of the app itself. - You return a Room database instance specifying
PetSaveDatabase
, which is the class type that extendsRoomDatabase
. You then give the database a name which, for consistency, is the same name the app uses. - You inject the
PetSaveDatabase
parameter you provide in the previous method, then you use it to callanimalsDao()
. This returns Room’s own implementation of the class.
The bindings for the DAOs and the database are in the dependency graph. You need now to add the Cache
.
Adding the Cache
You’ll handle Cache
a little differently. You want to provide the Cache
interface type, but you also have to provide the class that implements it, RoomCache
. This is where Dagger’s @Binds
comes in. In the same CacheModule.kt in common.data.di, add the following code outside the companion object:
@Module
@InstallIn(SingletonComponent::class)
abstract class CacheModule {
@Binds
abstract fun bindCache(cache: RoomCache): Cache // HERE
companion object {
// ...
}
}
This allows you to provide the return type, but under the hood, along with whatever you pass in as a parameter. The parameter has to be assignable to the return type.
This is why CacheModule
is an abstract class instead of an object
like ApiModule
. You can only apply the @Binds
annotation to abstract methods, which an object
can’t have. Regardless, by having the @Provides
-annotated bindings inside the companion, you get the same benefits as if you were using an object
module.
That’s it for cache dependency injection. Following the same pattern you’ve used so far, the next step would usually be to test the code. In this case, however, you’ll skip that and leave it as a challenge you can do to get some extra practice. Now, you’ll move on to assembling the repository.
Putting It All Together
At this point, you already have a sense of the data your app needs. You now have to update the repository interface in the domain layer accordingly.
It’s funny that the data layer actually helps you to figure out the domain layer — but, hey, that’s Android for you. It might seem that the domain layer has an implicit dependency on the data layer. This also makes it clearer why some people just prefer to skip the domain layer altogether. In all fairness, you need the data for the features that the domain defines. It can become tricky to understand what depends on what. :]
Anyway, climbing back out of the “Android doesn’t need a domain layer” rabbit hole, go to domain.repositories and open AnimalRepository.kt. Unless I’ve done a horrible job up to now, you should already expect the repository to provide ways to:
- Get cached data.
- Store cached data.
- Get remote data.
Turning that into code, change AnimalRepository
, like this:
interface AnimalRepository {
fun getAnimals(): Flowable<List<Animal>> // 1
suspend fun requestMoreAnimals(pageToLoad: Int, numberOfItems: Int): PaginatedAnimals // 2
suspend fun storeAnimals(animals: List<AnimalWithDetails>) // 3
}
These should be self-explanatory. The declaration:
- Returns the
Flowable
that emits when the database updates. - Calls the API to get more animals, passing in the page number and how many animals you want. Like the API call, it’s a
suspend
function. - Stores a list of animals in the database.
Now, the implementation. In common.data, create a new file called PetFinderAnimalRepository.kt. In it, create PetFinderAnimalRepository
, like this:
class PetFinderAnimalRepository @Inject constructor( // 1
private val api: PetFinderApi, // 2
private val cache: Cache,
private val apiAnimalMapper: ApiAnimalMapper,
private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository
Here, you:
- Annotate the constructor with
@Inject
, both to injectPetFinderAnimalRepository
when needed and to inject other dependencies into it. - Add the dependencies you need to fulfill the interface contract. Since it implements a domain interface, this class defines a boundary between layers. That’s why you need those two mappers.
Since you haven’t implemented the interface yet, there’s a red squiggly line under the class’s name. To fix that, place the cursor on the class name and press Option-Enter on MacOS or Alt-Enter on Windows.
Choose the Implement members option, then select all three in the dialog that opens.
This creates the stubs for the missing classes. They contain some TODOs, but you’ll come back to work on them soon.
Returning the Flowable
Going one by one, delete the TODO
comments in getAnimals
and add the following in their place:
class PetFinderAnimalRepository @Inject constructor(
private val api: PetFinderApi,
private val cache: Cache,
private val apiAnimalMapper: ApiAnimalMapper,
private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
override fun getAnimals(): Flowable<List<Animal>> {
return cache.getNearbyAnimals() // 1
.distinctUntilChanged() // 2
.map { animalList -> // 3
animalList.map {
it.animal.toAnimalDomain(
it.photos,
it.videos,
it.tags
)
}
}
}
// ...
}
This code:
- Calls the corresponding cache method, which returns a
Flowable
. - Calls
distinctUntilChanged
on the stream. This is important because it ensures only events with new information get to the subscriber. For instance, since the insertion abstract method has theREPLACE
conflict strategy, the same items can get inserted. In general, it’s a good practice to use this operator with Room because Room knows when a table is modified, but doesn’t know what was modified. That means, if you’re observing only one item, you’ll get false updates when any table involved in the corresponding SQLite query changes. - Maps the
CachedAnimalAggregate
list to theAnimal
list by calling thetoAnimalDomain
mapper for eachCachedAnimalAggregate
instance.
The previous operation returns all the Animal
s in the repository. You need now to implement the other operations.
Calling the API for More Animals
Next, you’ll modify requestMoreAnimals
. Replace the TODO
with the following code:
class PetFinderAnimalRepository @Inject constructor(
private val api: PetFinderApi,
private val cache: Cache,
private val apiAnimalMapper: ApiAnimalMapper,
private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
// ...
override suspend fun requestMoreAnimals(pageToLoad: Int, numberOfItems: Int): PaginatedAnimals {
val (apiAnimals, apiPagination) = api.getNearbyAnimals( // 1
pageToLoad,
numberOfItems,
postcode,
maxDistanceMiles
)
return PaginatedAnimals( // 2
apiAnimals?.map {
apiAnimalMapper.mapToDomain(it)
}.orEmpty(),
apiPaginationMapper.mapToDomain(apiPagination)
)
}
private val postcode = "07097" // 3
private val maxDistanceMiles = 100 // 3
// ...
}
Here, you:
- Call the corresponding API method and destructure the resulting
ApiPaginatedAnimals
instance. - Build a
PaginatedAnimals
instance with the destructured components, using the mappers in the process. -
postcode
andmaxDistanceMiles
don’t exist yet. You’ll get these later, by using another feature. Right now, you just use temporary values so you can add them as properties.
You’re still missing one final operation.
Storing the Animal List in the Database
Finally, you’ll handle storeAnimals
. Add the following code:
class PetFinderAnimalRepository @Inject constructor(
private val api: PetFinderApi,
private val cache: Cache,
private val apiAnimalMapper: ApiAnimalMapper,
private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
// ...
override suspend fun storeAnimals(animals: List<AnimalWithDetails>) {
val organizations = animals.map { CachedOrganization.fromDomain(it.details.organization) } // 1
cache.storeOrganizations(organizations)
cache.storeNearbyAnimals(animals.map { CachedAnimalAggregate.fromDomain(it) }) // 2
}
}
Here’s what’s going on in this code:
- You map each
Organization
to aCachedOrganization
, creating a list. Don’t forget that organizations have a one-to-many relationship with animals, so you have to insert them before inserting animals. Otherwise, Room will complain about not being able to satisfy the foreign key’s constraint inCachedAnimalWithDetails
. - After inserting all the organizations, you insert the animals, mapping them to the appropriate type.
That’s it! Build and run, and everything should go smoothly. Well, really, it has to. You implemented this data layer code, but so far, no other code is using it — and won’t until the next chapter. If only you could assert the correctness of your code somehow… You know where this is going. :]
Testing Your Repository
The repository is a great opportunity to venture into integration tests. In fact, unit testing wouldn’t add anything new to what you’ve seen so far, so you’ll only do integration tests. This means that instead of using fake or mock dependencies, you’ll use the real thing! Most of it, at least. :]
Start by creating the test file for PetFinderAnimalRepository
with Android Studio’s aid, just as you did for the interceptor. Only this time, instead of choosing the test directory, choose androidTest. You’ll see why later.
You’ll start by testing the integration with the network code. This involves:
- The API itself
- The interceptors
- Preferences
You need to build an instance of the API, using an instance of OkHttpClient that has the interceptors. It, in turn, needs a fake/mock Preferences
. Plus, you also need a Cache
instance to create the repository, along with mapper instances.
Ugh. No wonder so many people just skip testing completely! The thing is, those people probably aren’t aware of the power a DI framework leverages to make testing a breeze.
Integration Tests With Hilt
Hilt has some built-in features specifically for testing. It requires some configuration in the beginning, but that work pays off later. Your steps are to:
- Implement a custom
AndroidJUnitRunner
implementation for testing with Hilt. - Configure the custom runner for Instrumentation tests.
- Set up an instrumented test for
PetFinderAnimalRepository
. - Prepare the dependency graph for the instrumentation test.
- Implement
@Before
with code that all the tests have in common. - Write your tests.
It’s time to have some more fun. :]
Implementing HiltTestRunner
You’ll run instrumented tests here, so the first thing to do is tell your test runner to run them with an Application
object that supports Hilt. For this, Hilt provides its own instance, HiltTestApplication
.
In the petsave package of androidTest, create a file called HiltTestRunner.kt. In it, add:
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
Here, you create a test runner that forces the tests to run in HiltTestApplication
. Now, you need to set your test configuration to use this test runner.
Configuring the HiltTestRunner
You need to tell Gradle to use HiltTestRunner
when running an instrumentation test. Open build.gradle in the app module and apply the following changes:
// ...
android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
// ...
testInstrumentationRunner "com.realworld.android.petsave.HiltTestRunner" // 1
// ...
}
// ...
sourceSets { // 2
androidTest {
assets.srcDirs = ["src/debug/assets"]
}
}
}
// ...
In this definition, you:
- Tell Gradle to use
HiltTestRunner
as theAndroidJUnitRunner
for instrumentation tests. - Configure Gradle to reach the assets in the debug package, like you did for the unit tests earlier. You’ll use MockWebServer for these tests, so you need this code to access the mocked API responses.
Sync Gradle to make sure it accepts the settings. Now, it has all it needs to run the instrumentation test with Hilt. It’s time to write the test for PetFinderAnimalRepository
.
Preparing Your Instrumentation Test
Now, for the fun stuff! Back in PetFinderAnimalRepositoryTest.kt, add:
@HiltAndroidTest // 1
@UninstallModules(PreferencesModule::class) // 2
class PetFinderAnimalRepositoryTest
This code:
- Marks the test class for injection. This way, Hilt will know it has to inject some dependencies here.
- Tells Hilt to not load the original
PreferencesModule
, so you can replace it with a test module.
You need to add a few properties to the class now. Inside the same PetFinderAnimalRepositoryTest.kt, add:
@HiltAndroidTest
@UninstallModules(PreferencesModule::class)
class PetFinderAnimalRepositoryTest {
private val fakeServer = FakeServer() // 1
private lateinit var repository: AnimalRepository
private lateinit var api: PetFinderApi
@get:Rule // 2
val hiltRule = HiltAndroidRule(this)
@Inject // 3
lateinit var cache: Cache
@Inject
lateinit var retrofitBuilder: Retrofit.Builder
@Inject
lateinit var apiAnimalMapper: ApiAnimalMapper
@Inject
lateinit var apiPaginationMapper: ApiPaginationMapper
}
Here, you:
- Add
AnimalRepository
andPetFinderApi
properties, which you’ll initialize later. You also create something calledFakeServer
. This is a helper class that will handle MockWebServer for you, including reading from the assets. - Add a Hilt rule that you’ll use later to tell Hilt when to inject the dependencies. This is important because it gives you leeway to handle any configuration you might need before the injection.
- Mark every dependency you want to inject with
@Inject
so Hilt knows what you need.
At this point, you’re injecting all the dependencies you need to build a PetFinderAnimalRepository
instance, except for the API. That’s because you need to configure the API manually, using the Retrofit.Builder
you’re injecting here.
You’ll get to that in a second; there’s still one dependency missing. Remember that you told Hilt to ignore PreferencesModule
through @UninstallModules
? You need to provide a replacement, or the test won’t even build.
Providing the Dependency Graph for Testing
When you implement an instrumentation test with Hilt and want to inject test dependencies, you need to provide a different dependency graph than the one you use in the production app. In your case, you need to replace PreferencesModule
.
Hilt gives you two options here:
- Build an entirely new module to replace the original binding.
- Use a special set of annotations that both replace the original binding and bind anything else in its place.
For now, you’ll go with the second option. In the same PetFinderAnimalRepositoryTest.kt file add the following code:
Below the other dependencies you just added, add:
@HiltAndroidTest
@UninstallModules(PreferencesModule::class)
class PetFinderAnimalRepositoryTest {
// ...
@BindValue // 1
val preferences: Preferences = FakePreferences() // 2
}
Going line-by-line:
- This annotation handles the replacement and injection for you. If you need to need to work with multibindings, there are other variants to use, like
@BindValueIntoSet
and@BindValueIntoMap
. - This is what you replace the original binding with. You’re providing a fake implementation of
Preferences
that simply has a private map to read and write the properties.
Now, it’s time to implement your tests. They’ll have some initalization in common. You can handle this in @Before
.
Implementing the @Before Function
With this done, you can now implement the typical “before and after” test configuration methods. Add setup
and teardown
below what you just added:
@HiltAndroidTest
@UninstallModules(PreferencesModule::class)
class PetFinderAnimalRepositoryTest {
// ...
@Before
fun setup() {
fakeServer.start() // 1
// 2
preferences.deleteTokenInfo()
preferences.putToken("validToken")
preferences.putTokenExpirationTime(
Instant.now().plusSeconds(3600).epochSecond
)
preferences.putTokenType("Bearer")
hiltRule.inject() // 3
// 4
api = retrofitBuilder
.baseUrl(fakeServer.baseEndpoint)
.build()
.create(PetFinderApi::class.java)
// 5
repository = PetFinderAnimalRepository(
api,
cache,
apiAnimalMapper,
apiPaginationMapper
)
}
@After // 6
fun teardown() {
fakeServer.shutdown()
}
}
In this code:
- You tell the fake server to start itself. This will start the MockWebServer instance.
- This is a cool thing that Hilt lets you do. Since you already created the instance that will replace the original
Preferences
binding, you can change it. So here, you delete any previous information and add the information you need for the “happy path”. You’ll only test happy paths. Note that you could also mark the property as alateinit var
and initialize it here. - You’ve configured all the dependencies, so you’re ready for Hilt to inject them. To do so, you call
inject
on the rule instance. - Before creating the repository instance, you still need to configure the API. You need to redirect the calls to MockWebServer instead of the real endpoint, so you take the
Retrofit.Builder
you injected earlier, change its base URL and, finally, create aPetFinderApi
instance. - Lastly, you create the repository instance.
- This just shuts off the server, like you did in the unit tests.
Finally, you can start writing your tests.
Writing Your Tests
Whew! That took some work, but you’re finally able to write your tests. In the same PetFinderAnimalRepositoryTest.kt file add the following code:
@HiltAndroidTest
@UninstallModules(PreferencesModule::class, CacheModule::class)
class PetFinderAnimalRepositoryTest {
// ...
@Test
fun requestMoreAnimals_success() = runBlocking { // 1
// Given
val expectedAnimalId = 124L
fakeServer.setHappyPathDispatcher() // 2
// When
val paginatedAnimals = repository.requestMoreAnimals(1, 100) // 3
// Then
val animal = paginatedAnimals.animals.first() // 4
assertThat(animal.id).isEqualTo(expectedAnimalId)
}
// ...
}
In this code, you use:
-
runBlocking
, which makes the test run inside a coroutine. You need it because the network request is asuspend
function. - Set MockWebServer to choose the happy path: Return a successful response on the
animals
endpoint. - Make the request. MockWebServer doesn’t check the request parameters in this case, but in theory, it could.
- Get the first — and only, since the mocked response only has one in the list — animal, and check if its ID matches the expected value.
Note: Never use
runBlocking
in production code! It completely blocks the thread the coroutine runs on, defeating the purpose of having coroutines in the first place.
This test starts at the repository, goes to the API, works through the interceptors and goes back up again. With Hilt’s help, not only can you test the way all the bits and pieces fit together, but you can also make little tweaks here and there to test a wide variety of scenarios.
Now that you’ve covered the API integration, you’ll test how the cache fits into all this. There’s one small problem though: You don’t want to mess with the real database in your tests!
Using Room’s In-Memory Database
Fortunately, Room provides a neat way to work around this: an in-memory database. You’ll inject that instead of the production database, then use it to test the repository. That’s why you’re running the tests in androidTest: Room needs to run on a device.
First, tell Hilt to remove CacheModule
by updating @UninstallModules
:
@UninstallModules(PreferencesModule::class, CacheModule::class)
Then, remove the @Inject
annotation from cache
:
private lateinit var cache: Cache
Below it, inject PetSaveDatabase
:
@Inject
lateinit var database: PetSaveDatabase
Now, to replace CacheModule
, you’ll try the other option. Add a new module below Preferences
:
@Module
@InstallIn(SingletonComponent::class) // 1
object TestCacheModule {
@Provides
fun provideRoomDatabase(): PetSaveDatabase { // 2
return Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().context,
PetSaveDatabase::class.java
)
.allowMainThreadQueries() // 3
.build()
}
}
In this code, you:
- Install
TestCacheModule
in the same component asCacheModule
. - Provide a single
PetSaveDatabase
instance, as you don’t need any of the other dependencies. - Call
allowMainThreadQueries
when building the in-memory database, which lets you ignore the thread where you run the queries in your tests. Please, never do this in production code!
Building the Cache
With the module ready, you can now build your own Cache
with the test database. In setup
, between api
and repository
, add the line:
cache = RoomCache(database.animalsDao(), database.organizationsDao())
And that’s it! It’s time for an actual test now. You’ll test the insertion into the database. To assert the result, you’ll use getAllAnimals
and check if the Flowable
stream returns anything. Technically, it’s almost like doing two tests in one!
Speaking of streams, you want it to emit the new event right away, so you need to ensure that Room executes all its operations instantly. To do this, you have to use a JUnit rule that swaps the background executor used by Architecture Components with one that’s synchronous. This rule is called InstantTaskExecutorRule. Add it below the Hilt rule:
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
With that out of the way, you’ll create your test. At the bottom of the class, add:
@Test
fun insertAnimals_success() {
// Given
val expectedAnimalId = 124L
runBlocking {
fakeServer.setHappyPathDispatcher()
val paginatedAnimals = repository.requestMoreAnimals(1, 100) // 1
val animal = paginatedAnimals.animals.first()
// When
repository.storeAnimals(listOf(animal)) // 2
}
// Then
val testObserver = repository.getAnimals().test() // 3
testObserver.assertNoErrors() // 4
testObserver.assertNotComplete()
testObserver.assertValue { it.first().id == expectedAnimalId }
}
Here’s a breakdown of this code:
- To save you from creating an
Animal
instance, the code uses the mocked data that MockWebServer returns. - You store the animal…
- … And then subscribe to the
getAnimals
stream. Callingtest()
on it returns a specialTestObserver
that allows you to assess both its state and the stream’s. - Using the test observer, you assert that there were no errors on the stream and it didn’t complete. It’s an infinite stream after all. Finally, you call
assertValue
on the observer, which asserts that the stream emitted one event only. It also gives you access to event data for you to make sure that it’s what you expect.
This test is not completely contained in runBlocking
, like the previous one. JUnit tests are expected to return Unit
and that last testObserver
line returns a type. Having it inside runBlocking
’s scope would return that type.
Build and run your tests. Congratulations on making sure your different components are well integrated!
This concludes this chapter. In the next chapters, you’ll include the presentation layer in your work and, finally, wrap up a basic version of the Animals near you and Search features.
Key Points
- Room relationships allow you easily manipulate data… after you go through the effort of creating them.
- Dependency injection is helpful, not only with dependency management, but with testing as well.
- Just like MockWebServer, Room’s in-memory database allows you to build more robust and realistic tests.