Плохие тесты: кто виноват и что делать? |
13.07.2023 00:00 |
Автор: OSS contributor Тестирование — один из самых больных, если не самый больной вопрос в современной разработке программного обеспечения. Поговаривают, что разработчики не любят писать тесты, что написать правильные тесты зачастую сложнее, чем сам код, что «зеленое — не значит работает», а типовые экстремисты даже утверждают, что АДТ с лихвой эти самые тесты заменяют. Я бы не винил разработчиков слишком строго: в современном мире инструментарий для работы с тестами на три круга отстает от любых других вспомогательных средств. Ниже я не только расскажу, что именно с тестами не так, но и предположу, кто в этом виноват, и что по этому поводу можно сделать. Что тесты тестируют? Принято различать юнит-тесты, интеграционные тесты, acceptance testing (занимательный факт: статья про них в википедии на русский язык даже не переведена). Юнит-тест хорошо тестирует поведение чистой, без особенностей поведения в пограничных случаях, функции — с немногочисленными, простыми для понимания и желательно ограниченными аргументами. Например, функция умножения двух положительных целых чисел может быть очень хорошо протестирована с использованием только лишь юнит-тестов. Уже с делением — все заметно хуже, но там благоразумный программист может не забыть проверить пограничный случай нуля в знаменателе. Если взять что-то хоть немного посложнее, какую-нибудь простейшую в своем роде кубическую сплайн-интерполяцию, то юнит-тесты помогут выявить только совсем уж очевидные проблемы. Совершенно бесполезная метрика «покрытие» (нет, разумеется, она имеет свою область применения: от 0% до примерно 60%; дальше — никакого соответствия между качеством тестирования кода и покрытием просто нет) — была изобретена людьми с тем же менталитетом, которые измеряют качество и сложность программы — количеством строк кода. В сети можно найти миллиард примеров того, как стопроцентное покрытие не помогает найти даже самые тривиальные проблемы. Интеграционные тесты, в принципе, неплохи, но ужасно громоздки и капризны. Чтобы провести правильное интеграционное тестирование, нужно развернуть такое количество стендов, что зачастую бывает просто дешевле возместить клиентам понесенные из-за багов убытки (шучу, конечно, но на практике это не так уж и далеко от реальности). При этом, в интеграционном тесте в тепличных условиях (проведение одного заказа через чистую базу при нулевой нагрузке) — примерно столько же смысла, сколько в стопроцентном покрытии юнит-тестами кода калькулятора. Нет, он, конечно, выявит опечатку в запросе к API (которую, кстати, пропустит юнит, потому что эндпоинт для мока никто не станет набирать руками, а просто скопирует из вызова). Но действительно трудоемкие для починки проблемы — интеграционный тест выявит только в сходных с боевыми условиями (нагрузка, наполненность базы, network latency, наконец). Acceptance тесты — вообще не понятно, для чего нужны. Для ситуации, когда продуктовый отдел и разработка разговаривают через слепоглухонемого сурдопереводчика, наверное. Но мы их делаем, конечно, ведь во-первых, в 2023 году их все делают, а во-вторых, ну вот же, прикольно: попросили систему оформить заказ, и она его оформила! Есть еще тестирование свойств, известное как property-based testing, или QuickCheck, которое в сто раз лучше всего вышеперечисленного. Но оно, почему-то, страдает от нехватки популярности (видимо, плохая аура языка оригинальной версии: впервые фреймворк для тестирования свойств был написан на хаскеле). Впрочем, даже при помощи простейших инструментов, банальнейших юнит-тестов, можно было бы выловить на порядок больше проблем, и уменьшить в разы число багов, которые просачиваются в продакшн. Что же с нами не так?С нами все в порядке. Мы любим изящные штуки, ездим на красивых больших автомобилях, пьем крафтовое пиво и лавандовый раф, и мы, черт побери, привыкли к тому, что инструменты, помогающие нам в разработке, почти идеальны. Кроме тестов. Библиотеки для тестирования — как будто телепортировались к нам из семидесятых годов прошлого века, когда и слова-то такого — «тестирование» — в компьютерных науках не знали. Но виноваты в этом отнюдь не авторы этих библиотек: они-то как раз в поте лица добиваются лучшего, что они в принципе могли бы сделать — в условиях абсолютного безразличия к тестированию авторов всех остальных фреймворков и библиотек. Примеры ниже будут на руби, потому что это наиболее выразительный язык из более-менее широко распространенных, но сам синтаксис тут имеет вполне второстепенное значение. Принцип, насколько могу судить, прослеживается во всех известных мне языках: авторы библиотек совершенно не заботятся о том, чтобы сделать жизнь разработчиков, которые ставят им всяческие звезды на гитхабе и вообще используют плоды их трудов, — хоть немного приятнее. Пожалуй, самая популярная библиотека руби вне рельсовых примочек — sidekiq Майка Перама. Сама библиотека совершенно заслуженно занимает свое почетное место: она принесла в мир руби внятную асинхронность, она прекрасно отлажена, волшебно масштабируется и вообще на сам код — очень приятно смотреть. Но знаете, что? — интеграцию с тестами (rspec-sidekiq) внезапно сделало сообщество. Сам Майк об этом не подумал, или поленился. «Ну, это нормально, ведь сообщество для того и нужно», — слышу я напрашивающееся возражение. Так, да не так. Давайте взглянем, что официальная страничка помощи библиотеки, которой больше десяти лет (судя по дате первого коммита), предлагает для облегчения жизни грешников, решивших ей воспользоваться:
Целых пять матчеров! Каждый из которых тестирует саму библиотеку. Ну ладно, не совсем конечно, не все так плохо. Можно придумать сценарий, при котором проверка того, что задача была запущена с правильными параметрами — не повредит. Не скажу, что очень сильно поможет, правда. Вот пример из документации:
Что бы мне хотелось увидеть среди экспортируемых матчеров библиотеки, помогающей с тестированием высоконагруженного обработчика задач? Ну что-то вот такое:
На первый взгляд, разница может быть не так заметна, но она существенна: объектом тестирования должен выступать наш бизнес-объект, а не служанка из чужой библиотеки. Тогда такой тест можно будет и продакту показать, и вообще, написать с удовольствием: ведь он действительно тестирует то, что мы ждем от нашего кода, а не то, что (как мы подразумеваем) должно работать и так в сторонней библиотеке. Такая ситуация сложилась во всех языках, без исключения. Авторы библиотек наивно (или цинично) полагают, что разработчики как-нибудь там сами справятся с тестами, в которые вовлечена их библиотека, и в лучшем случае предоставляют интерфейс для тестирования того, что и так как бы по умолчанию уже было протестировано самими авторами: потрохов этой библиотеки. Еще замечу, что при почти ультимативной выразительности руби, синтаксис
Теперь у нас есть полный контроль над объектами, вовлеченными в бизнес-процесс, выполняемый с помощью этой библиотеки. Мы можем проверить, сравнить и инвалидировать любой атрибут любого вовлеченного объекта, как в начальном, так и в конечном состоянии. Мы фокусируемся на бизнес-логике вместо того, чтобы быть привязанными к потрохам жизненного цикла самой задачи. Что характерно, написать такой DSL — дело одних выходных, я бы сам взялся, если бы не ушел из руби несколько лет назад, и если бы у меня было достаточно лишнего времени. Но уж авторы-то библиотек точно могли бы быть поприветливее к разработчикам. Я абсолютно убежден, что если бы инструментарий для написания тестов не уступал бы на несколько порядков оному для написания кода, тестов в мире было бы больше и они были бы лучше. А что там с моками?Моки — дело хорошее. Они помогают не разворачивать все сто шестнадцать компонентов боевого окружения для тестирования однострочного фикса. И в то же самое время моки слишком часто и слишком многими понимаются неверно. Нельзя просто замо́чить (мо́кнуть? замокну́ть?) какой-то метод объекта и думать, что все остальное будет работать в точности так, как прежде. Точнее, можно: если этот метод абсолютно чист. Самый завалящий сайд-эффектишко — превратит любой мок метода — в чудовище, которое сожрет ваш код в продакшене. Более того, мок пары методов — непрактичен, потому что в результате получается тестирование оборотня: вот тут и тут у нас мок, а там — кишки самого объекта. Правильный подход — создавать моки на объекты целиком. Во-первых, сразу станет видно, где нужны инъекции зависимостей. Если у нас есть объект «заказ» и на нем есть метод «отправить письмо», который раньше (матеря́ того идиота, который такой код написал) можно было просто мокнуть — с подходом «мок — это двойник объекта» такой вариант не взлетит. Придется выделить отправителя писем в свою сущность, и вот его уже мокнуть. При таком подходе моки можно подготовить заранее; если не на все тесты, то уж на значительные по размеру блоки тестов — точно. В общем, моки — это несомненное добро, пока вы не застаете себя за использованием моков на методы в качестве заплат. Так что же делать?Если вы — автор библиотеки (микросервиса, подпроекта, обособленной группы моделей) — найдите в себе силы подготовить пару-тройку сотен строк кода, облегчающие тестирование бизнес-логики с использованием вашей библиотеки. Если вы просто пользуетесь чужими библиотеками — напишите вышеупомянутую обвязку для себя и коллег, а потом попробуйте предложить ее автору библиотеки. Скорее всего, ее примут, потому что это почти киллер-фича для выбора именно этой библиотеки. А если вы, как и я, просто проходили мимо — порадуйтесь, что тесты могут стать желанными гостями в процессе написания нового кода. «М-м-м-м, завтра и послезавтра мне нужно будет обложить вон тот код тестами, здорово-то как!». |