Бестолковые тесты versus качественное ПО. Часть 2. Что делать? 2. «Распрямляем» код

от автора

В первой части мы рассмотрели примеры тестов, из которых не все одинаково полезны. Затем попытались определиться, что же такое качество ПО, и как подходить к вопросу тестирования с системной точки зрения.

Теперь рассмотрим один из аспектов разработки, позволяющий уменьшить необходимое количество тестов — «прямолинейность» кода (как понятие, противоположное цикломатической сложности).


1. Тестовые данные и «прямолинейность» кода

Основным современным подходом является использование разнообразных тестов (юнит-тесты, приёмочные тесты, функциональные тесты, интеграционные тесты, …). Предполагается, что если тестовые данные подготовлены на основе требований, в значительной степени покрывают требования и код не противоречит тестовым данным, то код в какой-то степени соответствует предъявляемым требованиям.

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

Что означает «прямолинейность»? На мой взгляд, свойство «прямолинейности» можно сформулировать следующим образом:

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

Несмотря на очевидную слабость определения, оно позволяет нам отделить наиболее проблематичный вид кода — условное ветвление. Если какое-то значение приводит к выбору другой ветви, то такое значение нарушает «прямолинейность». Поэтому и требуется дополнительное тестирование.

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

2. Типы данных, уменьшающие цикломатическую сложность

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

Классический пример. Как может быть реализована функция, имеющая тип f: [A] => A => A?

реализация

val f: [A] => A => A = [A] => (a: A) => a

Иными словами — identity.

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

3. «Распрямление» if-boolean и match-enum

В некоторых программах ещё встречаются boolean-флаги, напрямую управляющие ходом программы:

def adjusment(value: Int, useHiLevel: Boolean): Int =   val level = if useHiLevel then hi else low   level - value

Каждый флаг, используемый таким образом, может увеличить цикломатическую сложность программы в 2 раза. Чтобы протестировать adjusment потребуется написать два набора тестовых данных — со значением флага true и false. Кроме того, все boolean-переменные совместимы между собой. Из-за этого легко ошибиться, передав не тот флаг.

Чтобы сделать несовместимые boolean-значения, применяются специализированные enum-типы:

sealed trait LevelConfig object LevelConfig:   case object Hi extends LevelConfig   case object Low extends LevelConfig  def adjusment(value: Int, levelConfig: LevelConfig): Int =   val level = levelConfig match     case LevelConfig.Hi  => hi     case LevelConfig.Low => low   level - value

Как избавиться от if-а внутри программы?

В ООП существует паттерн «Стратегия», а в функциональном программировании — просто функция в качестве параметра или by-name параметр:

def adjusment(value: Int, level: => Int): Int =   level - value

Условный оператор из основной программы переносится на уровень конфигурирования. Тем самым тестирование основной программы становится проще.

4. «Рельсовое программирование» и цикломатическая сложность

Во многих задачах алгоритм решения оказывается последовательным, но при этом каждое действие может завершиться неудачей. В таком случае может применяться идея «железнодорожно-ориентированного» программирования. На Scala похожий результат достигается при использовании Option или Either:

def foo(aOpt: Option[Int]): Option[Int] =   aOpt.flatMap(a => b(a)).flatMap(b => c(b))  def bar(aEither: Either[String, Int]): Either[String, Int] =   aEither.flatMap(a => b(a)).flatMap(b => c(b))

Непосредственный расчёт цикломатической сложности не внушает оптимизма, потому что ветвления по-сути остались на месте. Однако, т.к. переход от Happy path к обработке ошибок реализован в библиотеке, то при тестировании достаточно проверить корректность лишь Happy path, а библиотека представит гарантию корректной передачи ошибок.

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

5. Циклы vs. map/flatMap

Следующим оператором после if, вносящим вклад в цикломатическую сложность, является оператор цикла (for, while, …).
Естественным способом распрямления кода является использование .map, .flatMap на коллекциях. Получающийся код будет прямолинейным. А все детали реализации, возможно содержащие циклы, будут на уровне библиотеки.

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

Заключение

В этой части мы придумали новое понятие «прямолинейности» кода, которое противоположно известному понятию цикломатической сложности. Это свойство оказывается напрямую связано с количеством необходимых тестов, в связи с чем желательно максимально «распрямлять» код. Рассмотрели несколько способов распрямления.

Вся серия заметок:

  1. Примеры тестов.
  2. Что делать?
    1. Качество ПО.
    2. «Прямолинейность» кода.
    3. Классификация ошибок.
    4. Эквивалентность функций.
    5. Применимость тестов.
  3. Исправление примеров.


ссылка на оригинал статьи https://habr.com/ru/articles/756784/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *