В первой части мы рассмотрели примеры тестов, из которых не все одинаково полезны. Затем попытались определиться, что же такое качество ПО, и как подходить к вопросу тестирования с системной точки зрения.
Теперь рассмотрим один из аспектов разработки, позволяющий уменьшить необходимое количество тестов — "прямолинейность" кода (как понятие, противоположное цикломатической сложности).
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
на коллекциях. Получающийся код будет прямолинейным. А все детали реализации, возможно содержащие циклы, будут на уровне библиотеки.
Очевидно, что не все циклы возможно переписать таким образом. Остаётся только максимально изолировать оставшиеся циклы и тщательно протестировать все граничные случаи.
Заключение
В этой части мы придумали новое понятие "прямолинейности" кода, которое противоположно известному понятию цикломатической сложности. Это свойство оказывается напрямую связано с количеством необходимых тестов, в связи с чем желательно максимально "распрямлять" код. Рассмотрели несколько способов распрямления.
Вся серия заметок: