Привет, Habr. Меня зовут Илья Смирнов, я Android-разработчик в компании FINCH. Хочу показать вам несколько примеров работы с Unit-тестами, которые мы наработали у себя в команде.
В наших проектах используется два вида Unit-тестов: проверка на соответствие и проверка на вызов. Остановимся на каждом из них более подробно.
Тестирование на соответствие проверяет соответствует фактический результат выполнения какой-то функции ожидаемому результату или нет. Покажу на примере — представим, что есть приложение, которое выводит список новостей за день:

Данные о новости забираются из разных источников и на выходе из бизнес-слоя превращаются в следующую модель:
Согласно логике приложения, для каждого элемента списка требуется модель следующего вида:
За преобразование domain-модели к view-модели будет отвечать следующий класс:
Таким образом, мы знаем, что некий объект
Будет преобразован в некий объект
Входные и выходные данные известны, а значит можно написать тест на метод mapToNewsViewData, который будет проверять соответствие выходных данных в зависимости от входных.
Для этого в папке app/src/test/… создадим класс NewsMapperTest следующего содержания:
Полученный результат сравниваем на соответствие ожиданию при помощи методов из пакета 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.
Таким образом, mock-объект позволяет смоделировать различные варианты возвращаемых значений для проверки различных тест-кейсов.
Помимо примеров выше, тестирование на соответствие включает в себя различные валидаторы данных. К примеру, метод, проверяющий введённый пароль на наличие спецсимволов и минимальную длину.
Тестирование на вызов проверяет вызывает метод одного класса необходимые методы другого класса или нет. Чаще всего такое тестирование применяется к Presenter, который отправляет View конкретные команды на изменение состояния. Вернемся к примеру со списком новостей:
Здесь самое важное — сам факт вызова методов у Interactor и View. Тест будет выглядеть следующим образом:
Для исключения из тестов платформенных зависимостей могут потребоваться разные решения, т.к. все зависит от технологий для работы с многопоточностью. В примере выше используются Kotlin Coroutines с переопределенным scope для запуска тестов, т.к. используемый в программном коде Dispatchers.Main обращается к UI потоку android, что недопустимо в данном виде тестирования. При использовании RxJava потребуются другие решения, например, создание TestRule, переключающего поток выполнения кода.
Для проверки на факт вызова какого-либо метода используется метод verify, который может принимать в качестве дополнительных аргументов методы, указывающие количество вызовов проверяемого метода.
Рассмотренные варианты тестирования способны покрыть довольно большой процент кода, сделав приложение более стабильным и предсказуемым. Код, покрытый тестами, легче поддерживать, легче масштабировать, т.к. есть определенная доля уверенности, что при добавлении нового функционала ничего не сломается. Ну и конечно такой код проще рефакторить.
Самый легкий для тестирования класс не содержит в себе платформенных зависимостей, т.к. при работе с ним не нужны сторонние решения для создания платформенных mock-объектов. Поэтому в наших проектах используется архитектура, позволяющая максимально минимизировать использование платформенных зависимостей в тестируемом слое.
Хороший код должен быть тестируемым. Cложность или невозможность написания unit тестов обычно показывает, что с тестируемым кодом что-то не так и пора задуматься о рефакторинге.
Исходный код примера доступен на GitHub.
В наших проектах используется два вида 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<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) ) ) } } } }
Таким образом, мы знаем, что некий объект
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.
