[Перевод] Юнит‑тесты скорее метят территорию, чем устраняют баги

Я программист, которому довелось много работать с железом, и с этой точки зрения тестирование ПО выглядит просто ужасно: в программах полно багов.
Объяснить это можно тем, что разработчиков подгоняют выпускать код с багами: мол, потом всё исправим. В результате программы постоянно остаются полны багов, исправление которых откладывают на потом.
С железом ситуация другая: баги часто (хотя и не всегда!) приводят к катастрофическим последствиям, поэтому разработчикам волей‑неволей дают время на их исправление, даже если это затягивается.
С этой точки зрения остаётся лишь один вопрос: правы ли те, кто принимает решения, считая баги не такой уж большой проблемой? Или они обманывают себя и теряют из‑за багов больше денег, чем потратили бы на их предотвращение?
Думаю, иногда они правы. А когда ошибаются, переубедить их вряд ли получится без наглядного, почти осязаемого аргумента вроде «горы микросхем, отправленных на свалку», как это бывает с железом, или без требований регуляторов.
Но можно посмотреть на ситуацию и с другой стороны: как вообще тестируют ПО и зачем? Ведь если баги никого особо не волнуют, можно вообще обойтись без тестов. И до 2010-х годов такое действительно встречалось сплошь и рядом.
А сегодня юнит‑тесты в среднем почти удваивают объём кодовой базы. Что изменилось и что сами программисты получают от такого тестирования?
Почему юнит‑тесты плохо находят сложные баги
Юнит‑тестирование определённо не слишком помогает устранять нетривиальные баги. И на то есть две причины:
Многие сложные баги и проблемы с производительностью возникают из‑за взаимодействия компонентов, поэтому для их обнаружения нужны интеграционные тесты, в которых совместно работают компоненты, разработанные разными командами. Но интеграционные тесты, как и другие виды тестов, не относящиеся к юнит‑тестам, встречаются крайне редко.
Более того, разработчики готовы идти на немыслимые ухищрения, лишь бы подменить зависимости моками и не затрагивать ничего за пределами тестируемого компонента.
Например, использовать исключительно абстрактные классы, чтобы упростить создание моков, или делать отдельную заглушку API для каждого теста. В результате при изменении API приходится обновлять не только места его вызова, но и все эти заглушки, и так далее.
Чтобы находить трудноуловимые баги, нужно проверять очень много различных сочетаний входных данных, тогда как при юнит‑тестировании обычно просто жёстко задают несколько наборов входных данных и соответствующие ожидаемые результаты.
В разработке железа принято подавать на тестируемую схему случайно сгенерированные входные данные.
Такой подход заставляет задуматься о распределении входных данных (ведь не хочется, чтобы большинство из них оказалось мусором, который код тут же отвергнет), а заодно и о том, как определить, правильный ли результат получен, а не просто запускать код и объявлять полученный результат «правильным», жёстко фиксируя его в тесте. Именно так обычно и появляются эталонные выходные данные в юнит‑тестах.
Если юнит‑тесты гораздо хуже находят баги, чем интеграционные тесты и генераторы случайных входных данных, почему именно они получили повсеместное распространение в индустрии? И почему это произошло именно в 2010-х?
Юнит‑тесты как защита кода
На мой взгляд, главная задача юнит‑тестов — защищать твой код от чужих рук в условиях, когда всё вокруг постоянно и бесконтрольно меняется.
В конце концов, проблема «баги всегда можно исправить потом» — лишь частный случай более общей проблемы: «программу всегда можно изменить».
С железом такой проблемы нет (хотя простоту изменения кода, конечно, можно назвать преимуществом. Но в каком‑то смысле это вовсе не преимущество, ведь сложное бывает простым, а простое — сложным).
В ситуации, когда всех постоянно заставляют всё менять, а культура разработки постепенно приходит к тому, что ни у кого нет своей зоны ответственности за код (под красивым эвфемизмом «коллективное владение кодом»), как вообще не дать другим ломать твой код?
Ведь «поломка» — понятие социальное. Считается, что я что‑то «сломал», только если меня заставят это исправить. Желательно — ещё до того, как я отправлю свои изменения в репозиторий, потому что потом мне, чёрт возьми, уже точно не захочется ничего чинить.
Юнит‑тесты идеально помогают справиться с этим безумием:
Их очень легко писать.
Они быстро выполняются и почти ничего не стоят, поэтому можно сделать их успешное прохождение обязательным условием принятия коммитов, не встретив особого сопротивления.
Они позволяют поставить галочку напротив пункта «качество», причём примерно за ту цену, которую софтверные компании готовы заплатить, чтобы убедить себя, будто они действительно что‑то делают для повышения качества.
Каждый может менять что угодно — ну конечно! Но только если тесты не падают — тоже вполне логично!
Удалять чужие тесты или грубо переписывать их лишь ради того, чтобы они проходили, даже не поговорив с автором, — это уже какой‑то абсурд. Вот вам и механизм защиты кода от случайных прохожих, которые иначе могли бы разнести его в пух и прах. Теперь им придётся либо сохранить работоспособность тестов, либо обсудить изменения с вами.
…И большинство программистов с энтузиазмом поддерживают такие тесты: писать их легко, а пользы от них немало. Не в том смысле, что они помогают находить трудноуловимые баги, а в том, что можно, чёрт побери, рассчитывать: если ты что‑то написал, оно там и останется!
Получается идеальный баланс, который устраивает и руководство, и программистов. Более того, он действительно позволяет разрабатывать быстрее — по крайней мере, по сравнению с ситуацией, когда все сначала несутся вперёд на бешеной скорости, не имея таких тестов, а потом судорожно откатывают изменения и пытаются исправить то, что друг другу сломали.
Да, в программе по‑прежнему полно и странных, трудноуловимых багов, и вполне банальных ошибок, которые юнит‑тесты вообще не покрывают. Зато сама программа хотя бы состоит из частей, в которых их авторы всё ещё узнают собственный код, несмотря на размер команды и огромное количество изменений.
Думаю, именно поэтому тесты, кстати, примерно удваивают объём кода. Это немного похоже на то, как если бы ты писал один и тот же код дважды.
Сначала пишешь сам код, который все могут как угодно перекраивать — коллективное владение кодом и всё такое. А потом пишешь тесты, которые, оказывается, уже нельзя так просто взять и перелопатить, потому что всем же лучше, когда это не так‑то просто сделать.
По сути, ты ещё раз повторяешь то, что уже написал, словно говоришь:
«Я с первого раза именно это и имел в виду. Пожалуйста, не разносите всё к чертям».
(Когда тесты пишут для поиска багов, а не чтобы метить территорию, их может быть и намного больше, и намного меньше, чем самого кода. Всё зависит от обстоятельств. А вот количество меток, обозначающих территорию, обычно пропорционально её размеру. «Эта функция всё ещё на месте и делает примерно то, что должна. И вот эта тоже никуда не делась…»)
Почему именно 2010-е?
Тогда распределённые системы контроля версий (DVCS) стали массовым инструментом. Они позволили легко создавать ветки и сливать изменения целых кодовых баз.
Представьте, что через какое‑то время станет с вашим кодом, если при этом не будет CI с кучей юнит‑тестов.
(CI и DVCS получили практически повсеместное распространение примерно одновременно. Любопытно, что одним из первых известных проектов, использовавших DVCS, было ядро Linux, где юнит‑тестов не было и добавлять их начали лишь недавно. При этом среди сторонников Agile юнит‑тесты пользовались популярностью ещё до появления DVCS. А позже обе практики стали набирать популярность одновременно.)
Но если посмотреть на всё это глазами инженера, привыкшего работать с железом, ситуация по‑прежнему выглядит странно.
Такой инженер обычно не придаёт юнит‑тестам особого значения, поскольку для проверки корректности они дают слишком мало. И спрашивает: хорошо, а почему бы вам заодно не создать отдельную команду верификации, которая будет писать и запускать интеграционные тесты и генераторы случайных тестовых данных?
Вы найдёте массу багов, причём некоторые из них наверняка обойдутся вам дороже, если их не исправить, чем содержание всей этой команды.
И тут ему отвечают: дружище, ты вообще представляешь, как это будет выглядеть?
Во‑первых, нам не нужна команда, которая заводит баги на код, уже отправленный в репозиторий. Мы ведь уже пошли дальше, понимаешь?
Ты говоришь об интеграционных тестах и генераторах случайных входных данных. А с какими API они будут работать? Нам теперь ещё и заботиться о стабильности API ради этих людей, когда вся идея в том, чтобы постоянно всё менять?
(На практике многое по разным причинам становится неизменяемым, но это редко признают.)
И дело даже не только в том, что ты предлагаешь тратить деньги на качество. Ты ведь собираешься нас замедлить, а это, разумеется, категорически запрещено!
И здесь мы подходим к главному принципу обеспечения качества ПО: улучшения качества считаются оправданными тогда и только тогда, когда они позволяют быстрее вносить изменения в программу.
Любой, кто пытается повысить качество в ущерб пресловутой «скорости разработки»1, рано или поздно сталкивается с этим принципом на практике, причём самым неприятным образом.
Именно поэтому распространённые методы тестирования ПО так сильно отличаются от методов, принятых в разработке железа. И поэтому команды разработчиков ПО так редко перенимают весьма эффективные подходы из аппаратной разработки, где главное — устранять баги, а не наращивать скорость изменений.
P. S. Рекомендую генераторы случайных тестовых данных
Если вы пишете сложный код и хотите быть уверены в его корректности, я настоятельно рекомендую использовать генератор случайных тестовых данных.
Необязательно гонять его круглосуточно на множестве машин. Достаточно нескольких десятков тысяч запусков или столько, сколько можете себе позволить.
Главное — использовать фиксированные начальные значения генератора случайных чисел (seed), чтобы результаты были воспроизводимыми.
Тогда такие тесты можно включить в CI и хотя бы на этом наборе входных данных защитить код от чужих изменений.
(Если входные данные каждый раз действительно случайны, CI станет недетерминированным и разработчики смогут просто перезапускать тесты, пока те не пройдут. Именно эта проблема мешает использовать TSan в CI. Интересно, почему никто так и не заставил детерминированный планировщик потоков работать со сборками, использующими TSan, чтобы решить эту проблему.)
Я использую генераторы случайных тестовых данных на уровне «модулей» объёмом в несколько тысяч или десятков тысяч строк кода.
Так генератор работает со стабильным API, который сложно изменить, а не с постоянно меняющимися внутренними API, в тестирование которых нет смысла вкладывать столько усилий.
На написание такого генератора может уйти неделя или даже несколько.
Но после этого надёжность тестируемого кода приблизится к уровню, принятому в разработке железа. А время от времени можно запускать генератор круглосуточно на какой‑то период, чтобы отлавливать баги, которые воспроизводятся крайне редко. Конечно, если вы из тех, кто действительно хочет находить и исправлять баги заранее (хотя вряд ли вас за это будут поощрять).
Я хочу сказать вот что: серьёзное интеграционное тестирование одному человеку не организовать, если компания в этом не заинтересована, а обычно так и бывает.
Зато один разработчик вполне может эффективно использовать тестирование со случайными входными данными, даже если больше никто в команде этого не делает, и получить от этого реальную пользу.
Для некоторых видов кода такой подход вполне оправдан.
P.P. S. Агенты и юнит‑тесты
Похоже, современные ИИ‑агенты научились писать тесты скорее на примерах человеческих кодовых баз, чем благодаря обучению с подкреплением, которое научило бы их хорошо тестировать код.
Поэтому они примерно удваивают объём кода довольно бессмысленными тестами. Но если кодовую базу в основном меняют агенты, лучше ли это, чем совсем ничего? Как ни забавно, сейчас я склонен считать, что да.
Агент похож на сообразительного, опытного новичка, которого только что приняли на работу и ввели в курс дела, но который совершенно не знает ваш код.
Если большую часть изменений в проекте вносят такие вот «новички», код быстро начнёт терять функциональность и остатки какой‑либо структуры.
Поскольку агенты изначально приучены серьёзно относиться к падающим тестам, у вас появляется защитный механизм от подобных разрушительных изменений.
Правда, он не защищает от добавления сложных и ненужных конструкций, которые затем сами оказываются защищены тестами от будущего «рефакторинга». Но ведь и программисты‑люди занимаются тем же самым.
Здесь, как и во многих других случаях, агенты просто позволяют быстрее прийти к результату, и неважно, окажется он вашей целью или худшим кошмаром.
Примечание
1. Думаю, по идее мы говорим «velocity», а не «speed», потому что velocity — векторная величина, а speed — скалярная. То есть нам якобы важно не только то, насколько быстро мы движемся, но и в каком направлении. Однако подозреваю, что на самом деле мы выбираем слово «velocity» просто потому, что оно длиннее. Мы ведь любим слова посолиднее, потому что обожаем нести всякую чушь.

Юнит‑тесты помогают защитить код от случайных изменений, но не показывают всей картины: чтобы понять, где сервис начнёт тормозить и как поведёт себя при росте нагрузки, нужны отдельные проверки.
Если хотите научиться находить такие проблемы до того, как с ними столкнутся пользователи, приходите на бесплатные открытые уроки:
30 ноября, 20:00. «Почему Java‑сервис тормозит: пулы и виртуальные потоки на нагрузочном тесте». Записаться
15 декабря, 20:00. «Как подготовить систему к росту нагрузки и не переплачивать за инфраструктуру». Записаться
Если хотите посмотреть, какие ещё темы можно изучить в ноябре, загляните в дайджест
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.