Бестолковые тесты versus качественное ПО. Часть 3. Что получится?

от автора

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

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


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

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

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


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


Комментарии

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

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