Не просто проверяйте на ошибки, проверяйте на правильность.

Ценность покрытия юнит-тестами уже давно вызывает споры среди инженеров-программистов. Некоторые утверждают, что это гарантирует надлежащее тестирование всего нового кода. Другие утверждают, что охват — это бессмысленная метрика, которая больше всего служит плацебо.

На мой взгляд, правы обе стороны.

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

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

Давайте посмотрим на пример. Рассмотрим эту простую функцию:

fun foo(val x: Int, val y: Int) : Int {
  var sum = 0
  if (x > 10) {
    result += x
  }
  if (y > 10) {
    result += y
  }
  return sum
}

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

@Test
fun fooTest() {
  val x = 20
  val y = 50
  val sum = foo(x,y)
  Truth.assertThat(sum).isEqualTo(70)
}

Ваши метрики работоспособности кода покажут, что у вас есть 100% покрытие кода, даже если вы покрываете только 25% реальных случаев. Это простой пример, но подобные ситуации могут (и будут) происходить в рабочем коде. Давайте сделаем эту функцию немного острее.

fun divideByFoo(val x: Int, val y: Int, val z: Int ) : Int {
  var quotient = z
  if (x > 0) {
    result /= x
  }
  if (y >= 0) {
    result /= y
  }
  return quotient
}

Опять же, давайте достигнем 100% покрытия с помощью одного теста.

@Test
fun divideByFooTest() {
  val x = 1
  val y = 2
  val z = 90;
  val sum = divideByFoo(x,y, z)
  Truth.assertThat(sum).isEqualTo(45)
}

В данном случае я совершил глупую ошибку и использовал ≥ вместо › во втором условном предложении. Теперь я могу поделить на ноль и получить исключение, даже если у меня 100% тестовое покрытие! Это показывает наивность полагаться исключительно на эту метрику для определения качества тестирования. При написании тестов жизненно важно учитывать как можно больше входных данных. Если их слишком много для оценки, рассмотрите возможность разделения реализации. Функции, которые пытаются сделать слишком много, сложнее тестировать.

Недостающие крайние случаи — не единственная причина слабого тестирования. Плохие утверждения — еще один грех, который я видел бесчисленное количество раз. Разработчики могут завышать показатели покрытия, создавая некачественные тесты с ненадежными утверждениями. Модульный тест терпит неудачу или проходит на основе утверждений, которые он делает. Автор контролирует это напрямую, и это часто не имеет никакого отношения к линиям, которые на самом деле освещаются.

Давайте снова посмотрим на пример

@Test
fun fooTest() {
  val x = 20
  val y = 50
  val sum = foo(x,y)
  Truth.assertThat(sum).isNotNull()
}

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

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

Проверьте свои тесты с помощью мутаций

До сих пор мы видели, как легко писать тесты, которые пропускают случаи, но при этом достигают 100% охвата. Что, если я скажу вам, что есть способ избавиться от этих тестов? Здесь на помощь приходит мутационное тестирование.

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

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

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

Чтобы обеспечить адекватное исследование, вы должны создать множество таких мутантов, каждый из которых имеет только одно из этих крошечных изменений. Существует множество библиотек и фреймворков, упрощающих применение мутаций к вашим наборам тестов. В моей команде каждый новый тест автоматически проверяется мутациями, прежде чем он будет объединен. Мы не пытаемся устранить все выжившие мутации, но их помечают.

Покрытие не всегда плохо

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

Показатели охвата обманывают только тогда, когда они становятся целью. Инженеры любят использовать числа для количественной оценки своего влияния, а охват кода — это легко висящий плод. Заманчиво указать «увеличение охвата кода проекта на x%» в обзоре производительности, когда все, что вы на самом деле сделали, это написали тест, который вызывает конструктор. На самом деле это никому не помогает.

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

Покрытие модульным тестом также может выявить слабые места в вашем плане тестирования. Он служит отправной точкой для того, где присутствуют существующие недостатки. Покрытие должно пересчитываться каждый раз, когда разработчик объединяет новый код. Снижение охвата следует рассматривать как тревожный сигнал. Код редко следует отправлять без добавления дополнительных тестов.

Не забывайте об интеграционном тестировании

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

Модульные тесты не должны быть ответом на все вопросы. Некоторый код просто сложнее тщательно протестировать с помощью модульных тестов. Не все так просто, как проверка транзакций базы данных или бизнес-логики. Эти ситуации должны сигнализировать вам о том, что вам нужно найти что-то еще в своем наборе инструментов для тестирования.

Фреймворки для тестирования высокого уровня прошли долгий путь. У разработчиков есть бесчисленное множество инструментов, из которых они могут убедиться, что их код работает должным образом. Овладение этими инструментами и внедрение их в кодовую базу вашей команды — отличный способ добиться значимого результата. Удивительно много инженеров не имеют опыта интеграционного тестирования, что делает его ценным навыком.

Точная структура, которую вы используете, будет зависеть от технического стека вашего проекта. В основном я работаю над приложениями для Android, поэтому я использую Espresso для большей части сквозного тестирования. Эта мощная структура может быть расширена для проверки всего пути пользователя.

fun simpleTest() {
  onView(withId(R.id.button)).perform(click())
  onView(withText("Button clicked")).check(matches(isDisplayed()))
}

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

fun addToSavedListTest() {
  openMyListPage()
  val listSize = getListSize()
  openAppHomePage()
  getFirstCard().perform(click())
  openSavedListPage()
  checkListSize(listSize)
}

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

Выводы

Покрытие тестами может быть ценным показателем при правильном использовании. Он может выявить недостатки, выделить тенденции и дать представление об общей прочности теста. Однако он далек от совершенства, и слишком полагаться на него может быть не менее вредно.

Хороший план тестирования начинается с организации кода для облегчения тестирования. Разработчики, пишущие тесты, должны сосредоточиться на рассмотрении пограничных случаев и сценариев отказа, а не просто на достижении целевых показателей охвата. Мутации могут помочь обеспечить соблюдение этих стандартов. Наконец, для обеспечения общей стабильности продукта должны быть включены тесты, отличные от модульных.

Большая часть моего опыта тестирования связана с моим временем в пространстве Android. Мне всегда интересно узнать, как другие подходят к тестированию. Не стесняйтесь комментировать свои мысли. Спасибо за прочтение.

Хотите подключиться? Если вы еще этого не сделали, подписывайтесь на меня в Твиттере.