Пишем действительно тестируемый код

в 13:00, , рубрики: mobius, архитектура, Блог компании JUG.ru Group, тестирование, Тестирование IT-систем, Тестирование веб-сервисов, Тестирование игр, Тестирование мобильных приложений

Что такое тестируемый код? Каких правил стоит придерживаться для его написания? Как начать писать такой код, если кодовая база к этому не готова?

Статья с большим количеством примеров кода и иллюстраций, в основе которой – выступление Антона на конференции Mobius 2017 в Питере. Антон является разработчиком Android-приложений в Juno, и в своей работе затрагивает множество смежных технологий. Этот доклад не об Android и не о Kotlin, он о тестировании в целом, об идеях, которые лежат над платформой и над языком и которые могут быть адаптированы к любому контексту.

Зачем нужны тесты?

Для начала стоит определиться с тем, зачем мы пишем или хотим писать тесты для своего кода. Причин может быть несколько:

  • Чтобы получить доверие к коду;
  • Для составления документации;
  • Чтобы спать спокойно после рефакторинга;
  • Чтобы писать код быстрее;
  • Чтобы хвастаться перед коллегами.

И, возможно, самая важная причина – для того, чтобы проект мог долго жить и развиваться (то есть изменяться). Под развитием имеется в виду добавление новых фич, исправление ошибок, рефакторинг.

Когда наша кодовая база растет, шансы сделать ошибку повышаются, потому что база становится более сложной. А когда она выходит в продакшн, цена ошибки увеличивается. В результате нередко возникает страх модификаций, с которым очень сложно бороться.

Вот две глобальные задачи, которые мы решаем, когда пишем долгоживущий проект:

  • Управление сложностью системы, то есть как сделать систему максимально простой для заданных бизнес-требований;
  • Тестирование системы (сегодня мы говорим именно об этом).

Что такое тестируемый код?

Что же может пойти не так при попытке написания теста? Часто система просто бывает к этому не готова. Она может быть настолько связана с соседними частями, что мы не можем задать какие-то входные параметры, чтобы проверить, что все работает корректно.

Пишем действительно тестируемый код - 1

Чтобы избежать таких ситуаций нужно писать код правильно, то есть делать его тестируемым.

Что такое тестируемый код? Чтобы ответить на этот вопрос, нужно для начала разобраться в том, что такое тест. Скажем, есть система, которую надо протестировать (SUT – System Under Test). Тестирование – это передача неких входных данных и валидация результатов на соответствие ожидаемым. Тестируемый код означает, что у нас есть полный контроль над входными и выходными параметрами.

Пишем действительно тестируемый код - 2

Три правила написания тестируемого кода

Чтобы сделать код тестируемым, важно придерживаться трех правил. Давайте рассмотрим каждое из них подробно на примерах.

Правило 1. Передавать аргументы и возвращаемые значения явно

Посмотрим на тестирование функции (некой функции в вакууме, которая принимает N аргументов и возвращает какое-то количество значений):

f(Arg[1], ... , Arg[N]) -˃ (R[1], ... , R[L])

И есть функция, которая не является чистой:

fun nextItemDescription(prefix: String): String {
    GLOBAL_VARIABLE++
    return "$prefix: $GLOBAL_VARIABLE"
}

Рассмотрим, какие здесь входы. Во-первых, префикс, который передается как аргумент. Также входом является значение глобальной переменной, потому что оно тоже влияет на результат выполнения функции. Результатом функции является возвращаемое значение (строка), а также увеличение глобальной переменной. Это выход.

Схематически это выглядит так, как на рисунке ниже.

Пишем действительно тестируемый код - 3

У нас есть входы (явный и неявные) и выходы (явный и неявные). Чтобы из такой функции сделать чистую функцию, нужно убрать неявные входы и выходы. В этом случае она контролируемо тестируется. Например, так:

fun ItemDescription(prefix: String, itemIndex: Int): String {
    return "$prefix: $itemIndex"
}

Иными словами, функцию просто протестировать, если все ее входы и выходы переданы явно, то есть через список аргументов и возвращаемые значения.

Правило 2. Передавать зависимости явно

Для того чтобы понять второе правило, предлагаю подумать о модуле как о функции. Предположим, модуль – это функция, вызов которой растянут во времени, то есть часть входных параметров переданы в какой-то момент времени, часть – в следующей строчке, часть – через какой-то тайм-аут, потом еще какая-то часть и т.д. И то же самое с выходами: часть сейчас, часть – чуть позже и т.д.

M(In[1], ... , In[N]) -˃ (Out[1], ... , Out[L])

Как могли бы выглядеть входы и выходы такой функции-модуля? Давайте попробуем сначала посмотреть на код, а потом сделаем более общую картину:

class Module(
        val title: String //input
){}

Сам факт вызова конструктора такого класса является входом нашей функции, и передача строки на выход, очевидно, тоже является входом. Факт вызова какого-то метода нашего класса тоже будет являться входом функции, потому что от того, вызвали метод или нет, будет зависеть наш результат.

class Module(
        val title: String // input
){
    fun doSomething() { // input
        // …
    }
}

Получение какого-то значения из явной зависимости тоже является входом.  Я называю зависимость явной, если она до использования была передана через API модуля.

class Module(
        val title: String // input
        val dependency: Explicit // dependency
){
    fun doSomething() { // input
        val explicit = dependency.getCurrentState() //input
        // …
    }
}

Получение какого-то входа из неявной зависимости – это тоже инпут.

class Module(
        val title: String // input
        val dependency: Explicit // dependency
){
    fun doSomething() { // input
        val explicit = dependency.getCurrentState() //input
        val implicit = Implicit.getCurrentState() //input

        // …
    }
}

Перейдем к выходам. Возвращение некоего значения из поля – это выход. Модификация этого значения является выходом функции, так как мы можем потом проверить ее извне.

class Module(
){
    var state = "Some state"

    fun doSomething() {
        state = "New state" // output
        // …
    }
}

Модификация какого-то внешнего состояния тоже является выходом. Она может быть явной, как здесь:

class Module(
        val dependency: Explicit // dependency
){
    var state = "Some State"

    fun doSomething() {
        state = "New State" // output

        dependency.setCurrentState("New state") //output
        // …
    }
}

Или неявным, как здесь:

class Module(
        val dependency: Explicit // dependency
){
    var state = "Some state"

    fun doSomething() {
        state = "New state" // output

        dependency.setCurrentState("New state") //output
        Implicit.setCurrentState("New state") //input
        // …
    }
}

Теперь давайте обобщим.

In[1], … , In[N]

Входами такой функции-модуля могут быть:

  • Взаимодействия с API модуля и API его зависимостей;
  • Значения, которые мы в них передали;
  • Порядок, в котором мы делали эти взаимодействия;
  • Время между этими взаимодействиями.

Примерно то же с выходами:

Out[1], … , Out[N]

Выходами функции-модуля могут быть:

  • Взаимодействия с API модуля и API его зависимостей;
  • Значения, которые мы в них передали;
  • Порядок, в котором мы делали эти взаимодействия;
  • Время между этими взаимодействиями;
  • Модификация некоего состояния модуля, которое затем можно пронаблюдать, получить извне.

Если мы таким способом определяем модуль, то мы видим, что процесс тестирования модуля, то есть написанный на этот модуль тест, является вызовом этой функции и валидации результатов. То есть то, что мы пишем в блоках given и when (если мы используем given и when-аннотацию), – это процесс вызова функций, а then – процесс валидации результатов.

Пишем действительно тестируемый код - 4

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

M(In[1], ... , In[N]) -˃ (Out[1], ... , Out[L])

Правило 3. Контролировать заменяемость зависимостей в тестах

Даже при наличии явных аргументов и явных зависимостей мы все равно не получаем полный контроль, и вот почему.

Например, в модуле есть явная зависимость. Модуль не делает ничего, кроме умножения ее на три и записи в какое-то поле.

class Module(explicit: Explicit) {
    val tripled = 3 * explicit.getValue()
}

Пишем на это тест:

class Module(explicit: Explicit) {
    val tripled = 3 * explicit.getValue()
}
@Test
fun testValueGetsTripled() {

}

Как-то подготавливаем наш модуль, берем у него значение поля Tripled, записываем в результат, ожидаем, что он будет 15, и проверяем, что 15 равняется результату:

class Module(explicit: Explicit) {
    val tripled = 3 * explicit.getValue()
}
@Test
fun testValueGetsTripled() {
    // prepare Explicit dependency
    val result = Module( ??? ).tripled
    val expected = 15
    assertThat(result).isEqualTo(expected)
}

Самый большой вопрос в том, как мы подготовим нашу явную зависимость для того, чтобы сказать, что она возвращает пятерку и нам нужно в результате получить 15? Это сильно зависит от того, чем является явная зависимость.

Если явная зависимость – это синглтон, то в тестах мы ему не можем сказать: «Возвращай пятерку!», потому что код уже написан, и код в тестах мы модифицировать не можем.

// 'object' stands for Singleton in Kotlin
object Explicit {
    fun getValue(): Int = ...
}

Соответственно, тест у нас не работает – мы не можем передать туда нормальную зависимость.

// 'object' stands for Singleton in Kotlin
object Explicit {
    fun getValue(): Int = ...
}
@Test
fun testValueGetsTripled() {
    val result = Module( ??? ).tripled
    val expected = 15
    assertThat(result).isEqualTo(expected)
}

То же самое с final-классами – мы не можем модифицировать их поведение.

// Classes are final by default in Kotlin
Class Explicit {
    fun getValue(): Int = ...
}
@Test
fun testValueGetsTripled() {
    val result = Module( ??? ).tripled
    val expected = 15
    assertThat(result).isEqualTo(expected)
}

Последний и хороший случай, когда явная зависимость – это интерфейс, который имеет какую-то реализацию:

interface Explicit {
    fun getValue(): Int

    class Impl: Explicit {
        override fun getValue(): Int = ...
    }
}

Тогда мы можем уже в тесте подготовить этот интерфейс, создать тестовую реализацию, которая будет возвращать пятерку и, наконец, передать его в наш класс-модуль и выполнить тест.

@Test
fun testValueGetsTripled() {
    val mockedExplicit = object : Explicit {
        override fun getValue(): Int = 5
    }

    val result = Module(mockedExplicit).tripled
    val expected = 15
    assertThat(result).isEqualTo(expected)
}

Иногда функции являются приватными, и тут нужно посмотреть, что такое приватная реализация, и убедиться, что в ней нет неявных зависимостей, что там ничего не приходит из синглтонов, еще из каких-то неявных мест. И тогда в принципе не должно быть проблем с тем, чтобы протестировать код через публичный API. То есть если публичный API полностью описывает входы и выходы (других нет), то публичного API хватает де-факто.

Три правила написания тестируемого кода на практике

Мне сложно представить тестируемый код без какой-то архитектуры, поэтому как пример буду использовать MVP. Тут есть пользовательский интерфейс, слой View, модели, где условно собрана бизнес-логика, прослойка платформенных адаптеров (предназначены для изоляции моделей от API), а также платформенные API и third-party API, которые не связаны с пользовательским интерфейсом.

Пишем действительно тестируемый код - 5

Тестируем мы здесь Model и Presenter, потому что они полностью изолированы от платформы.

Как выглядят явные входы и выходы

У нас есть класс и самые разные явные входы: строка на вход, лямбда, Observable, вызов метода, а также все то же самое, сделанное через явную зависимость.

class ModuleInputs(
        input: String,
        inputLambda: () -> String,
        inputObservable: Observable<String>,
        dependency: Explicit
) {
    private val someField = dependency.getInput()

    fun passInput(input: String) { }
}

Очень похожа ситуация с выходами. Выходом может быть возвращение какого-то значения из метода, возвращение какого-то значения через лямбду, через Observable и через явную зависимость:

class ModuleOutputs(
        outputLambda: (String) -> Unit,
        dependency: Explicit
) {
    val outputObservable = Observable.just("Output")
    fun getOutput(): String = "Output"

    init{
        outputLambda("Output")
        dependency.passOutput("Output")
    }
}

Как выглядят неявные входы и выходы и как их преобразовать в явные

Неявными входами и выходами могут быть:

  1. Синглтоны
  2. Генераторы случайных чисел
  3. Файловая система и другие средства хранения
  4. Время
  5. Форматирование и локали

Теперь о каждом из них подробнее.

Синглтоны

Мы не можем модифицировать поведение синглтонов в тестах.

class Module {
    private val state = Implicit.getCurrentState()
}

Поэтому их нужно выносить как явную зависимость:

class Module (dependency: Explicit){
    private val state = dependency.getCurrentState()
}

Генераторы случайных чисел

В примере ниже мы не вызываем синглтон, а создаем объект класса random. А вот он у себя уже внутри дергает какие-то статические методы, на которые мы никак повлиять не можем (например, текущее время).

class Module {
    private val fileName = "some-file${Random().nextInt()}"
}

Поэтому такие сервисы, которые мы не контролируем, есть смысл выносить за интерфейсы, которые мы могли бы контролировать.

class Module(rng: Rng) {
    private val fileName = "some-file${Random(rng.nextInt()}"
}

Файловая система и другие средства хранения

У нас есть некий модуль, который инициализирует storage. Все, что он делает – создает файл по какому-то пути.

class Module {
    fun initStorage(path: String) {
        File(path).createNewFile()
    }
}

Но этот API очень коварный: он при успехе возвращает true, а при наличии такого же файла — false. А нам, к примеру, нужно не просто создать файл, а еще понять: создался он или нет, и если нет, то по какой причине. Соответственно, мы создаем типизированную ошибку и хотим вернуть ее на выход. Или же, если ошибки нет, то вернуть null.

class Module {
    fun initStorage(path: String): FileCreationError? {
        return if (File(path).createNewFile()) {
            null
        } else {
            FileCreationError.Exists
        }
    }
}

Кроме этого, этот API кидает два исключения. Мы их кетчим и, опять же, возвращаем типизированные ошибки.

class Module {
    fun initStorage(path: String): FileCreationError? = try {
        if (File(path).createNewFile()) {
            null
        } else {
            FileCreationError.Exists
        }
    } catch (e: SecurityException) {
        FileCreationError.Security(e)
    } catch (e: Exception) {
        FileCreationError.Other(e)
    }
}

Ок, обработали. А теперь хотим протестировать. Проблема в том, что создание файла – это штука, которая имеет побочные эффекты (то есть создает файл в файловой системе). Поэтому нам нужно или как-то подготавливать файловую систему, или выносить все, что имеет побочные эффекты, за интерфейсы.

class Module (private val fileCreator: FileCreator){
    fun initStorage(path: String): FileCreationError? = try {
        if (fileCreator.createNewFile(path)) {
            null
        } else {
            FileCreationError.Exists
        }
    } catch (e: SecurityException) {
        FileCreationError.Security(e)
    } catch (e: Exception) {
        FileCreationError.Other(e)
    }
}

Время

Не сразу очевидно, что это вход, но мы уже выше разобрались, что это так.

class Module {
    private val nowTime = System.current.TimeMillis()
    private val nowDate = Date()
    // and all other time/date APIs
}

К примеру, в модуле есть логика, которая ждет полминуты. Если вы планируете писать на это тест, вы не хотите, чтобы тест ждал полминуты, потому что все юнит-тесты должны проходить в целом за полминуты. Мы хотим иметь возможность контролировать время, поэтому, опять же, всю работу со временем имеет смысл вынести за интерфейс, чтобы это была одна точка в системе, и вы понимали бы, как вы работаете со временем и при необходимости могли крутить время вперед или даже назад. Тогда вы, скажем, сможете протестировать, как ваш модуль поведет себя, если переведут часы.

class Module (time: TimeProvider) {
    private val nowTime = time.nowMillis()
    private val nowDate = time.nowDate()
    // and all other time/date APIs
}

Форматирование и локали

Это самый коварный неявный вход. Скажем, обычный Presenter берет какую-то метку времени, форматирует ее по строго заданному шаблону (никаких AM или PM, никаких запятых, все вроде бы задано) и сохраняет в поле:

class MyTimePresenter(timestamp: Long) {
    val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp)
}

Пишем на это тест. Мы не хотим думать о том, чему это равняется в отформатированном виде, нам проще запустить на это модуль, посмотреть, что он нам выведет, и записать сюда.

class MyTimePresenter(timestamp: Long) {
    val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm").format(timestamp)
}

fun test() {
    val mobiusConfStart = 1492758000L
    val expected = ""
    val actual = MyTimePresenter(timestamp).formattedTimeStamp
    assertThat(actual).isEqualTo(expected)
}

Посмотрели, что Mobius начинается 21 апреля в 10 часов:

class MyTimePresenter(timestamp: Long) {
    val formattedTimestamp = SimpleDateFormat("yyyy-MM-dd HH:mm")
            .format(timestamp)
}

fun test() {
    val mobiusConfStart = 1492758000L
    val expected = "2017-04-21 10:00"
    val actual = MyTimePresenter(timestamp).formattedTimeStamp
    assertThat(actual).isEqualTo(expected)
}

Ок, запускаем это на локальной машине, все работает:

>> `actual on dev machine` = "2017-04-21 10:00" // UTC +3

Запускаем это на CI, а там почему-то Mobius начинается в 7.

>> `actual on CI` = "2017-04-21 07:00" // UTC

На CI другой часовой пояс. Он в часовом поясе UTC+0 и, соответственно, там время отформатируется по-другому, потому что SimpleDateFormat использует часовой пояс, установленный по умолчанию. В тестах мы это не переопределили, соответственно, в CI-серверах, которые находятся в нуле по Гринвичу, у нас другой выход. И этим коварны все входы, которые связаны с местоположением, в том числе:

  • Валюта
  • Формат чисел
  • Часовой пояс
  • Локали

Как мокировать зависимости в тестах

Говорят, что «серебряной пули нет», но мне кажется, что относительно мокирования она есть. Потому что интерфейсы работают везде. Если вы спрятали свою реализацию за интерфейсом, вы можете быть уверены в том, что в тестах вы сможете ее заменить, потому что интерфейсы заменяются.

interface MyService {
    fun doSomething()
    class Impl(): MyService {
        override fun doSomething() { /* ... */ }
    }

}

class TestService: MyService {
    override fun doSomething() { /* ... */ }
}

val mockService = mock<MYService>()

Интерфейсы даже иногда помогают что-то сделать с синглтонами. Скажем, в проекте есть синглтон, который GodObject. Вы не можете разобрать его на несколько отдельных модулей сразу, но хотите потихоньку внедрять какой-то DI, какую-то тестируемость. Для этого вы можете завести интерфейс, который будет повторять публичный API этого синглтона либо часть публичного API и сделать так, чтобы синглтон реализовывал этот интерфейс. И вместо того, чтобы использовать в модуле сам синглтон, вы можете передавать как явную зависимость уже этот интерфейс. Снаружи это будет, конечно, передача того же самого GetInstance, но внутри вы уже будете работать с чистым интерфейсом. Это может быть промежуточным шагом, пока все не перешло на модули и DI.

interface StateProvider {
    fun getCurrentState(): String
}
object Implicit: StateProvider {
    override fun getCurrentState(): String = "State"

}

class SomeModule(stateProvider: StateProvider) {
    init {
        val state = StateProvider.getCurrentState()
    }
}

Есть, конечно, и другие альтернативы. Я выше говорил, что нельзя мокировать final-классы, static-методы, синглтоны. Их мокировать, конечно, можно: есть Mockito2 для final-классов, есть PowerMоck для final-классов, static-методов, синглтонов, но с ними есть ряд проблем:

  • Они чаще всего сигнализируют о проблемах с дизайном (это касается, в основном, PowerMоck)
  • Они могут перестать работать в какой-то момент, например, на 1501 тесте, поэтому лучше сразу думать об архитектуре, пригодной для тестирования, и не использовать такие фреймворки.

Абстрагирование от платформы и зачем это нужно

Абстрагирование происходит на слое View и на прослойке платформенных адаптеров.

Пишем действительно тестируемый код - 6

Абстрагирование на слое View

Слой View – это изоляция UI-фрейморка от модуля Presenter. Тут существует два основных подхода:

  • Когда Activity реализует сам интерфейс View;
  • Когда View является отдельным классом.

Давайте сначала посмотрим на первый вариант: Activity реализует View. Тогда у нас есть некий тривиальный Presenter, который принимает на вход интерфейс вьюшки и вызывает у нее какой-то метод.

class SplashPresenter(view: SplashView) {
    init {
        view.showLoading()
    }
}

У нас есть тривиальный интерфейс View:

interface SplashView {
    fun showLoading()
}

И у нас есть Activity, в которой есть вход в виде метода onCreate, и есть реализация интерфейса SplashView, которая уже реализует непосредственно платформенным способом то, что ей нужно сделать, например, отобразить какой-то прогресс.

interface SplashView {
    fun showLoading()
}

class SplashActivity: Activity, SplashView {
    override fun onCreate() {

    }
    override fun showLoading() {
        findViewById(R.id.progress).show()
    }
}

Соответственно, Presenter мы делаем следующим образом: создаем в OnCreate и в качестве View передаем this. Так многие делают, вполне валидный вариант:

interface SplashView {
    fun showLoading()
}

class SplashActivity: Activity, SplashView {
    override fun onCreate() {
        SplashPresenter(view = this)
    }
    override fun showLoading() {
        findViewById(R.id.progress).show()
    }
}

Есть второй вариант – View как отдельный класс. Тут Presenter точно такой же, интерфейс точно такой же, но реализация является отдельным классом, не связанным с Activity.

class SplashPresenter(view: SplashView) {
    init {
        view.showLoading()
    }

    interface SplashView {
        fun showLoading()
        class Impl : SplashView {
            override fun showLoading() {
            }
        }
    }
}

Соответственно, чтобы он мог работать с платформенными компонентами, ему на вход передается платформенная вьюшка. И там он уже делает все, что нужно.

interface SplashView {
    fun showLoading()
    class Impl(private val viewRoot: View) : SplashView {
        override fun showLoading() {
            viewRoot.findViewById(R.id.progress).show()
        }
    }
}

В этом случае Activity немного разгружается. То есть, вместо того чтобы организовывать интерфейс в ней, остается получение этой платформенной вьюшки и создание SplashPresenter, где в качестве View создается отдельный класс.

class SplashActivity: Activity, SplashView {
    override fun onCreate() {
    // Platform View class
        val rootView: View = ...

        SplashPresenter(
                view = SplashView.Impl(rootView)
        )
    }
}

На самом деле с точки зрения тестирования два этих подхода одинаковы, потому что мы все равно работаем от интерфейса. Мы создаем мокированное View, создаем Presenter, в который его передаем, и проверяем, чтобы был вызван некий метод.

@Test
fun testLoadingIsShown() {
    val mockedView = mock<SplashView>()
    SplashPresenter(mockedView)
    verify (mockedView).showLoading()
}

Разница только в том, как вы смотрите на роли Activity и View. Если вам кажется, что роль View достаточно большая, чтобы не смешивать ее с другими ролями Activity, тогда вынести ее в отдельный класс – это хорошая идея.

Абстрагирование на слое Platform Wrappers

Теперь то, что касается абстрагирования от платформы на слое платформенных адаптеров. Платформенные обертки – это изоляция слоя Модели. Проблема в том, что за этим слоем на стороне платформы стоит платформенный API и API третьей стороны, и мы их в общем случае не можем модифицировать, потому что они приходят в разных формах. Они могут приходить как статические методы, как синглтоны, как final-классы и как не final-классы. В первых трех случаях мы не можем влиять на их реализацию, мы не можем подменить их поведение в тестах. И только если они являются не final-классами, мы можем как-то повлиять на их поведение в тестах.

Поэтому вместо того, чтобы использовать такие API напрямую, может иметь смысл создать обертку. Вот здесь используется API напрямую:

class Module {
    init {
        ThirdParty.doSomething()
    }
}

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

interface Wrapper {
    fun doSomething()
    class Impl: Wrapper {
        override fun doSomething() {
            ThirdParty.doSomething()      
        }  
    }
}

Мы получили обертку с реализацией, спрятали ее за интерфейсом и, соответственно, в модуле уже вызываем Wrapper, который приходит как явная зависимость.

class Module(wrapper: Wrapper) {
    init {
        wrapper.doSomething()
    }
}

Помимо гарантированной тестируемости это дает следующее:

  • Возможность использовать удобный дизайн вместо привязки к дизайну платформенных API;
  • Уменьшение сложности входного параметра (Single Responsibility вместо God Object);
  • Более легкая смена API в случае необходимости (без переписывания всей кодовой базы).

Множественные статические вызовы могут быть Design Smell, но это сильно зависит от того, что в этих статических вызовах. Мы подразумеваем, что статические вызовы – это чистые функции. Если они меняют глобальные переменные, то это Smell. Если в них не происходит ничего гиперсложного и вы готовы функциональность этого статического метода покрывать в каждом месте его использования тестами на весь модуль, где он вызывается, то это не Smell, а нахождение баланса. А от правил можно и нужно отступать.

Доступ к ресурсам

В Android есть ID-шники строк и других ресурсов. Иногда в презентерах или в других местах нам нужно иметь доступ к чему-то, что зависит от платформы. Вопрос в том, как это абстрагировать, ведь R-класс приходит из фреймворка.

class SplashPresenter(view: SplashView, resources: Resources) {
    init {
        view.setTitle(resources.getString(R.string.welcome))
        view.showLoading()
    }
}

Ресурсы – это уже наш интерфейс, это не интерфейс Android, но мы в него передаем все тот же самый эндовый ID-шник. И вот заметьте, что, по сути, это просто эндовый айдишник:

class SplashPresenter(view: SplashView, resources: Resources) {
    init {
        view.setTitle(resources.getString(R.string.welcome))
        view.showLoading()
    }
}
interface Resources {
    fun getString(id: Int): String
}

И тут уже вопросы вкуса, достаточно ли вам того, что пришел этот ID, чтобы проверить, что все ведет себя корректно. Нам обычно бывает достаточно.

public final class R {
    public static final class string {
        public static final int welcome=0x7f050000;
    }
}

На мой взгляд, имеет смысл более глубоко работать с этим, только если вы делаете какую-то кроссплатформенную логику. Там механизм доступа к ресурсам будет разным для iOS и Android, и уже гарантированно нужно это изолировать.

Что такое деталь реализации

У нас есть модуль, у него есть вход. Он внутри себя из этого входа посчитал какое-то состояние, записал в поле.

class SomeModule(input: String) {
    val state = calculateInitialState(input)
    // Pure
    private fun calculateInitialState(input: String): String =
            "Some complex computation for $input"
}

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

class SomeModule(input: String) {
    val state = calculateInitialState(input)
    // Pure
    private fun calculateInitialState(input: String): String =
            "Some complex computation for $input"
}

class AnotherModule(input: String) {
    val state = calculateInitialState(input)
    // Pure
    private fun calculateInitialState(input: String): String =
            "Some complex computation for $input"
}

Соответственно мы видим, что это повторение того же кода, выносим эту логику по подсчету начального состояния куда-то в отдельное место и покрываем ее тестом.

class SomeModule(input: String) {
    val state = calculateInitialState(input)
    // Pure
    private fun calculateInitialState(input: String): String =
            "Some complex computation for $input"
}

object StateCalculator {
    fun calculateInitialState(input: String): String =
            "Some complex computation for $input"
}

Но как в таком случае мы пишем тесты на оба модуля? В обоих из них нам нужно проверить функционал calculateInitialState, если она является условной частью реализации. Если это достаточно сложная штука, то, возможно, имеет смысл вынести ее как явную зависимость и передать как интерфейс.

class SomeModule(input: String, stateCalculator: StateCalculator){
    val state = stateCalculator.calculateInitialState(input)
}
interface StateCalculator {
    fun calculateInitialState(input: String): String
    class Impl: StateCalculator {
        override fun calculateInitialState(input: String): String =
                "Some complex computation for $input"
    }
}

Не всегда это имеет смысл, так как при таком подходе мы можем просто проверить, что был вызван метод calculateInitialState с таким-то параметром. То же самое касается внутренних классов, extension-функций (если говорить о Kotlin), static-функций, то есть всего, что является деталью реализации и может дергаться из нескольких мест.

Как начинать, если наша кодовая база еще не готова

Логично начинать с моделей, то есть с того, что не имеет зависимостей (это или модели без зависимостей, или платформенные обертки). Мы написали их достаточное количество, а затем, используя их как зависимости, мы строим модели, которые принимают их на вход, и так понемногу выстраиваем наш глобальный граф зависимостей.

Пишем действительно тестируемый код - 7

Это выглядит примерно так, как инструкция по рисованию совы из шуточного руководства для новичков.

Пишем действительно тестируемый код - 8

В результате вы получите следующее:

  • все, что было неявным (синглтоны), станет явным (начнет передаваться через DI);
  • вы получите контроль и понимание процесса инициализации;
  • модели станет легко тестировать.

Если мы все это качественно сделали, мы можем взять какую-то входную точку из фреймворка (Activity, сервис, broadcast-ресивер…), создать вокруг нее какую-то обертку (в случае с Activity это может быть View), взять наш граф зависимостей, который мы сделали ранее, и создать Presenter. Все зависимости уже удовлетворены, и мы можем создавать Presenter, передавая их на вход через DI.

Пишем действительно тестируемый код - 9

Когда мы все это сделали, мы можем идти вверх по пирамиде тестирования к интеграционным тестам.

Пишем действительно тестируемый код - 10

На этом этапе мы берем слои, где мы жестко заизолировались от платформы (обертка и View), и заменяем их на тестовые реализации. После этого мы можем интеграционно тестировать все, что находится между ними (а это иногда бывает полезно).

Пишем действительно тестируемый код - 11

Вместо заключения

В конце я хочу привести известную цитату Джошуа Блоха: «Изучение искусства программирования, как и большинства других дисциплин, состоит из изучения правил на первом этапе и изучения того, как их нарушать – на втором».

Выше были изложены именно правила. Важная часть здесь в том, чтобы понимать, как они работают. И если вам нужно нарушить правила, это должно быть осознанное решение. Вы должны знать, какие последствия могут быть из-за нарушения правил. Если вы решаете нарушить правило, вы должны осознанно согласиться с последствиями. Если вы не можете мириться с последствиями, вы должны осознанно их не нарушать.


Если мобильная разработка – ваш основной профиль, вас наверняка заинтересуют вот эти доклады на нашей ноябрьской конференции Mobius 2017 Moscow:

Автор: Руслан Ахметзянов

Источник

Поделиться

* - обязательные к заполнению поля