ATL-2026-014engineering_noteexperimental

Тесты зелёные. Почему сломанный код всё равно проходит?

Опыт на Dart показывает, почему тесты с полным покрытием строк пропустили обратный порядок пунктов выдачи, границу радиуса и отсутствие свободных мест — и какие проверки заметили эти дефекты.

Опубликовано
2 октября 2026 г.
Проверено
29 сентября 2026 г.

Тесты зелёные. Почему сломанный код всё равно проходит?

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

Такое расхождение легко не заметить при ревью. Тест на выбор пунктов есть, функция в нём вызывается, ожидаемые названия перечислены. Отчёт о покрытии тоже выглядит убедительно. Вопрос возникает позже: что именно этот тест обещал обнаружить?

Для проверки в ArkTelos Lab собран небольшой опыт на Dart с обычным package:test. Пункты здесь вымышленные, а дефекты внесены намеренно. Это не найденная ошибка в работающем сервисе. Зато у опыта есть полезное свойство: видно исходный код, требования, конкретные входные данные и реакцию тестов на каждую поломку.

Список правильный. Порядок — нет

Функция selectPickupPoints получает уже подготовленный список пунктов. У каждого есть идентификатор, расстояние, признак приёма заказов и число свободных мест. Она должна оставить пункты, которые принимают заказы, имеют хотя бы одно место и находятся не дальше заданного радиуса. Затем — отсортировать их по расстоянию и вернуть не больше указанного количества. При равном расстоянии порядок определяется идентификатором. Внешний список функция не меняет.

Упрощение здесь осознанное. Расстояния уже посчитаны, сведения о местах получены одним снимком; сетевых запросов и бронирования внутри функции нет. Если данные успели устареть, правильная сортировка сама по себе заказ не спасёт. Но пока вопрос уже: способен ли тест заметить нарушение этого контракта?

Вот существенная часть функции. Проверка отрицательных аргументов перед ней опущена ради чтения; в сохранённом исходнике она есть и тестируется.

final selected = points
    .where(
      (point) =>
          point.acceptsOrders &&
          point.freeSlots > 0 &&
          point.distanceMeters <= maxDistanceMeters,
    )
    .toList();

selected.sort((a, b) {
  final distance = a.distanceMeters.compareTo(b.distanceMeters);
  return distance != 0 ? distance : a.id.compareTo(b.id);
});

return selected.take(limit).toList();

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

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

final points = [
  PickupPoint('far', 900),
  PickupPoint('closed', 300, acceptsOrders: false),
  PickupPoint('near', 200),
  PickupPoint('outside', 6000),
];

expect(
  selectPickupPoints(points, maxDistanceMeters: 5000).map((p) => p.id),
  unorderedEquals(['near', 'far']),
);

PickupPoint — неизменяемая запись. В этом примере неуказанный freeSlots равен 1, а пункт по умолчанию принимает заказы. unorderedEquals проверяет состав без учёта порядка. Для множества результатов это было бы ровно то, что требуется. Но здесь функция возвращает упорядоченный список, а порядок влияет на выбор человека. Когда сравнение расстояний в сортировке развернули, функция вернула ['far', 'near']. Тест остался зелёным: оба имени действительно на месте.

Отдельный тест ограничивает выдачу двумя пунктами, но проверяет только hasLength(2). Если после обратной сортировки из четырёх доступных пунктов выбраны два дальних, длина списка по-прежнему равна двум. Каждая проверка отвечает на свой вопрос правильно. Требование «ближайшие первыми» между ними просто не оказалось записано.

В усиленном наборе вход намеренно перемешан: расстояния 900, 200 и 500 метров. Из правила сортировки вручную получены ожидаемые идентификаторы. Здесь имя points используется для нового списка из трёх записей:

final points = [
  PickupPoint('far', 900),
  PickupPoint('near', 200),
  PickupPoint('middle', 500),
];

expect(
  selectPickupPoints(points, maxDistanceMeters: 5000).map((p) => p.id),
  ['near', 'middle', 'far'],
);

expect(
  selectPickupPoints(points, maxDistanceMeters: 5000, limit: 2)
      .map((p) => p.id),
  ['near', 'middle'],
);

Остальные условия у всех трёх пунктов выполнены. Ожидание записано прямо, а не вычислено второй сортировкой в тесте. При внесённом дефекте результат стал ['far', 'middle', 'near']; обе новые проверки упали. Первая заметила неправильный порядок, вторая — ещё и неверный выбор первых двух.

Это не призыв всегда сравнивать списки строго. Если по контракту порядок безразличен, unorderedEquals уместен. Ошибка появляется тогда, когда тест снимает с результата свойство, которое для вызывающей стороны обязательно.

Две другие поломки, которые тоже прошли

Одного порядка для разбора мало. Исходные тесты проверяли расстояния внутри радиуса и за его пределами, но ни один пункт не находился ровно на границе. В функции условие distanceMeters <= maxDistanceMeters означает, что пункт на расстоянии 5000 метров при радиусе 5000 ещё подходит. Если заменить <= на <, обычные примеры продолжают проходить.

Проверка с расстояниями 4999, 5000 и 5001 сделала различие видимым. Правильная функция вернула ['inside', 'boundary']. После замены условия остался только ['inside']. Речь не о магии «граничных значений». В договорённости есть конкретное слово «не дальше», и оно включает ровно 5000 метров. Тесту нужен вход, на котором две возможные трактовки дают разные ответы.

Другой пропуск касался свободных мест. Исходный набор различал принимающие и закрытые пункты, но все его записи имели положительное freeSlots. Удаление условия freeSlots > 0 поэтому ничего не меняло для использованных данных. В дополнительном примере оба пункта принимали заказы и были внутри радиуса; у первого свободных мест 0, у второго — 1. Вместо ['available'] сломанная функция вернула ['full', 'available'].

Здесь полезно посмотреть на условия входа целиком. Если бы пункт full одновременно оказался закрытым или слишком далёким, соседний фильтр всё равно удалил бы его. Такой тест мог бы пройти даже после удаления проверки свободных мест. Чтобы исследовать одно правило, остальные условия примера должны позволять увидеть его нарушение.

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

Что показал прогон

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

Намеренное изменение поведения Исходные 6 тестов Усиленные 12 тестов
Граница радиуса стала строгой прошли обнаружили
Дальние пункты стали первыми прошли обнаружили
Не учитывается отсутствие свободных мест прошли обнаружили
Выдаются закрытые пункты обнаружили обнаружили
Возвращается лишний пункт обнаружили обнаружили

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

Теперь деталь, из-за которой часто возникает ложное чувство защищённости. Оба набора дали одинаковое покрытие исследуемой функции: 13 из 13 исполняемых строк по LCOV. Эта метрика сообщает, какие строки исполнялись во время тестов. Она не говорит, что все значимые сочетания входных данных встречались и что результат каждой исполненной ветки сравнивался с нужным ожиданием. Строка с сортировкой выполнялась и раньше. Проверки порядка не было.

Покрытие полезно для другого вопроса: какая часть кода вообще не попала в исполнение? Ноль на важной строке — повод разобраться. Но сто процентов по строкам не превращают hasLength(2) в проверку двух ближайших пунктов.

А если код изменился, но тесты остались зелёными?

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

Дополнительный вариант заменял freeSlots > 0 на freeSlots >= 1. Поле имеет целочисленный тип. Для целого числа эти условия равнозначны, поэтому оба набора ожидаемо прошли. Добавлять тест, который обязан различить одинаковое поведение, бессмысленно. Сначала нужно установить, существует ли вход, на котором изменилось требуемое поведение. Здесь такого входа в оговорённой области значений нет. Подобные варианты называют эквивалентными мутациями.

Был и намеренно испорченный синтаксис. Анализатор подтвердил ошибку, но тесты не начали выполняться. Такая запись в общем отчёте не должна превращаться в «тест обнаружил дефект»: ни одно утверждение не проверяло результат функции. Отдельная категория ошибок запуска нужна именно затем, чтобы успешность метода не измерялась любым ненулевым кодом завершения процесса.

В полном наборе появились также проверки порядка при равных расстояниях и сохранения входного списка. Для этих требований отдельные намеренные поломки в опыте не вносились. Поэтому результат «5 из 5» не является оценкой их защищённости.

Что спрашивать у теста при ревью

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

В ревью это можно сделать без запуска специального инструмента. Берётся утверждение, например hasLength(2), и рядом формулируется неисправное поведение, при котором оно всё равно проходит: функция выбирает двух самых дальних. После этого становится ясно, какой вопрос остался без ответа. Следующее действие зависит от цены ошибки: для списка пунктов важен порядок и нужен тест первых двух; для результата, где порядок не определён, менять unorderedEquals на строгое сравнение было бы уже ложным требованием. Тест должен уточнять контракт, а не случайно сужать его.

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

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

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


Другие инженерные разборы — в ArkTelos Lab. Об экосистеме и её решениях — на официальном сайте ArkTelos.

Telegram: ArkTelos Lab RU | ArkTelos Lab EN | ArkTelos RU | ArkTelos EN.

Границы результата

  • Учебный Dart-сценарий с намеренно выбранными дефектами; 2/5 и 5/5 относятся только к этой пятёрке. Покрытие 13/13 строк не измеряет полноту ветвей, сценариев или правильность требований. Не проверялись UI, сеть, актуальность наличия и бронирование.

CODE / DATA / AGENTS

Связанные эксперименты

experimentgreen-tests-mutation-trial

Сравнить, какие намеренные нарушения контракта выборки замечают исходные и усиленные тесты.

Учебный Dart-сценарий с намеренно выбранными дефектами; 2/5 и 5/5 относятся только к этой пятёрке. Покрытие 13/13 строк не измеряет полноту ветвей, сценариев или правильность требований. Не проверялись UI, сеть, актуальность наличия и бронирование.

Открыть паспорт эксперимента →

Версия в Telegram

Читать в ArkTelos Lab

Читать в ArkTelos Lab ↗

КАНАЛЫ ARKTELOS

Новости и инженерные материалы — без смешивания языков.

Основные каналы рассказывают о развитии ArkTelos. ArkTelos Lab публикует архитектурные разборы, эксперименты и воспроизводимые исследования.