How to make the application more stable with 2 types of unit tests
Hi, Habr. My name is Ilya Smirnov, I am an Android developer at FINCH. I want to show you some examples of working with Unit tests that we have developed in our team.
Two types of Unit tests are used in our projects: conformance checking and call checking. Let us dwell on each of them in more detail.
Conformity testing checks whether the actual result of the execution of some function matches the expected result or not. Let me show you an example - imagine that there is an application that displays a list of news for the day:
Data about the news is taken from different sources and, at the exit from the business layer, turns into the following model:
According to the application logic, a model of the following form is required for each element of the list:
The following class will be responsible for converting a domain model to a view model:
Thus, we know that some object
Will be converted to some object
The input and output data are known, which means you can write a test for the mapToNewsViewData method , which will check the compliance of the output data depending on the input.
To do this, in the app / src / test / ... folder, create the NewsMapperTest class with the following contents:
The result obtained is compared against expectations using methods from the org.junit.Assert package . If any value does not meet the expectation, then the test will fail.
There are times when the constructor of the tested class takes some dependencies. It can be either simple ResourceManager for accessing resources, or full Interactor for executing business logic. You can create an instance of such a dependency, but it is better to make a similar mock object. A mock object provides a fictitious implementation of a class, with which you can track the call of internal methods and override return values.
There is a popular Mockito framework for creating mock .
In Kotlin, all classes are final by default, so you cannot create mock objects on Mockito from scratch. To work around this limitation, it is recommended that you add the mockito-inline dependency .
If you use kotlin dsl when writing tests, you can use various libraries, such as Mockito-Kotlin .
Suppose that NewsMapper takes in the form of a dependency a certain NewsRepo , which records information about the user viewing a particular news item . Then it makes sense to mock NewsRepo and check the return values of the mapToNewsViewData method depending on the result of isNewsRead .
Thus, the mock-object allows you to simulate various options for return values to test various test cases.
In addition to the examples above, conformance testing includes various data validators. For example, a method that checks the entered password for the presence of special characters and the minimum length.
Testing for a call checks whether the method of one class calls the necessary methods of another class or not. Most often, such testing is applied to Presenter , which sends View specific commands for changing state. Back to the news list example:
The most important thing here is the very fact of invoking methods from Interactor and View . The test will look like this:
Different solutions may be required to exclude platform dependencies from tests, as it all depends on the technology for working with multithreading. The example above uses Kotlin Coroutines with an overridden scope to run tests, as used in the Dispatchers.Main program code refers to the android UI thread, which is unacceptable in this type of testing. Using RxJava will require other solutions, for example, creating a TestRule that switches the code execution flow.
To verify that a method has been called, the verify method is used, which can take methods that indicate the number of calls to the method being tested as additional arguments.
The test options considered can cover a fairly large percentage of the code, making the application more stable and predictable. Test-covered code is easier to maintain, easier to scale, because There is a certain amount of confidence that when adding new functionality, nothing will break. And of course, such code is easier to refactor.
The easiest class to test does not contain platform dependencies, because when working with it, you do not need third-party solutions for creating platform mock-objects. Therefore, our projects use an architecture that minimizes the use of platform dependencies in the layer under test.
Good code should be testable. The complexity or inability to write unit tests usually shows that something is wrong with the code being tested, and it's time to think about refactoring.
The source code for the example is available on GitHub .
Two types of Unit tests are used in our projects: conformance checking and call checking. Let us dwell on each of them in more detail.
Compliance testing
Conformity testing checks whether the actual result of the execution of some function matches the expected result or not. Let me show you an example - imagine that there is an application that displays a list of news for the day:
Data about the news is taken from different sources and, at the exit from the business layer, turns into the following model:
data class News(
val text: String,
val date: Long
)
According to the application logic, a model of the following form is required for each element of the list:
data class NewsViewData(
val id: String,
val title: String,
val description: String,
val date: String
)
The following class will be responsible for converting a domain model to a view model:
class NewsMapper {
fun mapToNewsViewData(news: List): List {
return mutableListOf().apply{
news.forEach {
val textSplits = it.text.split("\\.".toRegex())
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale("ru"))
add(
NewsViewData(
id = it.date.toString(),
title = textSplits[0],
description = textSplits[1].trim(),
date = dateFormat.format(it.date)
)
)
}
}
}
}
Thus, we know that some object
News(
"Super News. Some description and bla bla bla",
1551637424401
)
Will be converted to some object
NewsViewData(
"1551637424401",
"Super News",
"Some description and bla bla bla",
"2019-03-03 21:23"
)
The input and output data are known, which means you can write a test for the mapToNewsViewData method , which will check the compliance of the output data depending on the input.
To do this, in the app / src / test / ... folder, create the NewsMapperTest class with the following contents:
class NewsMapperTest {
private val mapper = NewsMapper()
@Test
fun mapToNewsViewData() {
val inputData = listOf(
News("Super News. Some description and bla bla bla", 1551637424401)
)
val outputData = mapper.mapToNewsViewData(inputData)
Assert.assertEquals(outputData.size, inputData.size)
outputData.forEach {
Assert.assertEquals(it.id, "1551637424401")
Assert.assertEquals(it.title, "Super News")
Assert.assertEquals(it.description, "Some description and bla bla bla")
Assert.assertEquals(it.date, "2019-03-03 21:23")
}
}
}
The result obtained is compared against expectations using methods from the org.junit.Assert package . If any value does not meet the expectation, then the test will fail.
There are times when the constructor of the tested class takes some dependencies. It can be either simple ResourceManager for accessing resources, or full Interactor for executing business logic. You can create an instance of such a dependency, but it is better to make a similar mock object. A mock object provides a fictitious implementation of a class, with which you can track the call of internal methods and override return values.
There is a popular Mockito framework for creating mock .
In Kotlin, all classes are final by default, so you cannot create mock objects on Mockito from scratch. To work around this limitation, it is recommended that you add the mockito-inline dependency .
If you use kotlin dsl when writing tests, you can use various libraries, such as Mockito-Kotlin .
Suppose that NewsMapper takes in the form of a dependency a certain NewsRepo , which records information about the user viewing a particular news item . Then it makes sense to mock NewsRepo and check the return values of the mapToNewsViewData method depending on the result of isNewsRead .
class NewsMapperTest {
private val newsRepo: NewsRepo = mock()
private val mapper = NewsMapper(newsRepo)
…
@Test
fun mapToNewsViewData_Read() {
whenever(newsRepo.isNewsRead(anyLong())).doReturn(true)
...
}
@Test
fun mapToNewsViewData_UnRead() {
whenever(newsRepo.isNewsRead(anyLong())).doReturn(false)
...
}
…
}
Thus, the mock-object allows you to simulate various options for return values to test various test cases.
In addition to the examples above, conformance testing includes various data validators. For example, a method that checks the entered password for the presence of special characters and the minimum length.
Call Testing
Testing for a call checks whether the method of one class calls the necessary methods of another class or not. Most often, such testing is applied to Presenter , which sends View specific commands for changing state. Back to the news list example:
class MainPresenter(
private val view: MainView,
private val interactor: NewsInteractor,
private val mapper: NewsMapper
) {
var scope = CoroutineScope(Dispatchers.Main)
fun onCreated() {
view.setLoading(true)
scope.launch {
val news = interactor.getNews()
val newsData = mapper.mapToNewsViewData(news)
view.setLoading(false)
view.setNewsItems(newsData)
}
}
…
}
The most important thing here is the very fact of invoking methods from Interactor and View . The test will look like this:
class MainPresenterTest {
private val view: MainView = mock()
private val mapper: NewsMapper = mock()
private val interactor: NewsInteractor = mock()
private val presenter = MainPresenter(view, interactor, mapper).apply {
scope = CoroutineScope(Dispatchers.Unconfined)
}
@Test
fun onCreated() = runBlocking {
whenever(interactor.getNews()).doReturn(emptyList())
whenever(mapper.mapToNewsViewData(emptyList())).doReturn(emptyList())
presenter.onCreated()
verify(view, times(1)).setLoading(true)
verify(interactor).getNews()
verify(mapper).mapToNewsViewData(emptyList())
verify(view).setLoading(false)
verify(view).setNewsItems(emptyList())
}
}
Different solutions may be required to exclude platform dependencies from tests, as it all depends on the technology for working with multithreading. The example above uses Kotlin Coroutines with an overridden scope to run tests, as used in the Dispatchers.Main program code refers to the android UI thread, which is unacceptable in this type of testing. Using RxJava will require other solutions, for example, creating a TestRule that switches the code execution flow.
To verify that a method has been called, the verify method is used, which can take methods that indicate the number of calls to the method being tested as additional arguments.
*****
The test options considered can cover a fairly large percentage of the code, making the application more stable and predictable. Test-covered code is easier to maintain, easier to scale, because There is a certain amount of confidence that when adding new functionality, nothing will break. And of course, such code is easier to refactor.
The easiest class to test does not contain platform dependencies, because when working with it, you do not need third-party solutions for creating platform mock-objects. Therefore, our projects use an architecture that minimizes the use of platform dependencies in the layer under test.
Good code should be testable. The complexity or inability to write unit tests usually shows that something is wrong with the code being tested, and it's time to think about refactoring.
The source code for the example is available on GitHub .