Pull to refresh

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

Level of difficultyMedium
Reading time5 min
Views807

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


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





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. Исправление примеров.
Tags:
Hubs:
Total votes 5: ↑1 and ↓4-3
Comments4

Articles