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