2026 г.

Тестирование сегодня: чему верят тесты

Проект ЦИТадель

О тестировании написано много учебников, но обсуждение часто сводится к отдельным приёмам и процентам покрытия. Между тем каждый успешный прогон содержит ограниченное утверждение о программе. Чтобы оценить его ценность, нужно понять, какое поведение проверено, через какую границу и какой ценой. Формулировка Дейкстры сохраняет силу: тестирование показывает наличие ошибок, но не их отсутствие. Тесты не доказывают полную корректность программы, однако дают конкретную информацию о её поведении.

В разделе 1 рассматривается основная ценность регрессионных тестов. Раздел 2 сравнивает силу примеров, свойств, моделей и типов. Раздел 3 посвящён границе между поведением и реализацией, раздел 4 — экономике тестовой пирамиды, раздел 5 — двойникам и контрактам. Затем разбираются слепые зоны обычных тестов, ограничения покрытия, мутационное тестирование и роль проверок при генерации кода. Завершает статью практикум на собственном проекте.

1. Основная ценность регрессионных тестов

Тесты помогают находить дефекты до выпуска, но ни один конечный набор не охватывает все возможные входы и состояния. Долгосрочная ценность регрессионного набора шире: он снижает стоимость и риск изменений. Без надёжной проверочной сети команда начинает избегать рефакторинга и обновления зависимостей, потому что последствия каждой правки неизвестны. Так появляется наследуемый код в смысле Майкла Физерса — код, который трудно безопасно изменять из-за отсутствия тестов. С хорошим регрессионным набором значительная часть последствий правки становится известна за минуты. На этом основаны небольшие партии и частые слияния, описанные в статье «Инженерный конвейер»: быстрый конвейер без содержательных тестов лишь быстрее доставляет непроверенные изменения.

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

2. Лестница утверждений: пример, свойство, модель, тип

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

Пример проверяет конкретное соответствие: «для этого входа ожидается такой выход». Даже большой набор примеров оставляет непроверенными промежутки и крайние случаи — пустые, отрицательные или необычно большие значения.

Свойство формулирует инвариант, который должен выполняться для целого класса входов. Например: после сериализации и обратного разбора получается исходное значение; результат сортировки упорядочен, имеет прежнюю длину и содержит те же элементы. Тестирование на основе свойств (property-based testing) создаёт множество примеров, включая крайние случаи, но не доказывает свойство для всех возможных входов. Сила метода в другом. Во-первых, формулировка свойства заставляет явно записать обещание функции. Во-вторых, после обнаружения ошибки инструмент выполняет минимизацию контрпримера (shrinking): вместо длинной случайной строки может остаться один символ, достаточный для воспроизведения сбоя.

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

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

Связанные примеры рассмотрены в главе 11 курса о распределённых системах, а роль типов — в статьях «Как современные языки обращаются с ошибками» и «Современный JavaScript и TypeScript».

3. Граница: тестировать поведение, не реализацию

Следующий вопрос к тесту — через какую границу сформулировано утверждение. Устойчивый тест обращается к модулю через публичный интерфейс и проверяет наблюдаемое поведение. Тест, тесно связанный с реализацией, фиксирует приватное состояние, последовательность внутренних вызовов или детали алгоритма. Его характерный признак — хрупкость: рефакторинг при неизменном поведении вызывает множество падений. Это не безусловный приговор отдельному тесту, но повод проверить, не зафиксировал ли набор внутреннее устройство вместо контракта.

Особенно слабы проверки, которые повторяют алгоритм основной реализации. Одинаковая ошибка может оказаться и в коде, и в тесте, поэтому совпадение результатов не даёт независимого подтверждения. Тесты полезнее, когда ожидаемое поведение выводится из контракта, свойства, модели или заранее подготовленного примера.

Порядок «сначала утверждение, потом код», используемый в разработке через тестирование (TDD), помогает отделить ожидание от реализации, но сам по себе не гарантирует качественной границы. Даже написанный заранее тест можно спроектировать вокруг будущих внутренних вызовов. Если же ожидания сформулированы через публичный контракт и регулярно выполняются, набор становится исполняемой спецификацией: расхождение между кодом и зафиксированным поведением проявляется как падение теста. В разделе 8 этот принцип будет применён к генерации кода.

4. Пирамида — это экономика

Классическая пирамида предлагает много быстрых модульных тестов, меньше интеграционных и немного сквозных. Её полезно понимать как экономическую модель, а не обязательную форму. Быстрая обратная связь важна для инженерного конвейера, поэтому часто выполняемые проверки должны быть дешёвыми. Но модульный тест видит компонент в изоляции, а двойники зависимостей могут расходиться с реальными системами (раздел 5). Набор успешно проверенных частей ещё не гарантирует их правильного взаимодействия.

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

5. Двойники и контракты: когда предположение расходится с реальностью

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

Контрактные тесты делают такое предположение отдельным проверяемым артефактом. Сервер и клиент проверяются против общей спецификации. Например, проверка по описанию OpenAPI может контролировать структуру запросов, ответов и часть ограничений, как показано в статье «JSON и дизайн API». При этом спецификация интерфейса не заменяет проверку всей предметной семантики. Потребительский контракт фиксирует минимальные ожидания конкретного клиента, а поставщик проверяет их до выпуска изменения.

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

6. Слепые зоны: конкурентность, время, недетерминизм

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

Конкурентность. Один прогон охватывает лишь одно из множества возможных переплетений потоков. Даже тысячи повторов могут не воспроизвести редкую гонку. Поэтому обычные тесты дополняют детекторами гонок, стресс-прогонами, управляемыми планировщиками и анализом инвариантов. Важно помнить, что динамический детектор видит только выполненные пути. Подробнее эта проблема разобрана в главе 11 курса о распределённых системах и статье «Конкурентность в современных языках».

Время. Тест с реальным ожиданием медлен и зависит от загрузки машины. Код, который напрямую читает системные часы, трудно проверять на календарных границах. Решение находится в архитектуре: часы передаются как зависимость, а в тесте заменяются виртуальными. Тогда переход на тридцать дней вперёд выполняется мгновенно и воспроизводимо.

Недетерминизм. Его источниками становятся случайность без сохранённого зерна, неопределённый порядок обхода, сеть, внешний сервис или общая база между тестами. Всё это может породить мигающие тесты. В статье «Инженерный конвейер» объяснено, почему привычка перезапускать такие проверки разрушает доверие к CI. После первого мигания нужно установить причину: иногда дефект находится в тесте, а иногда тест обнаружил настоящую гонку в программе. Карантин допустим как временная мера, но не заменяет расследование.

7. Покрытие и мутационное тестирование

Покрытие показывает, какие строки или ветви исполнялись во время тестов. Оно не сообщает, насколько содержательными были утверждения: вызов функции без проверки результата может дать почти тот же отчёт, что и сильный тест. Поэтому высокий процент покрытия совместим со слабым набором проверок. Если сделать процент самостоятельной целью, проявится закон Гудхарта, рассмотренный в статье «Разработка с ИИ-ассистентами»: показатель начнут улучшать самым дешёвым способом. При этом покрытие остаётся полезным диагностическим инструментом. Неисполненный код не был проверен этим прогоном; отчёт помогает найти такие пробелы, но сам по себе не оценивает качество тестов.

Мутационное тестирование задаёт более предметный вопрос: обнаружит ли набор небольшое изменение поведения? Инструмент меняет условие, сдвигает границу или удаляет вызов и запускает тесты. Если хотя бы один тест падает, мутант считается убитым. Выживший мутант требует анализа: он может указывать на отсутствующую проверку, но может оказаться эквивалентным — синтаксически изменённым, но сохраняющим поведение программы. Поэтому мутационный балл нельзя трактовать механически. Такой анализ обычно дороже подсчёта покрытия, и его разумно применять выборочно к критичным модулям или запускать периодически.

8. Тесты в эпоху генерации кода

Ассистенты снизили стоимость написания тестов, но не решили проблему независимого ожидаемого результата. Как показано в статье «Разработка с ИИ-ассистентами», проверки, сгенерированные только из реализации, склонны закреплять её текущее поведение вместе с ошибками. Они могут быть полезны для фиксации существующего поведения перед рефакторингом, но не служат независимым подтверждением его правильности.

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

9. Практикум: аудит собственного набора

Задания выполняются на рабочем или учебном проекте. В примерах используется Python, но методы переносятся на другие языки; в задании 6 применяется пример на Go из связанной статьи о конкурентности.

Задание 1. Свойство вместо примеров. Выберите чистую функцию с несколькими тестами-примерами и сформулируйте свойство: обратимость кодирования и декодирования, идемпотентность нормализации или инварианты сортировки. Подключите Hypothesis и запустите тест на генерируемых входах. Если найден сбой, изучите минимизированный контрпример и сравните его с исходным набором примеров.

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

Задание 3. Мутационный аудит. Запустите mutmut или аналог для своего языка на одном критичном модуле либо внесите несколько мутаций вручную: измените условие, сдвиньте границу, удалите вызов. Для каждого выжившего мутанта определите, отсутствует ли проверка или мутация эквивалентна исходной программе. Сопоставьте результат с покрытием того же модуля.

Задание 4. Контракт вместо предположения. Возьмите API из практикума статьи «JSON и дизайн API» или собственный интерфейс и добавьте контрактную проверку ответов по OpenAPI-спецификации. Затем найдите в клиентских тестах двойник внешнего сервиса и проверьте, соответствует ли он текущему контракту реальной системы.

Задание 5. Часы как зависимость. Найдите тест с реальным ожиданием или зависимостью от текущей даты. Передайте часы в код как зависимость и перепишите проверку на виртуальном времени. Сравните продолжительность и устойчивость многократных прогонов до и после изменения.

Задание 6*. Слепая зона своими глазами. Возьмите счётчик с намеренной гонкой из практикума статьи «Конкурентность в современных языках». Обычный тест может многократно проходить, не воспроизводя ошибку. Затем запустите Go с официальным детектором гонок (go test -race) и добавьте стресс-прогон. Объясните, почему обычного утверждения недостаточно и почему динамический детектор не даёт гарантий для невыполненных путей.

Итоги

  • Тестирование обнаруживает конкретные ошибки, но не доказывает их отсутствие. Регрессионный набор снижает стоимость и риск изменений и служит основой быстрого инженерного конвейера.
  • Пример проверяет отдельную точку, property-based testing исследует сформулированное свойство на множестве входов, модель даёт независимый эталон, а система типов исключает некоторые классы состояний. Эти методы дополняют друг друга.
  • Устойчивые тесты проверяют поведение через публичные границы. Падения при рефакторинге помогают обнаружить излишнюю связь с реализацией, но каждое такое падение требует анализа.
  • Пирамида — экономическая модель, а не обязательная форма. Состав набора зависит от системы, стоимости выполнения и объёма поведения, проверяемого на каждом уровне.
  • Тестовый двойник кодирует предположение о зависимости. Общая спецификация, контрактные проверки и собственный адаптер помогают не допустить расхождения с реальной системой.
  • Обычные тесты имеют слепые зоны: конкурентность требует специальных инструментов, время лучше передавать как зависимость, а причины недетерминированных падений необходимо расследовать.
  • Покрытие показывает исполненный код, но не силу утверждений. Мутационное тестирование оценивает реакцию набора на небольшие изменения, однако выжившие мутанты нужно проверять на эквивалентность.
  • При генерации кода ожидаемое поведение важно задавать независимо от реализации. Программный агент может опираться на тесты только в пределах качества набора и его известных слепых зон.

Литература

  1. E. W. Dijkstra, "Notes on Structured Programming", EWD 249, 1969 (второе издание — 1970) — источник формулировки об ограничениях тестирования.
  2. K. Claessen, J. Hughes, "QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs," ICFP 2000 — основополагающая работа о тестировании свойствами; документация Hypothesis описывает современный инструмент для Python.
  3. M. Feathers, "Working Effectively with Legacy Code," 2004 — работа с кодом, который трудно безопасно изменять из-за отсутствия тестов.
  4. M. Fowler, статьи о пирамиде тестов, двойниках и контрактных тестах. martinfowler.com/testing
  5. D. Schuler, A. Zeller, "Covering and Uncovering Equivalent Mutants," 2013 — проблема эквивалентных мутантов. doi.org/10.1002/stvr.1473
  6. Материалы нашего сайта: курс «Распределённые системы», глава 11 (свойства и инварианты целых систем), «Инженерный конвейер» (скорость обратной связи и мигающие тесты), «JSON и дизайн API» (спецификация как контракт), «Конкурентность в современных языках» и «Разработка с ИИ-ассистентами» (слепые зоны и независимый контракт).
404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией