Comments 4
То что всё будет выражено в типах, автоматически не убирает необходимость тестов. Да, тест могут лишь гарантировать, что программа корректно работает только с теми данными, что используются в тестах. Для расширения пространства вариантов данных, можно использовать генераторы.
Да, типы позволяют уменьшить количество тестов, но не заменить их полностью. То что вернулся идентификатор из БД, не дает гарантии, что данные были сохранены полностью. То что вернулся нужный тип - не означает, что данные буду сериализованы корректно. И на это нужны тесты. Условно, при сериализации в JSON было поведение, что поля со значением null оставались, а после обновления библиотеки сериализации, такие поля стали по умолчанию убираться Или сериализация енумов изменилась - сериализовывались как UPPER_SNAKE_CASE, а стали CamelCase. Как результат - может сломаться интеграция с внешней системой. Если эта часть кода будет не покрыта тестами, то о проблеме узнаем в момент проверки интеграции, а если будет покрыта тестами, то сразу после обновления библиотеки на машине разработчика.
Да и работу с БД лучше проверять не на моках, а с реальной БД, к счастью, есть test containers, позволяющий легко поднять требуемое решение в контейнере. Так же в контейнерах можно поднять и брокеры сообщений. А моки оставить для систем, которые существуют только в облаке.
Причем подход с контейнерами, так же позволяет проще проверить, что при переходе на новую версию БД сервис, вероятно, будет работать как раньше. Достаточно изменить версию БД поднимаемую в контейнере. Да и просто проверить, что сервис работает с нужной версией БД.
Опять же, тесты позволяют проверить, что в случае ошибки, результат содержит определенную информацию достаточную, чтобы понять, что произошло. Что логи и метрики пишутся правильно. Это тоже нужно проверить, в меньшей степени, но бывает необходимо. Просто потому, что в процессе поддержки приложения внутренняя логика может измениться. И чем раньше о потенциальной ошибке станет известно, тем проще её будет исправить.
Тесты - это и в том числе документация, которую разработчик не забудет обновить, если изменилось поведение системы. А еще, можно использовать мутационное тестирование, чтобы чуть лучше понимать работают ли тесты, нет ли дублирования как в тестах, так и в проверках внутри кода.
Будь у меня выбор как проект взять на сопровождение - с хорошими тестами (включая юнит-тесты) или проект с "хорошей" архитектурой и типизацией всего, что только можно, то я предпочту первый, так как его код можно будет переписывать и улучшать в процессе поддержки. А со вторым всё будет хорошо, до тех пор пока не придется вносить серьезных изменений, ну или в процессе "красивая" архитектура будет деградировать, так как существенные изменения будет делаться "сбоку", так как трогать код не покрытый тестами "страшно", поведение изменится и не будешь знать об этом.
Некоторые тесты полезны, например, те, что упомянуты в следующей части.
Действительно, property-based тесты (использующие генераторы) - весьма сильная штука. Они позволяют существенно, в сотни раз, увеличить мощность тестового множества. Классический пример - наличие прямой и обратной функции (сериализация/десериализация; запись в БД/чтение). Их композиция должна давать
identity
, которое легко проверить - достаточно нагенерировать данных с одной стороны, потом прогнать через этуidentity
и убедиться, что всё ок. К сожалению, в общем случае обнаружить/придумать свойство, которое можно протестировать, не так-то просто. Более того, если такое свойство обнаружено, то, весьма вероятно, оно может быть выражено в типах явным образом.Я во всех примерах исхожу из того, что используемые библиотеки, компилятор, виртуальные машины и т.д. работают корректно. Если это не так, то мы вступаем на тонкий лёд непредсказуемого окружения и там может быть всё что угодно. В таком окружении тесты, проверяющие корректность работы/использования библиотек - обычное дело. Я лично писал такого рода тест на языке Go при использовании библиотеки google FHIR, потому что эта библиотека обладала неожиданной "фичёй" - при сериализации объект портился и некоторые свойства пропадали. При сериализации! Представить себе такое в Scala - крайне сложно.
Работу с БД я также обычно проверяю тестами на реальной БД в контейнере. Это гораздо полезнее моков и позволяет обнаружить в том числе отличия между версиями СУБД. Другое дело, что мне достаточно написать один тест на одну сущность и убедиться, что весь generic-код для этой сущности работает корректно. Этого одного теста достаточно для того, чтобы быть уверенным, что все остальные сущности также будут обрабатываться этим generic-кодом корректно.
Про логи и метрики. К сожалению, обычно логирование и запись метрик происходит в форме не наблюдаемых побочных эффектов. Поэтому протестировать, что логирование действительно происходит, может быть непросто. Обычно ограничиваются тем, что визуально, при запуске тестов, видны сообщения в логах. Если требуется проверить корректность логирования/измерения метриц, то, по-видимому, необходимо выносить их явным образом на уровень типов. В одном проекте, например, я использовал специальную обёртку, чтобы собирать метрики в явном виде. И в тестах проверялась корректность измерений.
Про мутационное тестирование. Интересно, в реальных проектах его применяют?
Типы vs тесты. Я предполагаю, что 99% читателей согласились бы с Вами и также выбрали бы проект с тестами, вместо того, чтобы разбираться со "сложными типами" :). Вся серия заметок направлена на популяризацию использования типов. Внесение изменений в корректно-типизированную систему, на мой взгляд, приводит к более предсказуемому результату. В частности, после изменения код перестаёт компилироваться. А вот после исправления всех точек, где ругается компилятор, мы можем быть почти на 100% уверены в том, что программа снова корректно работает. Тесты в принципе не могут приблизиться к такому уровню уверенности. Корректно-типизированный код изменять не "страшно". Сложно добиться того, чтобы программа потом компилировалась:) Зато если это получилось, то мы снова имеем гарантированно работающий код.
Я лично писал такого рода тест на языке Go при использовании библиотеки google FHIR, потому что эта библиотека обладала неожиданной "фичёй" - при сериализации объект портился и некоторые свойства пропадали. При сериализации! Представить себе такое в Scala - крайне сложно.
Библиотека может изменить свое поведение, может возникнуть потребность заменить одну библиотеку сериализации на другую. Например, для улучшения перформанса. Можно просто забыть выставить нужную опцию или наследоваться от правильного трейта. И вот уже сериализация идет не так как нужно.
Про мутационное тестирование. Интересно, в реальных проектах его применяют?
Как минимум одна команда Яндекс использовала их, общался с их лидом, они прогоняли мутации раз один-два раза в месяц, писали они на C++. Ну и скорей всего Авито, зря что ли они выложили фреймворк свой для мутационного тестирования на Go. Ну и для скалы, когда я смотрел на мутационное тестирование, был только один фреймворк, п потом тот фреймворк был заброшен, то появилась парочка новых. Раз пишет, значит кто-то использует. Единственное надо смотреть ограничения, не исключено, что какие-то библиотеки или плагины компилятора могут ломать работу этих фреймворков. Собственно с тем, что я встретил впервые - он ломался с аккой, когда подменялось поведение актора в процессе исполнения. Для скалы пока не встречал тех кто, использовал бы мутационное тестирование.
Типы vs тесты.
Я двумя руками "за" использование типов, особенно в скале, где очень много возможностей для этого и при этом типы будут существовать только на этапе компиляции не внося дополнительного оверхеда при исполнении. Это сильно уменьшает поле для ошибок - описав сигнатуру функции или тип переменной уже можно просто перебором подставить нужное, чтобы совпали типы и получить желаемое. Это всё так. Но корректное поведение программы и желаемое поведение - увы, это разные вещи. Ну и опять же, изменение типов, которое может потребоваться в рамках расширения функциональности приложения или рефакторинга, может привести к нежелательным последствия, которые без тестов будет не отловить.
Так же тесты - это документация, которую разработчик вынужден поддерживать, так как если поменялось поведение - тест будет падать. Так же для QA инженеры, могут смотреть на написанные тесты, чтобы не повторять уже проверенные вещи, а сосредоточится на интеграциях. Ну и в проект с тестами погружаться проще, так как тесты - это еще своего рода песочница в которой можно поиграться с кодом и лучше понять что и как работает.
Еще пример, с сериализацией. Если говорить о JSON, то если поле опциональное и представляет собой коллекцию, что есть два варианта задать тип у поля кейс класса - Option[List[DataType]]
или просто List[DataType]
. Редко когда нужно отличать пустую коллекцию от не заданной, потому есть возможность задать значение по умолчанию (бывает такое и со скалярными значениями полезно), но разные библиотеки могут обрабатывать это по-разному. Одни, по умолчанию, при десериализации вместо отсутствующего поля в JSON подставят требуемое, другим нужно явно указать, чтобы в таком случае использовались значения по умолчанию. В обоих случаях поведение программы корректное, мне не попадалось библиотеки, где разница в поведении будет влиять на тип кодеков. Можно, конечно, один раз запустить и проверить, что всё хорошо, но это знание останется у того, кто это написал. Более того, значение по умолчанию у поля кейс класса не обязательно свидетельствует о том, что в принимаемом JSON это поле может отсутствовать.
При работе с БД, можно забыть проставить аннотацию у поля описывающее имя столбца, или изменить порядок полей и тоже нарушить запись/чтение в/из БД. И всё это никак не описать в типах. Эти ошибки могут возникнуть как при написании кода впервые и тут можно сказать, что достаточно запустить и посмотреть что всё хорошо, но также они могут возникнуть случайно в процессе поддержки. И если на это будет тест, то он это поймает. А еще, для енумов в БД может быть задан пользовательский тип и при расширении енума в коде, можно забыть добавить миграцию, меняющую пользовательский тип в БД. Если в тестах поднимается контейнер и схема накатывается из миграций, то тесты помогут выловить это.
Я согласен с тем, что типы помогают уменьшить количество юнит-тестов, но это не считывается в том, что Вы пишете. Идею которую я вижу в этой серии статей - тесты не нужны пишите типы. Хотя, кажется, что Вы имеете в виду, как раз то, что использование типов позволяет уменьшить количество требуемых тестов. Возможно, отсюда и количество минусов у публикаций.
Замена библиотеки. Продукт сериализации, к сожалению, не наблюдается на уровне типов. Опции библиотеки сериализации также не находят отражения в типах, по-большому счёту. Поэтому гарантировать определённый результат сериализации, действительно, можно только проверив результат в специальном тесте.
Надо ли делать таких тестов много, на каждую сущность? Я исхожу из того, что если библиотека сериализации выбрана для проекта, опции настроены и для одной сущности написан тест, позволяющий убедиться, что результат соответствует требованиям, то для остальных сущностей библиотека должна давать корректный результат без необходимости написания дополнительных тестов.
Мутационное тестирование. У меня аналогичные впечатления - применяется энтузиастами, широкого признания не получило.
Использование типов. Не все типы одинаковы :) Некоторые типы особенно хороши. Например, алгебраические типы данных делают возможным "make illegal state unrepresentable". Этого очень не хватает, если приходится писать на языках без ADT.
"Но корректное поведение программы и желаемое поведение - увы, это разные вещи." Я как раз исхожу из того, что корректное поведение == соответствие требованиям == желаемое поведение.
Тесты - документация. Нормальный use case для тестов. Я об этом тоже пишу - документирование-api.
"тесты - это еще своего рода песочница в которой можно поиграться с кодом и лучше понять что и как работает". Тоже - Юнит-тесты как упражнение.
"пример с сериализацией". См.п.1. Минимизируем число тестов.
"При работе с БД". По-хорошему, необходимо следовать принципу "единая версия правды". Либо код схемы генерируется по базе, либо схема базы генерируется из кода. Как только мы дублируем сущности в двух исходных файлах, жди беды.
В моих проектах я делаю универсальный тест проверки корректности миграций. Текущая схема, сгенерированная из кода, заливается в отдельную пустую базу и сравнивается со схемой, получаемой миграциями. Все отличия печатаются и приводят к падению теста. Тем самым, гарантируется, что схема, с которой работает код, совпадает со схемой БД. И нет необходимости писать тест на каждую сущность.
Типы помогают уменьшить количество юнит-тестов. Да. Тесты, приведённые в первой части, как раз могут быть ликвидированы/упрощены за счёт использования типов. И я не предлагаю совершенно избавиться от тестов. Надо подходить к ним исходя из рациональных соображений. Если польза перевешивает, то надо писать. Если много стереотипных тестов, то, может, стоит заменить их на один универсальный? Я постарался привести несколько категорий тестов, которые полезны даже если активно использовать типы данных - Применимость юнит-тестов.
"Возможно, отсюда и количество минусов у публикаций." Похоже, Вы правы. Видимо, складывается обманчивое впечатление, что я против всех тестов вообще.
Бестолковые тесты versus качественное ПО. Часть 2. Что делать? 4. Эквивалентность функций