В этом топике я не пытаюсь доказать, что тесты бесполезны. Это скорее мои размышления вслух и личная попытка нащупать их реальную ценность. Некоторые идеи в процессе всё-таки зацепили - но скорее ккак частные случаи, а не что-то универсальное.

Я программирую уже больше шести лет. На самом деле существенно больше (на свой первый аутсорс на 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 для меня выглядит как способ принять решения до этапа исследования - работает для уже изученных задач, но мешает, когда цель ещё не ясна. Возможно, дело в контексте - моих проектах, подходе к логированию или просто нехватке удачных примеров. Но если я действительно что-то упускаю - я хочу это понять. Даже если придётся пересобрать часть привычной практики.

Нельзя научиться чему-то, если бояться выглядеть глупо. Поэтому буду рад комментариям и приглашаю к обсуждению.

Комментарии (17)


  1. flancer
    24.06.2025 07:40

    Я когда-то тоже думал на эту тему. Если кратко, то чем больше в коде решений, чем больше в команде разработчиков, чем более стабильна предметная область - тем осмысленнее применение юнит-тестов. Нет смысла писать юнит-тесты, если проект ведётся в "одну голову" или пишется "одноразовый" код. Но смысл появляется, если предполагается, что проект будет эволюционировать или количество разрабов станет больше одного. Хотите набраться реального опыта в юнит-тестировании - возьмите "силиконового" напарника ;)


    1. absurd_logik Автор
      24.06.2025 07:40

      Ну собственно эта мысль и прослеживается в топике :) В условиях изначально продуманной структуры они действительно полезны.

      Но дело в том, что я практически не работаю в таких условиях, а вот на собеседованиях периодически об этом спрашивают. И вопрос в том, нужно ли стремиться ли в компании с культурой тестирования, или можно просто забить?


      1. flancer
        24.06.2025 07:40

        Вчера я бы ответил, что можно забить. Сегодня - что в парном кодировании с ИИ-агентом без юнит-тестов не обойтись.

        Завтра вы не сможете себя продать, если не будете способны генерировать код в паре с ИИ. "Силиконовые" производят стандартный код с очень впечатляющей скоростью. Это как копать ямы лопатой и экскаватором. А раз уж "экскаватором", то какая разница, что "копать" - код или тесты для него?


  1. brutfooorcer
    24.06.2025 07:40

    Тесты помогают:

    • Исключить ошибки невнимательности

    • Понимать, что при изменении кодовой базы функционал работает корректно

    • Планировать и структурировать код

    • Убедиться в работоспособности перед выпуском на стенд

    Если у вас появляются новые кейсы (все предусмотреть действительно невозможно) - просто добавляйте еще один тест и все)

    А необходимость всего этого каждый решает для себя сам. Ну, либо это регламентируется правилами на проекте


  1. dmitrysbor
    24.06.2025 07:40

    Статью не читал, но с посылом согласен. Пирамида тестирования известна давно: юнит тесты в основании пирамиды тестирования, их много, но вклад в покрытие функционала мизерный. Их девы пишут для себя, чтобы просто прошёл мёрж реквест. Никакой дополнительной пользы они не несут.


    1. summerwind
      24.06.2025 07:40

      А то что юнит-тесты позволяют очень точно протестировать бизнес-логику и покрыть граничные случаи, которые или очень тяжело или вообще невозможно покрыть огромными интеграционными тестами - это конечно же БЕСПОЛЕЗНО :)


      1. dmitrysbor
        24.06.2025 07:40

        Бизнес логику покрывают фукнциональные и приёмочные тесты. Граничные условия покрывают тоже функциональные тесты. Итеграционные тесты проверяют протокол взаимодействия компонент по API. И да, юнит тестам тут места нет.


        1. summerwind
          24.06.2025 07:40

          Приёмочные и функциональные тесты проверяют сквозные пользовательские сценарии (happy path + несколько граничных) и требуют обычно полного запуска системы с базой, брокером и т.п.

          Если вместо использования юнит-тестов, вы будете пытаться тестировать все инварианты через перебор комбинаций входов в функциональных тестах, вам нужен будет по меньшей мере квантовый CI-сервер, потому что это будет очень-очень долго и тяжеловесно.

          В нормальной разработке вместо этого юнит-тесты обычно используются чтобы быстро прогнать много граничных комбинаций за секунды и показать точный метод, где упал инвариант. Поэтому, говорить что юнит-тесты не нужны значит недооценивать само их предназначение.


          1. dmitrysbor
            24.06.2025 07:40

            Перебор вариантов будет в любов случае. И это корневая задача тестирования объять необъятное: ограниченным числом тестов покрыть огромный функционал. И отчасти эту проблему решает white box когда сокращается количество изначально неработающих вариантов путём изучения кода и бизнес кейсов заказчика. Именно эти тесты будут гонятся постоянно в регрессии и будет гарантом функционала, который ещё не сломали. Большая часть юнит тестов вообще не попадёт в CI в виду flakebility (ломкости).


            1. summerwind
              24.06.2025 07:40

              Юнит-тесты это чаще всего и есть самый точный и дешевый способ сделать то, что делается при white-box тестировании.

              Если юнит-тест то проходит, то нет, это вопрос к тем кто его писал, а не к самой концепции. Даже более того, за счет того что в юнит-тестах нет сети, БД и тому подобного, шанс flakebility при правильном написании даже ниже, чем у функциональных/приемочных тестов.

              Ну, и гораздо полезнее, когда CI загорается красным сразу после некорректного коммита, а не спустя полдня, когда упал регресс-ран из e2e-сценариев.

              Если вы отключаете хрупкие юнит-тесты в CI, а не чините их, то это опять вопрос к тем, кто работает над проектом, а не к самой концепции.


              1. dmitrysbor
                24.06.2025 07:40

                Красные тесты на каждый коммит это не есть хорошо и не лучшая практика отладки кода. Отлаживаться на юнит тестах надо не в CI, а где то там локально, на кошках. В CI должен попадать хоть как то отлаженный код (а не как это принято сейчас пушить что попало без тестирования) для чего и служат юнит тесты. При этом самим юнит тестам быть в CI вовсе не обязательно.


                1. summerwind
                  24.06.2025 07:40

                  Речь не про отладку, а про единую точку правды и скорость обнаружения ошибок. Ведь человеческий фактор никто не отменял. Но да ладно.

                  Что могу сказать, могу пожелать только удачи с подходами "отключать тесты в CI" и надеяться, что разработчики будут все тесты запускать локально.

                  Как пошутили где-то в комментариях, с такими подходами можно просто в прод всё катить, и пусть пользователи тестируют :)


                  1. dmitrysbor
                    24.06.2025 07:40

                    Можно подумать, что разрабские юнит тесты это гарантия хоть чего-нибудь, а уж тем более качества. Если у вас на прод попадают тесты только после юнит тестов и у вас нет функционального тестирования, CI и прочих видов тестирования, то пожалуй ой.


  1. shoorick
    24.06.2025 07:40

    1. А что вы считаете очевидным? Да, в assert 0.1 + 0.2 == 0.3 смысла нет, но в assert some_your_sum(0.1, 0.2) == 0,3 он уже появляется. Тестируются не встроенные операторы, а то, что мы сами пишем, ну или то внешнее, чему доверяем, но всё-таки проверяем.


    1. flancer
      24.06.2025 07:40

      Я думаю, тут дело в плавающей запятой, а не в юнит-тестах.


  1. XVlady5
    24.06.2025 07:40

    Кажется дзен TDD так и не понят, что тесты пишутся до кода и как это будет проверяться проектируется одновременно с системой.

    Вот честно, проще тесту скормить кусок непонятных данных отброшенных в лог, чем страдать ловя их в потоке.

    И это хорошо когда данные просто поменялись, а когда начинается подмес чего-то нового? А потом ещё и ещё? Вам же нужно стабильно обрабатывать и первое и второе и третье. Без тестов тут такой замес можно устроить...

    И ещё раз - пишите тесты до кода и на все уровни сразу. Хотя-бы их канву...


  1. AbitLogic
    24.06.2025 07:40

    Уже как-то формулировал свой взгляд...

    Совет из TDD: Начинайте писать тест ДО того как создали метод, убеждаемся что тест не проходит и только потом создаём метод который проходит тест, рефакторим, убеждаемся что проходит, думаем а какой функционал ещё нужен? добавляем новый тест, убеждаемся что он не проходит, меняем или добавляем новый метод, чтобы он его проходил, рефакторим и смотрим чтобы уже два теста проходили, и так накручиваем

    Такой способ даёт 100% покрытие и 100% предсказуемость, а так же крепкий сон, исключает драмы в стиле почему когда я парсил json у меня дропнулась таблица в бд

    если ты не знаешь чего тестировать - скорее всего либо тебе не ясна задача, либо тебе дали задание в стиле "сделай как нибудь" , значит и делай как нибудь, но я так не делаю, потому что потом всё будешь переписывать, руководствуюсь другим принципом - стараюсь делать хорошо, хренова всегда получится, когда ты пишешь тест ты четко понимаешь что ты хочешь на выходе при таком входе, а значит и проще объяснить железяке, поэтому относиться ко времени на тесты нужно как на проектирование, а не само программирование

    Но упарываться в юнит-тесты тоже не нужно, если как минимум три исключения:

    1) не нужно тестировать приватные методы, не нужно ничего открывать во вне, только интерфейс, который задуман, покрытие обеспечивается косвенными методами

    2) не нужно пихать в тесты бизнес логику, её нужно тестировать, а не сувать в тест, иначе тест станет хрупким и бесполезным

    3) не нужно загрязнять тестами release код, продукт ничего не должен знать о тестах, иметь какие-то скрытые параметры и методы только для прохождения теста