Repository pattern and testing
This article shows how to test a repository in a relatively large project.
Listening to changes
In another article in this blog, in the section ‘Observable repository’, I described a design pattern I use for different observers to be notified when there has been a change in the repository. I call that pattern LiveRepository
.
This is the code I was using before I performed a refactoring, which I will discuss shortly. Please do not use it:
abstract class LiveRepository {
private val liveData = MutableLiveData<Unit>()
private val channel = BroadcastChannel<Unit>(Channel.CONFLATED)
protected fun notifyDataSetChanged() {
liveData.postValue(Unit)
channel.sendBlocking(Unit)
}
infix fun vm(vm: ViewModel) = { block: () -> Unit ->
vm.viewModelScope.launch { this@LiveRepository(block) }
}
operator fun plus(owner: LifecycleOwner): HotData<Unit> =
DefaultHotData(liveData, owner)
suspend operator fun invoke(block: () -> Unit) {
with (channel.openSubscription()) {
if (!isEmpty) receive()
try {
while (true) {
receive()
block()
}
} finally { cancel() }
}
}
}
The code above tries to solve the following problem:
There are two ways in which something may be observed. One is aware of Lifecycle
, and the other is aware of CoroutineScope
.
Things observed in Activity
and Fragment
should probably use LiveData
, but beceause Lifecycle
is not present in ViewModel
, but viewModelScope
is, the developer, apart from returning an instance of LiveData
, should provide a mechanism that is canceled simultaneously with the surrounding CoroutineScope
.
In the above code, I accommodated for the first case by implementing the operator plus()
that combines LiveData
with LifeCycle
, so that it may be observed by an outside code. The latter case is addressed by the suspend operator invoke()
, which runs a block of code on each update, as long as the CoroutineScope
it runs in is not canceled.
The DSL I used in the above code is discussed in a dedicated article in this blog. Please note that at present, I do not recommend using it.
Instead of the above, I currently suggest the form presented below, and introduced in a specific commit:
abstract class LiveRepository {
private val broadcastChannel = BroadcastChannel<Unit>(Channel.CONFLATED)
protected fun notifyDataSetChanged() {
broadcastChannel.sendBlocking(Unit)
}
fun updatedLiveData(scope: CoroutineScope) =
updatedLiveData(scope.coroutineContext)
fun updatedLiveData(context: CoroutineContext = EmptyCoroutineContext) =
liveData(context) {
with (broadcastChannel.openSubscription()) {
try {
while (true) {
emit(receive())
}
}
finally {
cancel()
}
}
}
}
The above code takes advantage of my favorite function liveData()
, which wraps CoroutineScope
handling in LiveData
.
The function currently is in its alpha state, but I believe it is good to use it. By doing so, the developer takes advantage of code that is probably already tested by thousands of other developers.
By avoiding the use of alpha dependencies, and choosing to use one’s own implementations, the developer is forced to use a code that is only tested by themselves, and perhaps a few of the other members of their team, and has to maintain it.
By using the libraries provided by Google, even though they are in alpha stage now, the developer capitalizes of the maintenance performed by the company, and the function itself will probably soon reach a stable release.
The required dependencies for liveData()
are listed in Google’s documentation.
The only time when I use updatedLiveData()
with CoroutineContext
other than the default EmptyCoroutineContext
is when I don’t consume the LiveData
in Activity
or Fragment
, but inside ViemModel
:
class CommViewModel(private val state: SavedStateHandle) : ViewModel() {
...
val name by lazy { MutableLiveData<String?>() }
val desc by lazy { MutableLiveData<String?>() }
val isAdmin by lazy { MutableLiveData<Boolean?>() }
init {
comms.updatedLiveData(viewModelScope).observeForever { update() }
val position = state.get<Int>(COMM_POSITION) ?: -1
if (position >= 0) {
comm = comms[position]
}
}
fun withPosition(position: Int) {
comm = comms[position]
state.set(COMM_POSITION, position)
}
fun update() {
comm = comms[comm.name]
reset()
}
fun reset() {
name.value = comm.name
desc.value = comm.desc
isAdmin.value = comm.isAdmin
}
...
}
In the above code, belonging to a ViewModel
, I don’t expose directly the LiveData
coming from the LiveRepository
. Instead, I create a few other instances of LiveData and expose them.
The fields name
, desc
and isAdmin
are instances of LiveData
that are exposed to the Fragment
that uses them. Because I didn’t want the Fragment
itself be notified about the updates is the repository, I have no access to Lifecycle
.
What I do have access to is viewModelScope
, so I use it to create LiveData
and observe it forever in the line:
comms.updatedLiveData(viewModelScope).observeForever { update() }
Thanks to that, the CoroutineScope
wrapped in the LiveData
will be canceled in due time, which prevents the ViewModel
from hanging around and being notified when it is no longer alive.
The test
Testing with coroutines was discussing in another article in this blog, so I recommend reading it to it in order to understand the present description.
Preparation for tests, like the use of a few of the annotations @Rule
, @Before
and @After
, was explained in the other article, so I won’t repeat it here.
This is the testing code:
@ExperimentalCoroutinesApi
@ObsoleteCoroutinesApi
class EventsTest {
private lateinit var fsReader: FsReader
private lateinit var fsWriter: FsWriter
private lateinit var eventsDao: EventsDao
private val mainThreadSurrogate = newSingleThreadContext("UI thread")
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Before
fun setUp() {
Dispatchers.setMain(mainThreadSurrogate)
}
@After
fun tearDown() {
Dispatchers.resetMain()
mainThreadSurrogate.close()
}
@Before
fun mockConstructorParameters() {
fsReader = mock(FsReader::class.java)
fsWriter = mock(FsWriter::class.java)
eventsDao = mock(EventsDao::class.java)
}
fun testAdd() = runBlockingTest {
val events = Events(fsReader, fsWriter, eventsDao)
val event = Event.creationEvent
val job = Job()
@Suppress("UNCHECKED_CAST")
val observer = mock(Observer::class.java) as Observer<Unit>
events.updatedLiveData(job).observeForever(observer)
events.add(event)
verify(fsWriter).add(event)
verify(eventsDao).add(event)
verify(observer).onChanged(Unit)
job.cancel()
}
}
When I add more tests to the code of the class pasted above, probably all of them are going to inject the same dependencies (FsReader
, FsWriter
and EventDao
) in the constructor of Events
, so I moved thein initialization to one of the @Before
functions.
In case specific arrangements need to be done for a particular test, they will be in specific functions annotated as @Test
, but the creation of the mock itself will probably be common for all tests.
The test verifies that three things happen od addition of an Event
to the repository:
- The
Event
is added to Firestore database. - The
Event
is added to the DAO. - An interested ‘Observer’ is notified about the change.
As dicsussed above, the test mocks the Firebase cloud and the DAO. Because I am not sure whether the Observer
is going to be used in other tests, and it is not a required dependency, I decided to mock it in the @Test
function. Because I do not have a Lifecycle
available in the test, I start the observation by calling observeForever()
, which doesn’t require it.
Conclusion
The present article has demonstrated how to add to a project a test using dependency injection and coroutines.
It has also demonstrated how to use one function currently in alpha stage - liveData()
- to create an instance of LiveData
that is aware not only of Lifecycle
, but also of CoroutineScope
.
It has also shown the progress I made in my understanding of testing with Mockito, since I wrote the first article about it.
I have used Mockito in two of my projects now: The first article used the project Compass as an example, while the present one is using a more advanced project - Victor Events - which I still have a great interest in developing, and is probably going to have many more improvements, which may be in time described in this blog.
Donations
If the reader has enjoyed the article, they may want to donate some bitcoin at the address presented below. Readers may also look at my donations page.
BTC: bc1qncxh5xs6erq6w4qz3a7xl7f50agrgn3w58dsfp