Как при помощи 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<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.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 7

    0

    Добрый день. Спасибо за статью!
    Есть небольшие замечания по коду, напрямую не относящиеся к содержанию статьи.
    В методе mapToNewsViewData(news: List<News>) на каждой итерации создается объект Regex и DateFormat, хотя по сути они для всех одинаковы. В дополнение к этому заполнение итогового списка выглядело бы лучше (читабильнее) при использовании stream подхода. Например, так:


    class NewsMapper {
        companion object {
            private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale("ru"))
            private val regex = "\\.".toRegex()
        }
    
        fun mapToNewsViewData(news: List<News>): List<NewsViewData> {
            return news.asSequence()
                .map {
                        val textSplits = it.text.split(regex)              
                        NewsViewData(
                            id = it.date.toString(),
                            title = textSplits[0],
                            description = textSplits[1].trim(),
                            date = dateFormat.format(it.date)
                        )
                }
                .toList()
        }
    } 
      0
      Спасибо за комментарий! Согласен, так действительно лучше и правильнее. Подчистить можно много чего еще, но для примера не сильно критично на мой взгляд.
      0
      В одном тесте делать несколько assert не рекомендуется
        0
        Да, есть такая рекомендация и так тоже иногда делаем. Скорее, это дело вкуса и конкретного случая
          0

          Не совсем так. В одном тесте нельзя делать несколько "act" (из парадигмы arrange-act-assert). А вот "arrange" и "assert" вполне может быть несколько.

          0
          Можно еще тест mapToNewsViewData сделать параметризованным, чтобы не на hardcoded значениях проверять, а на разных, в том числе замысловатых.
          А там и до property based tests недалеко.
            0

            Хорошая идея. Ещё можно попробовать для входных значений использовать random, хотя некоторые источники утверждают, что так лучше не делать. От случая зависит опять же

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое