[Из песочницы] Как при помощи 2 видов unit-тестов сделать приложение более стабильным

Привет, Habr. Меня зовут Илья Смирнов, я Android-разработчик в компании FINCH. Хочу показать вам несколько примеров работы с Unit-тестами, которые мы наработали у себя в команде.

В наших проектах используется два вида Unit-тестов: проверка на соответствие и проверка на вызов. Остановимся на каждом из них более подробно.

Тестирование на соответствие


Тестирование на соответствие проверяет соответствует фактический результат выполнения какой-то функции ожидаемому результату или нет. Покажу на примере — представим, что есть приложение, которое выводит список новостей за день:

tztc4tfga9z7qodtptwi2mj-bgq.png

Данные о новости забираются из разных источников и на выходе из бизнес-слоя превращаются в следующую модель:

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.

© Habrahabr.ru