Разработка
Дело против модульных тестов
Чем сложнее данные и чем сложнее тестовый код, тем сложнее получить четкий надежный модульный тест.
Однажды у меня был инженер-менеджер, который хотел стопроцентного покрытия юнит-тестами. Это человек, которого я глубоко уважал с того момента, как начал с ним работать, поэтому я стремился достичь целей, которые он поставил, когда получил повышение. Он также был довольно разумным менеджером и радовался, когда мы достигали чего-либо выше 90%, поскольку он понимал убывающую отдачу от дальнейшей работы, но он никогда не уступал в стремлении к этой 100%-й цели. Он искренне хотел достичь этого.
До работы с ним я довольно поверхностно относился к тестированию. Я реализовал некоторые интеграционные тесты здесь и там, но в начале моей карьеры в основном QA-инженеры должны были беспокоиться о тестировании. Но в то время в отрасли произошел сдвиг парадигмы. И эта цель совпала с общекорпоративным переходом на разработку через тестирование (TDD) в рамках развертывания непрерывной интеграции и непрерывной доставки (CI/CD). С тех пор от разработчиков ожидается не только ручное тестирование собственного кода, но и самостоятельное внедрение автоматизированного тестирования.
Уйдя из этой команды, я с трудом могу забыть эту цель, поскольку почти каждая команда, в которой я работал с тех пор, нуждалась в автоматизированном тестировании и имела определенные показатели. В то время как TDD отошел на второй план, CI/CD остается в силе. Я видел много побед за эти годы, которые оправдывают усилия по покрытию юнит-тестами.
Но я также заметил несколько недостатков.
Иногда модульное тестирование невозможно. Я работал много лет — большую часть времени после этой цели, на самом деле — с программированием на Go, в котором есть несколько хороших функций модульного тестирования, встроенных в язык. Но у него также есть некоторые компоненты, которые нельзя протестировать. Функции main() и init() недоступны для среды модульного тестирования. 100% тестовое покрытие невозможно ни в одной программе Go. Всегда будет как минимум одна непроверенная основная функция, чтобы заблокировать последний процент.
Что еще более важно, нужно ли все покрывать unit-тестами, даже если бы это было возможно?
Модульный тест main функции больше не является модульным тестом. По сути, это интеграционный тест, одетый как модульный тест. Модульный тест должен тестировать отдельный компонент, а не весь композит. Интеграционные тесты предназначены для проверки взаимодействия компонентов.
Итак, если компонент ничего не делает, кроме как интегрирует другие компоненты, то какой смысл в его модульном тестировании?
Возьмем в качестве примера эту функцию Python, которую я недавно реализовал для задания ETL:
Он читает файл из S3 и выдает итератор для кадров данных фрагмента за раз.
Функция read_json() уже протестирована как часть библиотеки Pandas, как и большая часть того, что делает этот код. Единственное, что добавлено, это обертка для обработки параметров и логов. Это потребует большой работы над мокированием для получения очень незначительной пользы от тестирования. Единственное, что здесь стоит протестировать, — это интеграцию с нашим форматом озера данных в S3.
Нет смысла в модульном тестировании этой функции. Даже интеграция охвачена тестами всего приложения, поэтому нет смысла создавать какие-либо тесты специально для этой конкретной функции.
Работая с ETL в качестве инженера данных, довольно часто приходится иметь дело с этими типами функций. В другом месте того же кода есть легко тестируемая функция, которая собирает строку нужного формата. Но она единственная. Любая другая функция в том же коде больше похожа на этот пример. Каким бы сложным ни был основной компонент, он интегрирует другие компоненты даже на уровне преобразования. Одна функция обращается к внешнему API и сохраняет результат в кеше. Другая объединяет результаты всех этих других функций, по сути, main() операции, и добавляет некоторую обработку.
Эта последняя функция вызывает наибольшую тревогу, так как в ней есть некоторые части, требующие тестирования, но это чрезвычайно сложно сделать в модульном тесте, хотя он довольно прост, как интеграционный тест. Однако части, заслуживающие модульного тестирования, нелегко абстрагировать в отдельные функции. Я не думаю, что стоит пытаться имитировать все взаимодействия этой функции или переделывать ее, чтобы я мог добавлять юнит-тесты тут и там к отдельным строкам кода. Даже если бы у меня было время делать это вместо реализации из бэклога других задач, это все равно не очень хорошее использование unit-тестирования, так как интеграционный тест все равно сделает тоже самое и все равно будет необходим.
Модульные тесты полезны, когда они служат четкой и непосредственной цели — показать ожидаемый результат функции. Например, этот макрос Rust для добавления переменного количества перечислений для числовых данных, которые могут поступать в виде текста или чисел, имеет прямую реализацию:
Что делает этот модульный тест полезным?
Во-первых, он необходим. Это необычный фрагмент кода — функции с переменным числом переменных не являются нормальной частью Rust, поэтому этот макрос является исключением, основанным на некоторой ограниченной документации и ссылках без полной ясности. И он выполняет сложную рекурсию и приведение типов для моего ограниченного уровня знакомства с Rust. Поэтому тестирование необходимо, чтобы быть уверенным в коде.
Также этот макрос хорошо подходит для модульного тестирования. Он выполняет дискретную задачу. У него нет внешних взаимодействий или зависимостей, поэтому нет необходимости реструктурировать всю архитектуру со всеми потенциальными ошибками и дополнительной сложностью, которую может внести такая реорганизация, проводимая просто для ее тестирования. Все возможные вводы хорошо понятны. Ожидается явный детерминированный результат. У него есть несколько возможных путей и результатов, что означает, что он может работать в одном экземпляре (где он тестируется вручную), но не работать на разных входных данных, если он реализован неправильно.
Чем меньше таких качеств имеет участок кода, тем меньше вероятность того, что модульное тестирование будет правильным подходом.
Но даже когда функция хорошо подходит для тестирования, оно не всегда нужно. Ненужные тесты, которые просты в реализации, реализуются только для достижения целевого процента покрытия, при этом не делая ничего, кроме потребления времени на работу и искажая то, что должна представлять эта метрика покрытия.
Вот участок кода, который явно бессмысленно тестировать:
Эти геттеры необходимы для некоторого общего кода, вычисляющего стоимость квадратного фута в разных MLS. Но реализация модульных тестов для них совершенно бесполезна — разве что только для процента покрытия тестами. Они не дают значимой уверенности в работе.
Существует также проблема, заключающаяся в том, что практика модульного тестирования предполагает отсутствие ошибок в самих тестах. Вот чрезмерно натянутый пример, но достаточный, чтобы понять суть:
Этот тест проходит успешно, хотя он явно глючит, так как предназначен для достижения тестирования в 98.6, а не 98. Убедитесь сами в Go Playground.
Чаще всего ошибки в юнит-тестах будут в выборе ожидаемых данных. С десериализацией данных самая большая проблема при тестировании заключается в поиске реальных примеров всех возможных полей, которые необходимо проанализировать, чтобы редко используемые поля не привели к сбою конвейера. В составленном образце почти наверняка будут ошибки, а крайние случаи в реальном мире может быть трудно найти.
В модульном тесте также может отсутствовать неизвестный случай, но он по-прежнему будет казаться полным покрытием. Это опять-таки наиболее вероятно для выбора данных. Так называемые неизвестные неизвестные, когда вы не знаете о чем-то, что вы не рассмотрели, поэтому вы тестируете 100% строк кода, но не 100% крайних случаев неверных данных. Возьмите приведенный выше пример test_add_some_numberlike(), который работает для всех известных вариантов использования, которые довольно ограничены в продакшене, но не учитывает, что кто-то может использовать недопустимый тип или неверные данные. Покрытие тестами не обязательно приводит к тщательному тестированию и может вводить в заблуждение, предполагая, что тесты являются достоверными.
Чем сложнее данные и чем сложнее тестовый код, тем сложнее получить четкий надежный модульный тест.
Как вы гарантируете точность самих тестов? Добавление тестовых случаев по мере того, как вы сталкиваетесь с ошибками из недавно обнаруженных пограничных случаев, означает, что тест изначально не работал. Модульное тестирование модульных тестов представляет собой замкнутый круг. Вы можете вручную протестировать модульный тест так же, как вы могли бы вручную протестировать сам код.
Как правило, модульные тесты настолько просты, что ошибок мало и их легко обнаружить, поэтому это не проблема, но тот же аргумент можно привести и в том случае, когда сам код не нуждается в модульных тестах при тех же обстоятельствах.
В конце концов, я не против юнит-тестов в целом. Я нахожу их очень ценными на протяжении многих лет. Проблема в том, что юнит-тесты не абсолютны и не всегда уместны. Вы не можете полагаться на охват как показатель надежности. В некоторых случаях убывающая отдача начинается очень рано и очень быстро. Есть много мест, где их становится все труднее реализовать, и они просто не приносят никакой пользы.
Так что, хотя я все еще очень уважаю этого бывшего менеджера, я больше не пытаюсь достичь 100% или любого целевого показателя охвата юнит-тестами, если уж на то пошло. Вместо этого я ищу, где есть возможная выгода, и останавливаюсь там, где ее нет, а где-то посередине я сравниваю усилия на внедрение тестов с другими подходами.