В этом топике я не пытаюсь доказать, что тесты бесполезны. Это скорее мои размышления вслух и личная попытка нащупать их реальную ценность. Некоторые идеи в процессе всё-таки зацепили - но скорее ккак частные случаи, а не что-то универсальное.
Я программирую уже больше шести лет. На самом деле существенно больше (на свой первый аутсорс на PHP я попал примерно в 2016 году), но осознанно подходить к своей карьере я начал не сразу. За это время я вполне успешно поработал в довольно разных местах, от маленьких стартапов до международных компаний.
Недавно я проходил очередное собеседование, и на мой взгляд я неплохо держался. Как минимум до вопроса о том, как я покрываю свой код тестами. После него я стыдливо пробормотал о том, что знаю, как работает assert в python, и даже слышал про pytest. И что я с радостью начну писать тесты как только попаду к ним на проект, просто в наших проектах их не требовали. После чего мы плавно перешли к следующей теме, а оффер я так и не получил.

Спустя некоторое время я решил закрыть пробелы в своих знаниях. Я нашел несколько лекций по TDD на YouTube, и с удивлением обнаружил, что не узнал из них ничего нового. Они давали базовые технические знания и объясняли идеи, но это было мне и так знакомо. А изначальный запрос на понимание ценности тестов они удовлетворить не могли. Несмотря на то, что я был бы рад внедрить новые good practices в свою работу, я столкнулся с некоторыми проблемами.
Проблемы тестов
Стоит уточнить, что я не разрабатываю приложения. Я Data Engineer, в основном пишу пейплайны обработки данных, и при работе с данными очень многое зависит от их источника. Не всегда есть возможность контролировать, что именно функция получит на вход. Все проще, если мы же эти данные и создаем, но часто приходится работать с внешними источниками. Из-за этого задача проверки состояния данных решается несколько иначе - вместо того, чтобы предусмотреть возможное поведение в нестандартной ситуации, намного удобнее просто словить ошибку при любом нестандартном поведении программы, отправить ее в логи и разобрать вручную. В первую очередь потому это может быть симптомом того, что что-то поменялось со стороны поставщика. И тут мы подходим к первой проблеме.
1) Тестировать можно только ожидаемое поведение
Программирование - это очень часто исследовательская работа. Хотя я не сравниваю себя с полноценными research-профилями, для специалиста уровня middle и выше критично умение решать нетиповые задачи. Да, опыт позволяет быстро определить направление, но деталями решение обрастает в процессе, зачастую спустя несколько итераций.
Само по себе это никак не мешает тестам. Если у нас есть ожидаемый результат, то нюансы реализации нас не волнуют. Проблема появляется как только мы обнаружим, что некоторые граничные ситуации не были учтены. В этот момент появляется парадокс - тесты написаны и работают корректно, а программа падает с ошибкой. Приходится лезть в логи, расчехлять дебаггер, а после этого писать еще один тест, фиксирующий новые условия. Через какое-то время процесс повторится снова.
Вместе с этим покрытие тестами дает не всегда соответствующее действительности чувство уверенности в коде. Очень легко написать десяток очевидных тестов, из которых 3 будут проверять работу функции сложения с отрицательными числами. А вот предусмотреть то, что пользователь использует длинное тире вместо минуса, намного сложнее.

Но разве такое покрытие не должно закрыть хотя бы часть проблем? Я бы спросил иначе - а имеет ли оно реальную ценность?
2) Очевидное поведение не требует покрытия тестами
В разработке мы очень часто используем чужой код. Когда я только учился программировать, я внимательно следил за тем, что мне отдавал компилятор. Числа с плавающей запятой до сих пор являются проблемой во многих языках. Но поместив в код проверку вида 0.1 + 0.2 != 0.3
вы получите много косых взглядов. Несмотря на утрированность она ничем не отличается от большинства найденных мной учебных примеров.
Тесты спасут вас, если вы умудрились захардкодить return. Но вместе с этим это говорит о больших проблемах с процессами, и вам стоит обсудить ваши review. Иногда мы действительно работаем с какими-нибудь алгоритмическими задачами, где можно ошибиться знаком, но это происходит не так часто.
Если код содержит неочевидное поведение, то полноценное покрытие его тестами может потребовать больше ресурсов, чем написание этого кода. Это не значит, что код можно не тестировать вообще - в своей работе я активно пишу use-cases. Основное их отличие от юнит-тестов в том, что я не проверяю конкретную пару значений на вход и выход - я создаю синтетическую цепочку вызовов, передаю туда конфигурацию параметров и смотрю на ее поведение. При хорошо настроенных логах это используется не только для теста конкретного поведения, но и для полноценного дебага. Но написание нескольких use-case - это далеко не Test Driven Development (далее TDD). Это подводит нас к следующей проблеме.
3) Тесты кратно увеличивают объем кода
В отличие от использования use-cases в тестировании очень важно покрытие. Никому не нужны тесты, которые игнорируют часть состояний программы. При этом программы обычно имеют большое количество зависимостей, которые увеличивают объем этих состояний экспоненциально. Решение у этого конечно есть, даже два:
Во-первых, мы можем загнать все возможные состояния в тесты. Да, их получится много, но в теории мы можем использовать для этого генераторы.
Во-вторых, мы можем снизить количество зависимостей путем разбиения кода на более мелкие функции. В этом случае мы сможем тестировать их отдельно друг от друга.
Второй вариант может показаться оптимальным, но на деле оба являются компромиссами. Возьмем для примера задачу генерации xlsx из json. Добавим несколько входных условий - json структурирован под no-sql формат, некоторые колонки могут иметь больше одного значения, и по необходимости комбинируются по принципу декартового произведения. Только некоторые колонки должны попасть в конечный файл. Алгоритм решения выглядит примерно так:
def json_to_xlsx(json_data, fields, output_file):
wb = Workbook()
current_row = 1
for entry in data:
lists_for_product = []
for field in fields:
raw_value = entry.get(field, None)
if isinstance(raw_value, list):
if len(raw_value) == 0:
lists_for_product.append([None])
else:
lists_for_product.append(raw_value)
else:
lists_for_product.append([raw_value])
for combo in itertools.product(*lists_for_product):
for idx, field in enumerate(fields):
ws.cell(row=current_row, column=idx+1, value=combo[idx])
current_row += 1
В этом примере есть необязательная зависимость - мы считываем данные и сразу же записываем их в файл. Функцию можно разбить на две части - первая функция будет извлекать данные из json и представлять их в табличном виде, а вторая - сохранять их.
Но я не просто так упомянул декартово произведение. Этот синтетический пример может иметь эффект бомбы - данные, представленные в табличном виде, значительно увеличат свой объем. Изначальное решение же работает построчно, из-за чего использует меньше памяти. Именно эта зависимость дает нам дополнительное пространство для оптимизации.
Конечно тесты пишутся на будущее и упрощают разработку - но только если архитектура остаётся стабильной. А это возможно либо когда проект достаточно простой и не требует рефакторинга, либо когда задача хорошо формализована и отличается лишь нюансами продукта. В первом случае тесты избыточны. Во втором - необходимы, но с такими задачами я почти не сталкивался: мне ближе менее формализуемые и более живые системы.
С приходом в нашу жизнь GPT и Copilot процессы стали проще, и многие вещи уже не надо писать руками. Если задача понятна, то код для нее можно сгенерировать. А раз это не отнимет много времени, то какие у этого минусы?
Дело в том, что читать код приходится чаще, чем писать.
4) Лучший код - это код, который не был написан
(А точнее код, который делает свою работу максимально просто)
Одна из реальных ценностей TDD - это дополнительный слой документации. Когда каждая функция имеет четкую спецификацию, достаточно взглянуть на тесты, чтобы понять, что она делает. Рефакторинг упрощается: мы работаем с набором черных ящиков, каждый из которых можно заменить целиком.
Недавно моей жене понадобился небольшой скрипт, строящий дерево зависимостей в проекте. Довольно простая задача, для которой она быстро набросала решение в Cursor. Логика была такая: считываем корневой файл -> собираем ID зависимостей -> ищем по проекту соответствующие файлы. Повторяем рекурсивно, пока не соберем дерево целиком. Каждый шаг вполне хорошо выделяется в функцию и дорабатывается по необходимости.
На практике всё оказалось сложнее. Многоуровневая вложенность в файлах требовала использования сложных регулярок. Кроме того, ID файла находился внутри самого файла, из-за чего третий шаг требовал большого количества условий для поиска. Агент честно пытался - но, как и полагается LLM, выдавал убедительные ответы, а не работающий код. Контекста не хватало. Поэтому жена обратилась ко мне за помощью - мое контекстное окно пока что немного шире.
Проблема в том, что к этому моменту код уже был нечитаем. Каждая отдельная строка была понятна, но объем уже превышал 300 строк из-за огромного количества доработок. Я открыл новый файл и переписал логику с нуля:
закэшировал пути,
сделал сбор зависимостей рекурсивным
убрал адаптивность и жёстко заточил под одну структуру
Итоговое решение составило 80 строк и пару комментариев. Я убрал адаптивность, сделал поведение жёстким и предсказуемым. Да, любое изменение входной структуры теперь положит систему - но структура и не должна меняться, поскольку она привязана к куче сторонних подпрограмм. А если это вдруг случится - 100 строк кода не так уж сложно заменить. Если в изначальной версии я сам с трудом понял, что происходит, то вторую моя жена дорабатывала уже самостоятельно.
TDD предполагает расширяемость, но иногда мы избыточно о ней беспокоимся. Когда вы пишете проект под миллионы запросов в секунду, то вам стоит озаботится его поддержкой. Но очень часто мы переоцениваем свои задачи, и вместо кубернетиса будет достаточно виртуального хостинга. А пока мы не меняем код, тестировать собственно и нечего.
Конечно, даже стабильный код иногда падает. Особенно когда меняются внешние данные или API. В этом случае нам действительно придется заниматься рефакторингом. Но юнит-тесты автономны и не ловят такие изменения, вследствие чего устаревают быстрее самого кода.
5) Написанный код часто требует утилизации
Мы, программисты, очень не любим уничтожать написанный код. Чужой еще ладно, но в свой вложено столько сил и времени, что когда он начинает разваливаться мы рефлекторно тянемся к костылям. Хотя иногда честнее просто выдернуть вилку: пациент уже не жилец.

Плохая архитектура связывает нам руки. В примере выше я мог бы доработать более гибкую версию, предложенную курсором. Но для конкретной задачи она работала бы хуже, а вносить в нее изменения получалось бы только после бутылки пива. Тесты поощряют нас сохранять структуру, поскольку увеличивают объем кода, попадающего под рефакторинг.
Возможно я не считал бы это проблемой, но к сожалению я слишком редко встречаю хорошие спецификации. Когда у задачи нет четких требований, приходится писать прототипы и дорабатывать их по мере поступления информации. А когда над проектом работает несколько людей, зависимости со временем превращаются в спагетти.
В одном из моих рабочих проектов я передаю вниз по цепочке список, собранный из значений словаря. Одна ключевая функция с нетривиальной логикой принимала именно список, и поскольку она была написана не мной, я не стал её трогать при рефакторинге. Остальным функциям в цепочке это не мешало, из-за чего список стал универсальным интерфейсом.
Проблема в том, что ниже по стеку мне всё же нужен словарь ради быстрого доступа. Теперь приходится собирать его заново. Проще было бы спускать словарь и вытягивать список по месту, но архитектура уже устоялась, и менять её означает пройтись по половине проекта.
При полном покрытии вам придется переписать еще и все связанные тесты. Правда скорее всего вы и не сможете достичь полного покрытия. Просто потому что без чёткой спецификации невозможно верифицировать поведение. Сейчас я работаю с ИИ-агентами, и даже не всегда могу гарантировать стабильный результат - галлюцинации делают тесты почти бессмысленными.
Но даже если вам всё-таки удастся добиться полной формальной стабильности - у этого тоже есть побочные эффекты, потому что даже идеальные тесты работают не с системой, а с её моделью.
6) Тесты изолированы от системы
Тесты - штука локальная. Они не знают, что происходит вокруг. А это проявляется по-разному.
В инфраструктуре: допустим у вас есть база данных на stage. Вы загнали туда кучу синтетических данных, покрыли тестами, все работает прекрасно. Раскатываете систему на prod и что-то идёт не так. Что делать?
Можно залить синтетику в prod, но она смешается с реальными данными и ее придется фильтровать. Фильтрацию тоже надо тестировать. В итоге тесты привязываются к реальной системе, а stage теряет свою основную задачу.
Второй вариант - это воспроизведение проблемы на stage. Но для этого проблему надо сперва найти, а это логи, дебаг и анализ. При этом тесты не выполняют свою роль.
В архитектуре: иногда продвигается идея того, что тесты надо писать еще на этапе архитектуры, до реального кода. Формально это задаёт поведение, к которому мы стремимся. Но в реальности это может стать золотым молотком: вход и выход заданы, и мы начинаем подгонять логику под тест. Если в процессе выясняется, что изначальная архитектура неверна, переписываем и код, и тесты.
В работе с ИИ: недавно я упростил логику агента, который генерировал нестабильный отчёт. Один из шагов - временно отключить summary: он строился поверх остальных блоков, занимал контекст и не был критичен. Я оставил комментарий, но он затерялся.
Тестировщик запустил агента, увидел, что summary нет, и завел баг. Дальнейшие шаги он даже не посмотрел (позже он, кстати, принял результат). Но если даже живой человек может проигнорировать контекст, как тогда тест, у которого контекста нет вообще, поймёт, что всё идёт правильно?
Я не могу сказать, что тестировщик был неправ. В задачах с нестабильным поведением до сих пор не очевидно, что именно и как именно мы вообще должны проверять.
7) Тесты (как и программирование в целом) - это не набор четких инструкций
Мой внутренний конфликт заключается в том, что тестирование - это не только технический навык. Можно научиться писать рабочий код на курсах, но чтобы писать читаемый и оптимизированный код нужны опыт и экспертиза. Аналогично и с тестами - теория проста для понимания, но ощущается максимально синтетической.
С моим опытом компании уже не рассчитывают меня доучивать. В итоге я попадаю в проекты с нулевой культурой тестирования. Теоретически я могу пройти собеседование "на зубрёжке", но чаще отвечаю честно потому что ищу не просто оффер, а нормальный метч. И в результате я просто не оказываюсь в командах, где у тестов есть практика и смысл - а значит, и сам не набираю этого опыта.
Я бы с радостью разобрался в тестах глубже, если бы понимал их реальную ценность. Пока что мне чаще встречаются не тесты как инструмент, а тесты как ритуал. Например, покрытие: формально звучит убедительно, но на практике это плохая метрика, поскольку она слишком легко превращается в самоцель.
Недавно мне пришлось работать с API Azure DevOps. Интерфейсы выглядели чисто и были продокументированы, но чтобы получить список изменений в PR мне пришлось нужно вызывать цепочку на 3-4 последовательных запроса. В GitHub это делалось в 1 запрос.
Подводя черту
Формальная аккуратность - не то же самое, что удобство использования. Архитектура и ориентация на реальные задачи делают код понятнее и практичнее, чем любые внешние признаки качества.
К сожалению у меня нет ресурса изучать всё подряд. Я стараюсь расти как специалист, и выбор, куда потратить своё время, напрямую влияет на темп этого роста. Но именно тема тестов настолько распространенная и универсальная, что я ощущаю, что что-то важное прошло мимо меня.
Сейчас TDD для меня выглядит как способ принять решения до этапа исследования - работает для уже изученных задач, но мешает, когда цель ещё не ясна. Возможно, дело в контексте - моих проектах, подходе к логированию или просто нехватке удачных примеров. Но если я действительно что-то упускаю - я хочу это понять. Даже если придётся пересобрать часть привычной практики.
Нельзя научиться чему-то, если бояться выглядеть глупо. Поэтому буду рад комментариям и приглашаю к обсуждению.
Комментарии (7)
brutfooorcer
24.06.2025 07:40Тесты помогают:
Исключить ошибки невнимательности
Понимать, что при изменении кодовой базы функционал работает корректно
Планировать и структурировать код
Убедиться в работоспособности перед выпуском на стенд
Если у вас появляются новые кейсы (все предусмотреть действительно невозможно) - просто добавляйте еще один тест и все)
А необходимость всего этого каждый решает для себя сам. Ну, либо это регламентируется правилами на проекте
dmitrysbor
24.06.2025 07:40Статью не читал, но с посылом согласен. Пирамида тестирования известна давно: юнит тесты в основании пирамиды тестирования, их много, но вклад в покрытие функционала мизерный. Их девы пишут для себя, чтобы просто прошёл мёрж реквест. Никакой дополнительной пользы они не несут.
summerwind
24.06.2025 07:40А то что юнит-тесты позволяют очень точно протестировать бизнес-логику и покрыть граничные случаи, которые или очень тяжело или вообще невозможно покрыть огромными интеграционными тестами - это конечно же БЕСПОЛЕЗНО :)
shoorick
24.06.2025 07:40А что вы считаете очевидным? Да, в
assert 0.1 + 0.2 == 0.3
смысла нет, но вassert some_your_sum(0.1, 0.2) == 0,3
он уже появляется. Тестируются не встроенные операторы, а то, что мы сами пишем, ну или то внешнее, чему доверяем, но всё-таки проверяем.
flancer
Я когда-то тоже думал на эту тему. Если кратко, то чем больше в коде решений, чем больше в команде разработчиков, чем более стабильна предметная область - тем осмысленнее применение юнит-тестов. Нет смысла писать юнит-тесты, если проект ведётся в "одну голову" или пишется "одноразовый" код. Но смысл появляется, если предполагается, что проект будет эволюционировать или количество разрабов станет больше одного. Хотите набраться реального опыта в юнит-тестировании - возьмите "силиконового" напарника ;)
absurd_logik Автор
Ну собственно эта мысль и прослеживается в топике :) В условиях изначально продуманной структуры они действительно полезны.
Но дело в том, что я практически не работаю в таких условиях, а вот на собеседованиях периодически об этом спрашивают. И вопрос в том, нужно ли стремиться ли в компании с культурой тестирования, или можно просто забить?
flancer
Вчера я бы ответил, что можно забить. Сегодня - что в парном кодировании с ИИ-агентом без юнит-тестов не обойтись.
Завтра вы не сможете себя продать, если не будете способны генерировать код в паре с ИИ. "Силиконовые" производят стандартный код с очень впечатляющей скоростью. Это как копать ямы лопатой и экскаватором. А раз уж "экскаватором", то какая разница, что "копать" - код или тесты для него?