Testing WorkManager Worker classes which use Assisted Dependency Injection

ยท

6 min read

Table of contents

No heading

No headings in the article.

This article is for someone who wants to write tests for the business logic inside their Worker classes that use Assisted Dependency Injection.

Suppose you have a Worker which looks like below and you want to test its business logic.

@HiltWorker
class DemoWorker @AssistedInject constructor(
    // provided by WorkManager
    @Assisted ctx: Context,

    // provided by WorkManager
    @Assisted params: WorkerParameters,

    // not provided by WorkManager
    private val foo: Foo()

) : CoroutineWorker(ctx, params) {
       override suspend fun doWork(): Result {
       foo.someFunction()
    }
}

Here Dagger-Hilt has been used for DI but that won't matter as for testing we won't be using any DI framework.

Testing Business logic is not the aim here but how to set up our Tests so that a Worker class that uses assisted Injection can be tested is.
I have attached the link to the GitHub repository at the end of the blog with the complete code but I would recommend you follow along.

To start create a new Android Project and add the following dependencies inside app-level build.gradle file

// work manager
implementation "androidx.work:work-runtime-ktx:2.7.1"
implementation("androidx.hilt:hilt-work:1.0.0")

// Testing WorkManager
androidTestImplementation "androidx.work:work-testing:2.7.1"

// For Making Assertions in Test Cases
androidTestImplementation "com.google.truth:truth:1.1.3"

Let's create and write some minimal business logic inside a Worker class for the sake of better understanding. Create a class SyncWorker inside the main source set

This is what our SyncWorker looks like.

package com.raghav.workmanagertesting

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.raghav.workmanagertesting.repository.IRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject

@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted ctx: Context,
    @Assisted params: WorkerParameters,
    private val repository: IRepository
) : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result {
        return if (repository.refreshLocalDatabase()) {
            Result.success()
        } else {
            Result.failure()
        }
    }

    companion object {
        private const val TAG = "SyncWorker"
    }
}

IRepository is an Interface that will later help us to use a fake version of
the real repository class which will be named RealRepository and will contain the actual code used in production located at com.raghav.workmanagertesting.repository as the purpose is to test SyncWorker and not the RealRepository itself. Also, it is advised to use fakes or mocks of dependencies of the class under test to create a controlled testing environment.

package com.raghav.workmanagertesting.repository

interface IRepository {
    suspend fun refreshLocalDatabase(): Boolean
}

Our RealRepository class will take two parameters api (Remote DataSource) and dao (Local DataSource) and will contain a function refreshLocalDatabase() which performs two operations:
1. Fetches data from a remote api
2. Saves this data inside our database

If any exception occurs while performing any of the above operations it will return False otherwise True.
We need to do this operation periodically with an interval of 1 hour so we will use SyncWorker for this operation.

This is how our RealRepository class will look

package com.raghav.workmanagertesting.repository

import com.raghav.workmanagertesting.SampleApi
import com.raghav.workmanagertesting.SampleDatabaseDao
import javax.inject.Inject

class RealRepository @Inject constructor(
    private val api: SampleApi,
    private val dao: SampleDatabaseDao
) : IRepository {

    override suspend fun refreshLocalDatabase(): Boolean {
        return try {
            val items = api.getItemsFromApi()
            dao.saveItemsInDb(items)
            true
        } catch (e: Exception) {
            false
        }
    }
}

Our Worker returns Result.success() if the local database was successfully refreshed and Result.failure() if there was an exception.

Inside androidTest source set of your project create a class SyncWorkerTest
This is where we will write test cases to test business logic o SyncWorker

What are we going to Test?
We will write a test to confirm that if any error occurs while fetching data from the remote api our SyncWorker will return Result.failure()

Inside SyncWorkerTest, create the following function with @Test annotation.

package com.raghav.workmanagertesting

import org.junit.Test

@Test
fun ifErrorInFetchingResponseThenReturnFailure(){
}

In Production code we have to first get an Instance of WorkManager then create a work request and then enqueue our work request something like this
val myWorkRequest = OneTimeWorkRequest.from(MyWork::class.java) WorkManager.getInstance(myContext).enqueue(myWorkRequest)

But since Workmanager 2.1.0 classes TestWorkerBuilder and TestListenableWorkerBuilder were provided to test business logic inside Worker and Coroutine Worker classes respectively without needing to instantiate WorkManager in the test cases.
Also as we are inside the androidTest source set we can get context with the help of
ApplicationProvider.getApplicationContext<Context>() and our test case will now look like this

package com.raghav.workmanagertesting

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.testing.TestListenableWorkerBuilder
import org.junit.Test

@Test
fun ifErrorInFetchingResponseThenReturnFailure() {
    val context = ApplicationProvider.getApplicationContext<Context>()

        val worker = TestListenableWorkerBuilder<SyncWorker>(
            context = context
        ).build()
    }

What's left is to call our SyncWorker's doWork() method and assert the result, right? Emphasis on right here.

package com.raghav.workmanagertesting

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.Test

@Test
fun ifErrorInFetchingResponseThenReturnFailure() {
    val context = ApplicationProvider.getApplicationContext<Context>()
    val worker = TestListenableWorkerBuilder<SyncWorker>(
        context = context
    ).build()

    runBlocking {
        val result = worker.doWork()
        assertThat(result).isEqualTo(Result.failure())
    }
}

As doWork() is a suspend function we can only call from a Coroutine so I have used runBlocking Coroutine builder which is often used for testing purposes but hardly in a production environment.

If we will run this test we will get an Error!
java.lang.IllegalStateException: Could not create an instance of ListenableWorker

The Error says that an Instance of Listeneable Worker which in our case is SyncWorker cannot be created. Therefore we have to provide a factory that will create SyncWorker in the test environment because in the production environment, Hilt automatically takes care of this by HiltWorkerFactory.

Let's create a file called TestWorkerFactory inside androidTest Source set.
In this class, we will write the logic for the creation of our SyncWorker in a test environment.

package com.raghav.workmanagertesting

import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters

class TestWorkerFactory : WorkerFactory() {

    override fun createWorker(
        appContext: Context,
        workerClassName: String,
        workerParameters: WorkerParameters
    ): ListenableWorker {
        return SyncWorker(
                         appContext, 
                         workerParameters, 
                         // instance of repository
        )
    }
}

We will pass a Fake Implementation of RealRepository for testing purposes.
Create a class FakeRepository inside androidTest source set and copy the following content.

package com.raghav.workmanagertesting

import com.raghav.workmanagertesting.repository.IRepository
import com.raghav.workmanagertesting.util.FakeApi
import com.raghav.workmanagertesting.util.FakeDao

class FakeRepository(
    private val fakeApi: FakeApi = FakeApi(),
    private val fakeDao: FakeDao = FakeDao()
) : IRepository {
    override suspend fun refreshLocalDatabase(): Boolean {
        return try {
            val items = fakeApi.getItemsFromApi()
            if (items.isNullOrEmpty()) {
                throw Exception("Error occurred while fetching response")
            }
            fakeDao.saveItemsInDatabase(items)
            true
        } catch (e: Exception) {
            false
        }
    }
}

FakeApi and FakeDao, as the name suggests, are fake versions of real api and dao used inside RealRepository to emulate real-world conditions without actually using retrofit or Room. Let's create a package called util an add both with the following content.

package com.raghav.workmanagertesting.util
import com.raghav.workmanagertesting.SampleResponseItem

class FakeApi {

//    to mock successful response
//    fun getItemsFromApi() = listOf(
//        SampleResponseItem(1, "title1"),
//        SampleResponseItem(1, "title2")
//    )

//    to mock failure response
      fun getItemsFromApi(): List<SampleResponseItem>? = null
}
package com.raghav.workmanagertesting.util

import com.raghav.workmanagertesting.SampleResponseItem

class FakeDao {
    fun saveItemsInDatabase(items: List<SampleResponseItem>) {
    }
}

Now as we have our FakeRepository ready we can just provide it in our TestWorkerFactory while creating SyncWorker.

package com.raghav.workmanagertesting

import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters

class TestWorkerFactory : WorkerFactory() {

    override fun createWorker(
        appContext: Context,
        workerClassName: String,
        workerParameters: WorkerParameters
    ): ListenableWorker {
        return SyncWorker(
               appContext, 
               workerParameters, 
               FakeRepository()
              )
    }
}

What's left is to provide this factory to the TestListenableWorkerBuilder inside our SyncWorkerTest class through the setWorkerFactory() method and our final test class will look like this.

package com.raghav.workmanagertesting

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.Test

class SyncWorkerTest {

    @Test
    fun ifErrorInFetchingResponseThenReturnFailure() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val worker = TestListenableWorkerBuilder<SyncWorker>(
            context = context
        ).setWorkerFactory(TestWorkerFactory())
            .build()

        runBlocking {
            val result = worker.doWork()
            assertThat(result).isEqualTo(Result.failure())
        }
    }
}

If you will now run your test it will run and execute successfully!

We have reached our aim and here is the link to the GitHub repository
https://github.com/avidraghav/TestingWorkManager

If you liked the blog then do comment or react. Any Feedback will be highly appreciated ๐Ÿ˜„

Connect with me on LinkedIn and Twitter

Here are links to some of my other blogs:
Testing Strategy and its Theory in Android
Code Compilation Process in Android
Intercepting Network Requests in Android via OkHttp Interceptor.

ย