background

This article is a summary of Introduction to Test Double and Dependence Injection and Testing Basics in Google Code Lab. This article focuses on how to test the Model layer and ViewModel layer in an Android project with MVVM architecture

Model layer

Why are we measuring it

As the data acquisition layer, the Model layer mainly deals with network and database. We need to test the correctness of its data acquisition and update operation logic

What kind of problems do you have when you test it

As mentioned above, the Model layer usually has a strong correlation with databases and networks, and we only need to test its data processing logic.

How to solve

Change the way the dataSource is obtained, do not use internal construction, use dependency injection method for injection. This is usually written in the Repository code, the dataSource is built in the internal, which makes it difficult to remove the coupling between the logic and the dataSource, so that the test cannot be carried out

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

    init {
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }
    // Rest of class
}

Here is the code that uses the construction-injection approach

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }

This gives us decoupling and allows us to test it in unit tests

To implement the test, we need to implement a fakeDataSource of our own that maintains the virtual data set

For the test, we went straight to the fakeDataSource

Complete code:

@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {
    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
                tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }
    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest{
        val tasks = tasksRepository.getTasks(true) as Result.Success
        assertThat(tasks.data,IsEqual(remoteTasks))
    }
}

The ViewModel layer

Why are we measuring it

As the primary control center for the program logic, it is necessary to test the ViewModel to ensure that the logic is correct

What kind of problems do you have when you test it

As the middle layer between View and Model, the biggest problems in ViewModel testing are the following two points

  1. How to test bidirectional binding LiveData
  2. How to solve the dependency problem with the Model layer, how to use fake data to test the correctness of logic

How to solve

  1. How to test bidirectional bound LiveData using the following utility class to use Countdownlatch to change an asynchronous process to a synchronous one, thereby synchronizing the value of LiveData
@VisibleForTesting(otherwise = VisibleForTesting.NONE) fun <T> LiveData<T>.getOrAwaitValue( time: Long = 2, timeUnit: TimeUnit = TimeUnit.SECONDS, afterObserve: () -> Unit = {} ): T { var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer<T> { override fun onChanged(o: T?) { data = o latch.countDown() [email protected](this) } } this.observeForever(observer) try { afterObserve.invoke() // Don't wait indefinitely if the LiveData is not set. if (! latch.await(time, timeUnit)) { throw TimeoutException("LiveData value was never set.") } } finally { this.removeObserver(observer) } @Suppress("UNCHECKED_CAST") return data as T }
  1. Using the method used in this article to test the Model layer, we build a FakeRepository that passes in the constructor of the ViewModel. At this point, the way the ViewModel is constructed in the Fragment or Activity changes, as shown in the following code

Fragment

class TasksFragment : Fragment() {

    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
    }
//...
}

ViewModel

class TasksViewModel(
    private val tasksRepository: TasksRepository
) : ViewModel() {
//...
}

@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TasksViewModel(tasksRepository) as T)
}

The complete code

@RunWith(AndroidJUnit4::class) class TasksViewModelTest{ // Subject under test private lateinit var tasksViewModel: TasksViewModel private lateinit var tasksRepository: FakeTestRepository // Executes each task synchronously using Architecture Components. @get:Rule var instantExecutorRule = InstantTaskExecutorRule() @Before fun setupViewModel() { tasksRepository = FakeTestRepository() val task1 = Task("Title1", "Description1") val task2 = Task("Title2", "Description2", true) val task3 = Task("Title3", "Description3", true) tasksRepository.addTasks(task1, task2, task3) tasksViewModel = TasksViewModel(tasksRepository) } @Test fun addNewTask_setsNewTaskEvent() { // Given a fresh TasksViewModel // When adding a new task tasksViewModel.addNewTask() // Then the new task event is triggered val value =tasksViewModel.newTaskEvent.getOrAwaitValue() assertThat(value.getContentIfNotHandled(),(not(nullValue()))) } @Test fun  setFilterAllTasks_tasksAddViewVisible() { // Given a fresh ViewModel // When the filter type is ALL_TASKS tasksViewModel.setFiltering(TasksFilterType.ALL_TASKS) // Then the "Add task" action is visible val value = tasksViewModel.tasksAddViewVisible.getOrAwaitValue() assertThat(value,`is`(true)) } }

Why not do UI testing

The official provides a UI test solution, but the test scope is limited to whether the UI display and UI text, can be replaced by manual test, and the test cases are changed more frequently after THE UI is changed, so UI testers feel it is not necessary to write unit test mode