Hi, Habr. My name is Ilya Smirnov, I'm an Android developer at FINCH. I want to show you some examples of working with Unit tests, which we have developed in our team.
In our projects, two types of unit tests are used: compliance testing and challenge testing. Let us dwell on each of them in more detail.
Conformance testing
Testing for compliance verifies whether the actual result of performing a function matches the expected result or not. I will show with an example - imagine that there is an application that displays a list of news for the day:

')
Data about news is collected from various sources and at the exit from the business layer are transformed into the following model:
data class News( val text: String, val date: Long )
According to the application logic, for each element of the list a model of the following type is required:
data class NewsViewData( val id: String, val title: String, val description: String, val date: String )
The following class will be responsible for the transformation of the
domain model to the
view model:
class NewsMapper { fun mapToNewsViewData(news: List<News>): List<NewsViewData> { return mutableListOf<NewsViewData>().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) ) ) } } } }
So 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 with the input data.
To do this, in the app / src / test / ... folder, create a
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 obtained result is compared to the correspondence to the expectation using the methods from the
org.junit.Assert package. If any value does not match the waiting, the test will end with an error.
There are cases when the constructor of the class under test accepts any dependencies. It can be either a simple ResourceManager for accessing resources, or a full-fledged
Interactor for executing business logic. You can create an instance of a similar 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 calls to internal methods and override return values.
To create a mock there is a popular framework
Mockito .
In the Kotlin language, all default classes are final, so you cannot create mock objects on Mockito from scratch. To circumvent this limitation, it is recommended to add the
mockito-inline dependency.
If kotlin dsl is used when writing tests, then you can use different libraries, like
Mockito-Kotlin .
Suppose that
NewsMapper accepts a kind of
NewsRepo as a dependency, in which information about the user viewing a particular newsletter is recorded. Then it is reasonable to make a mock for
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 different variants of the returned values to test different test cases.
In addition to the examples above, conformance testing includes various data validators. For example, a method that checks the password entered for the presence of special characters and the minimum length.
Testing for a call
Testing for a call verifies whether a method of one class needs the necessary methods of another class or not. Most often, such testing is applied to
Presenter , which sends View specific commands for state changes. Let's return to the example with the list of news:
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 fact of calling 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()) } }
To exclude platform dependencies from tests, different solutions may be required, since it all depends on multi-threading technologies. The example above uses Kotlin Coroutines with a redefined scope to run tests, because used in the program code Dispatchers.Main refers to the UI android thread, which is unacceptable in this type of testing. When using RxJava, other solutions will be needed, for example, creating a TestRule that switches the flow of code execution.
To check whether a method is called, the verify method is used, which can take as additional arguments methods indicating the number of calls to the method being checked.
*****
These test options can cover a fairly large percentage of the code, making the application more stable and predictable. The code covered with tests is easier to maintain, easier to scale, because there is a certain amount of confidence that adding a new functionality will not break anything. And of course this code is easier to refactor.
The easiest to test class does not contain platform dependencies, because when working with it, no third-party solutions are needed to create platform mock objects. Therefore, our projects use an architecture that allows minimizing the use of platform dependencies in the layer under test.
Good code should be testable. The difficulty or impossibility of writing unit tests usually shows that there is something wrong with the code under test and it is time to think about refactoring.
The source code for the sample is available on
GitHub .