[Из песочницы] Как при помощи 2 видов unit-тестов сделать приложение более стабильным
Привет, Habr. Меня зовут Илья Смирнов, я Android-разработчик в компании FINCH. Хочу показать вам несколько примеров работы с Unit-тестами, которые мы наработали у себя в команде.
В наших проектах используется два вида Unit-тестов: проверка на соответствие и проверка на вызов. Остановимся на каждом из них более подробно.
Тестирование на соответствие
Тестирование на соответствие проверяет соответствует фактический результат выполнения какой-то функции ожидаемому результату или нет. Покажу на примере — представим, что есть приложение, которое выводит список новостей за день:
Данные о новости забираются из разных источников и на выходе из бизнес-слоя превращаются в следующую модель:
data class News(
val text: String,
val date: Long
)
Согласно логике приложения, для каждого элемента списка требуется модель следующего вида:
data class NewsViewData(
val id: String,
val title: String,
val description: String,
val date: String
)
За преобразование domain-модели к view-модели будет отвечать следующий класс:
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)
)
)
}
}
}
}
Таким образом, мы знаем, что некий объект
News(
"Super News. Some description and bla bla bla",
1551637424401
)
Будет преобразован в некий объект
NewsViewData(
"1551637424401",
"Super News",
"Some description and bla bla bla",
"2019-03-03 21:23"
)
Входные и выходные данные известны, а значит можно написать тест на метод mapToNewsViewData, который будет проверять соответствие выходных данных в зависимости от входных.
Для этого в папке app/src/test/… создадим класс NewsMapperTest следующего содержания:
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")
}
}
}
Полученный результат сравниваем на соответствие ожиданию при помощи методов из пакета org.junit.Assert. Если какое-либо значение не будет соответствовать ожиданию, то тест завершится с ошибкой.
Бывают случаи, когда конструктор тестируемого класса принимает в себя какие-либо зависимости. Это могут быть как простые ResourceManager для доступа к ресурсам, так и полноценные Interactor для выполнения бизнес-логики. Можно создать экземпляр подобной зависимости, но лучше сделать подобный mock-объект. Mock-объект предоставляет фиктивную реализацию какого-либо класса, с помощью которой можно отслеживать вызов внутренних методов и переопределять возвращаемые значения.
Для создания mock существует популярный framework Mockito.
В языке Kotlin все классы по умолчанию являются final, поэтому нельзя на пустом месте создавать mock-объекты на Mockito. Для обхода этого ограничения рекомендуется добавить зависимость mockito-inline.
Если при написании тестов используется kotlin dsl, то можно использовать различные библиотеки, вроде Mockito-Kotlin.
Допустим, что NewsMapper принимает в виде зависимости некий NewsRepo, в который записывается информация о просмотре пользователем конкретной новости. Тогда разумно сделать mock для NewsRepo и проверить возвращаемые значения метода mapToNewsViewData в зависимости от результата 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)
...
}
…
}
Таким образом, mock-объект позволяет смоделировать различные варианты возвращаемых значений для проверки различных тест-кейсов.
Помимо примеров выше, тестирование на соответствие включает в себя различные валидаторы данных. К примеру, метод, проверяющий введённый пароль на наличие спецсимволов и минимальную длину.
Тестирование на вызов
Тестирование на вызов проверяет вызывает метод одного класса необходимые методы другого класса или нет. Чаще всего такое тестирование применяется к Presenter, который отправляет View конкретные команды на изменение состояния. Вернемся к примеру со списком новостей:
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)
}
}
…
}
Здесь самое важное — сам факт вызова методов у Interactor и View. Тест будет выглядеть следующим образом:
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())
}
}
Для исключения из тестов платформенных зависимостей могут потребоваться разные решения, т.к. все зависит от технологий для работы с многопоточностью. В примере выше используются Kotlin Coroutines с переопределенным scope для запуска тестов, т.к. используемый в программном коде Dispatchers.Main обращается к UI потоку android, что недопустимо в данном виде тестирования. При использовании RxJava потребуются другие решения, например, создание TestRule, переключающего поток выполнения кода.
Для проверки на факт вызова какого-либо метода используется метод verify, который может принимать в качестве дополнительных аргументов методы, указывающие количество вызовов проверяемого метода.
*****
Рассмотренные варианты тестирования способны покрыть довольно большой процент кода, сделав приложение более стабильным и предсказуемым. Код, покрытый тестами, легче поддерживать, легче масштабировать, т.к. есть определенная доля уверенности, что при добавлении нового функционала ничего не сломается. Ну и конечно такой код проще рефакторить.
Самый легкий для тестирования класс не содержит в себе платформенных зависимостей, т.к. при работе с ним не нужны сторонние решения для создания платформенных mock-объектов. Поэтому в наших проектах используется архитектура, позволяющая максимально минимизировать использование платформенных зависимостей в тестируемом слое.
Хороший код должен быть тестируемым. Cложность или невозможность написания unit тестов обычно показывает, что с тестируемым кодом что-то не так и пора задуматься о рефакторинге.
Исходный код примера доступен на GitHub.