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


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





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. Исправление примеров.

Комментарии (4)


  1. ws233
    25.08.2023 09:15

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

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

    До этого вы писали, что на тестах лучше экономить и сокращать их число.

    Из этих двух тезисов без всяких выдумываний "прямолинейности кода" выходит, что циклометрическую сложность надо снижать. Для этого есть вполне себе известные инструменты. Некоторые из них Вы перечислили. Применяем (но не доводя до абсурда), и получаем уменьшение стоимости тестирования системы. Точка.


    1. primetalk Автор
      25.08.2023 09:15
      -1

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

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


  1. ws233
    25.08.2023 09:15

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

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


    1. primetalk Автор
      25.08.2023 09:15

      Если честно, я не сталкивался в реальных проектах с успешным применением мутационного тестирования.
      Для кода, в котором полно операторов if, такое тестирование, по-видимому, можно реализовать.
      Я здесь выступаю за то, чтобы минимизировать количество if'ов и по-максимуму использовать развитые типы данных. Я затрудняюсь предположить, что может "мутировать" автоматика в таком коде:

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

      ?