В первой части мы рассмотрели примеры тестов, из которых не все одинаково полезны. Затем попытались определиться, что же такое качество ПО, и предложили "распрямлять" код и выводить программы из требований. Рассмотрели классификацию ошибок. Рассмотрели те задачи, в которых тесты хорошо себя проявляют.


Попробуем разобраться, что получится, если применить все эти соображения к тестам из первой части.





Тавтологичные тесты *


Напомним пример:


def config(): Config = 
  Config("a", 2)

def testConfig(): Unit =
  val cfg = config()
  assert(cfg.a == "a")
  assert(cfg.b == 2)

"Как бы функция" config на самом деле не содержит алгоритма. И даже не является таблицей. Это просто константа. По-видимому, мы имеем дело с вырожденным случаем.


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


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


Мощность множества входных значений равна 1 (Unit).


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


Тесты ради покрытия


Тесты, вызывающие код, но игнорирующие результат:


def f(x: Int): Int = x + 1

def test(): Unit =
  f(0)

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


Больше одного теста такого типа, по-видимому, бесполезны. Такие тесты можно рассматривать в той же категории, что и недостижимый код. С экономической точки зрения — пустая трата усилий (waste).


Возможно, стоит пометить такие тесты аннотацией @Ignore, чтобы исключить регулярный запуск, либо удалить.


Поверхностное тестирование


Пример теста, в котором хотя и тестируется возвращаемое значение, но лишь формально:


def test(): Unit =
  val request = ???
  val response = http.Get(request)
  assert(response.status == 200)

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


Было бы удобнее, если бы в этом тесте при падении выдавалась вся контекстная информация.


def test(): Unit =
  // ...
  assert(response.status == 200, s"response = $response")

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


Тесты + флаги


Пример кода с флагами:


def foo(a: Boolean, b: Boolean, c: Boolean) =
  val resA = if a then
    doa()
  else
    dona()

  val resB = if b then
    dob()
  else
    donb()

  val resC =  if c then
    doc()
  else
    donc()

  resA + resB + resC

Здесь функция, по-видимому, имеет две проблемы:


  • нарушает "принцип единственной ответственности" (SRP);
  • неоправданно увеличивает цикломатическую сложность кода за счёт использования флагов.

Было бы неплохо такую функцию разбить на части, которые мы могли бы протестировать независимо — testDoa, testDona, ..., testDonc. Отдельно можно протестировать операцию агрегирования — testSum, если необходимо.


Что можно сделать с флагами?


Самый прямолинейный и универсальный способ — заменить флаги на enum'ы, содержащие 2 варианта. Такая замена позволяет решить некоторые проблемы простых флагов:


  • явный и обособленный тип значения, несовместимый с другими флагами (благодаря чему исключается использование вместо другого флага);
  • явные имена значений, описывающие смысл каждого значения.

Если от флага зависит какая-то одна часть программы, то можно сконфигурировать функцию там, где значение флага становится известным, и далее передавать уже эту функцию.


Также некоторые идеи предложены в Флаги в аргументах функций (перевод Toggles in functions).


В итоге можно получить такую программу:


sealed trait ConfigA
object ConfigA:
  case object FirstVariantForA extends ConfigA
  case object SecondVariantForA extends ConfigA

sealed trait ConfigB
...
sealed trait ConfigC
...

def algA(configA: ConfigA): () => Int = 
  configA match
    case FirstVariantForA  => doa
    case SecondVariantForA => dona

def algB(configB: ConfigB): () => Int = ???
def algC(configC: ConfigC): () => Int = ???

def foo(a: => Int, b: => Int, c: => Int) =
  a + b + c

Моки 1. "Алгоритм шиворот-навыворот"


Пример кода с использованием моков, в котором алгоритм повторяется в виде теста:


trait IFoo:
  def inc(): Unit
  def get: Int

def alg(f: IFoo): Int =
  f.inc()
  f.inc()
  f.get

def testAlg(): Unit =
  val mock = Magic[IFoo]()
  Magic.expectSequence{
    mock.inc()
    mock.inc()
  }.when{
    mock.get
  }.returnResult(2)
  assert(alg(mock) == 2)
  mock.check()

По-сути здесь алгоритм дублируется немного в другом виде. Вместо проверки ожидаемых результатов мы вынуждены фиксировать то, каким образом этот результат достигается. Тем самым затрудняется внесение изменений в будущем.


Можно ли этого избежать?


По-видимому, основной причиной использования mock'ов при тестировании кода с зависимостями является наличие внутреннего состояния внешнего сервиса, который мы моделируем. Алгоритм воспроизводит состояние путём последовательного исполнения цепочки команд, а моки вынуждены отражать каждый шаг в этой цепочке.


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


type Delta = Int
type FooMessage = List[Delta]

trait IFoo:
  def invoke(msg: FooMessage): Int

def alg(f: IFoo): Int =
  f.invoke(List(1,1))

def testAlg(): Unit =
  val mock: IFoo = (i: FooMessage) => i.sum
  assert(alg(mock) == 2)

Моки 2. Тесты ради тестов


Пример теста без тестируемого кода:


def foo(db: IDatabase): Int =
  db.ReadSomeValue()

def test(): Unit =
  val mock = Magic[IDatabase]()
  Magic.when{
    mock.ReadSomeValue()
  }.returnResult(10)
  assert(foo(mock) == 10)

Здесь мы наблюдаем тест, который живёт своей жизнью и для которого нет кода, который будет потом работать в production. С экономической точки зрения, это бесполезная трата ресурсов. Возможно, этот код можно было бы выделить в отдельный проект и рассматривать этот проект как учебный.


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


Ещё один способ избавиться от моков — интеграционное тестирование. Вместо попытки моделирования сложной системы будет использована сама эта система. Либо можно заменить компонент более простой версией. Например, использовать БД в памяти.


Тесты — бухгалтеры


Пример теста, проверяющего случайные факты:


def routes(): List[Route] = ???

def test(): Unit =
  assert(routes().length == 10)

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


Глубокое тестирование


Тест, в котором создаётся специальный код, который и тестируется в дальнейшем.


def insertA(db: IDB, a: A): Unit =
  db.Insert(a)

def test(): Unit =
  val realDb = openTempDb()
  case class B()
  realDb.CreateTable[B]()
  realDb.Insert(B())
  val b = realDb.Read[B]()
  assert(b == B())

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


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


Раздутые тесты


Тесты и код, которые делают слишком много.


def doAandB(a: IA, b: IB) = ???

def testDoAandB(): Unit =
  val mockA
  val mockB
  doAandB(mockA, mockB)
  mockA.check()
  mockB.check()

С точки зрения SRP функция должна делать что-то одно на том уровне абстракции, на котором её имя имеет смысл. Собственно реализация функции может состоять из нескольких частей. Но эти составные части должны принадлежать нижнему уровню абстракции. В данном случае функция имеет корректное имя A and B, которое сразу даёт понять, что функция делает два действия, имеющих смысл на этом уровне абстракции. У нас есть два варианта — понять, как объединены эти действия и перейти на следующий уровень абстракции — doC. Либо разделить эту функцию на две независимых.


Для модульного тестирования, конечно в любом случае следует тестировать отдельно A и B. Тестирование C можно рассмотреть в случае, если соединение A и B нетривиально.


Заключение


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


Во второй части серии приведены некоторые идеи, которыми можно было бы руководствоваться, чтобы понять, насколько полезны/удобны реализуемые тесты. Рассматриваются понятия:


  • качества ПО;
  • "прямолинейности" кода (противоположное цикломатической сложности);
  • эквивалентности функций.
    Также продемонстрировано использование изоморфизма Карри — Ховарда для написания программ, соответствующих требованиям.

В последней части серии исходные тесты проанализированы и переработаны на основе рассмотренных идей.


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


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

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


  1. kirkorov123123
    25.08.2023 15:59
    -1

    Отличная статья!