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
- How to test bidirectional binding LiveData
- How to solve the dependency problem with the Model layer, how to use fake data to test the correctness of logic
How to solve
- 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 }
- 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