dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

Тесты не доказывают отсутствие бага. Что доказывает

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

10 мин чтения

Коротко

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

Что на самом деле говорит зелёный прогон

Тест — это утверждение о существовании: «есть прогон, на котором система ведёт себя правильно». Набор тестов — много таких утверждений. Ни одно из них и все вместе они не говорят «поведения, которое ломает систему, не существует», — они говорят «мы его не встретили».

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

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

Где эта разница стоит денег

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

Все четыре — не про объём кода, а про число сочетаний. Это и есть область, где перебор состояний реально применим: протоколы, конечные автоматы, блокировки, согласование между сервисами. Обычный CRUD в эту область не входит, и тащить туда проверку моделей незачем.

Что такое проверка моделей

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

ts
const spec = {
  name: 'counter',
  processes: 2,
  init: [{ n: 0 }],
  actions: [
    {
      name: 'p0: увеличить',
      process: 0,
      reads: ['n'],
      writes: ['n'],
      step: (s) => (s.n < 3 ? [{ n: s.n + 1 }] : []),
    },
  ],
  invariants: [{ name: 'n не растёт сверх меры', reads: ['n'], holds: (s) => s.n <= 3 }],
  terminal: (s) => s.n === 3,
};
Спецификация: действие возвращает следующие состояния, пустой список означает «не сработало»

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

Второе: reads, writes и множества симметричных процессов объявляются, а не угадываются. Ровно так же поступает TLA+. Плата за это честная: неверное объявление становится ошибкой в спецификации, а не тихой неполнотой инструмента, который молча выбросил нужные состояния.

Контрпример важнее вердикта

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

text
✗ нарушен инвариант «в критической секции не больше одного процесса»

  трасса
    (начало)                     pc=00 flag=00
    p0: проверил чужой флаг      pc=10 flag=00
    p1: проверил чужой флаг      pc=11 flag=00
    p0: поднял флаг и вошёл      pc=31 flag=10
    p1: поднял флаг и вошёл      pc=33 flag=11
Алгоритм Петерсона, испорченный: сначала проверяем чужой флаг, потом поднимаем свой

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

Почему перебор нельзя делать в лоб

Число состояний растёт как произведение: шесть процессов по пять фаз — это 15 625 сочетаний, и это игрушечный пример. Поэтому содержательная часть инструмента — не поиск, а то, что позволяет не искать.

Спецификация
Состояний: без сокращений → с обоими
workers(4)
625 → 17, выигрыш 37×
workers(6)
15 625 → 25, выигрыш 625×
peterson
20 → 20, выигрыша нет

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

Два способа не искать

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

Коммутирующие действия. Если два действия не трогают ничего общего, порядок между ними не наблюдаем: изучив один порядок, вы ничего не узнаете из второго. Формально это ample-множества, и у них четыре условия, каждое из которых заслужено — выбросьте одно, и проверка начнёт уверенно пропускать нарушения.

Как проверить сам сокращатель

Здесь начинается самое неприятное место всей затеи. Сокращение, которое выбрасывает лишнее, выдаёт ровно тот же ответ, что и правильное: «нарушений не найдено». Изнутри сокращённого поиска отличить одно от другого невозможно.

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

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

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

Живость: система не падает, а просто не доходит

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

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

text
✗ спинлок — возможен бесконечный прогон, где процесс 0 не входит в критическую секцию
  цикл справедлив: процесс 1 продолжает двигаться, процесс 0 заблокирован

  дальше навсегда
    p1: захватил   pc=03 lock=1
    p1: освободил  pc=00 lock=-1
Разница между спинлоком и алгоритмом Петерсона видна ровно там, где должна

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

Чего этот метод не умеет

  • Проверяется модель, а не код. Это главное ограничение и главный риск: доказанная модель и расходящаяся с ней реализация — обычное дело. Модель ловит ошибки замысла, тесты — ошибки исполнения; одно не заменяет другое.
  • Сокращения верны относительно того, что вы объявили. Действие, которое пишет незаявленную переменную, может стоить пропущенных состояний. Это контракт, а не анализ.
  • Только инварианты и одна форма живости. Утверждения вида «всегда верно» и «когда-нибудь случится» под слабой справедливостью. Ни вложенных временных операторов, ни сильной справедливости, ни полного LTL.
  • Всё в памяти. Тысячи и десятки тысяч состояний, а не миллиарды: нет ни хранилища на диске, ни символьного представления.
  • Для серьёзной работы есть TLC и SPIN. За ними десятилетия, дисковые хранилища состояний, распределённая проверка и языки, придуманные под задачу.

Когда это оправдано на коммерческом проекте

Как понять, что вашей задаче нужен перебор, а не ещё десять тестов
  1. 01

    Считаем участников общего состояния

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

  2. 02

    Формулируем инвариант одной фразой

    «Из состояния оплачен заказ не возвращается в ожидает оплаты», «два перевода не спишут больше остатка». Если инвариант не формулируется, проверять пока нечего — сначала договоритесь, что должно быть верно.

  3. 03

    Пишем модель на 30–60 строк, а не всю систему

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

  4. 04

    Полученный контрпример превращаем в тест

    Трасса — готовый сценарий регрессии. Модель осталась в репозитории, тест сторожит реализацию, и обе стороны делают то, что умеют.

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

Частые вопросы

Это то же самое, что TLA+?

Та же идея и те же алгоритмы: описать систему как машину состояний и обойти достижимые. TLA+ с TLC — промышленный инструмент со своим языком, хранением состояний на диске и распределённой проверкой; для реальной верификации протокола берите его. Разница в пороге входа: модель на TypeScript пишется в том же репозитории, что и код, и её читает вся команда, а не один энтузиаст.

Нам придётся описывать систему дважды — моделью и кодом?

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

Что делать, если состояний слишком много?

Уменьшать модель, а не мощность машины: три процесса вместо десяти, две валюты вместо тридцати. Ошибки протоколов почти всегда проявляются на маленьких экземплярах — это известное практическое наблюдение, на нём построена вся работа с моделями. Если и это не помогает, задача уходит к TLC и SPIN.

Зачем вы написали свой model checker, если есть готовые?

Чтобы понимать, чему я доверяю. Сокращения перебора — это код, который может тихо выбрасывать нужные состояния и выдавать тот же зелёный ответ; написав их, вы понимаете, чем именно рискуете, пользуясь чужим инструментом. Код открыт: pnueli, MIT, ссылка в источниках.

Источники

Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.

  1. 01Clarke, Grumberg, Peled. Model CheckingЧастично-упорядоченное сокращение и условия ample-множеств
  2. 02TLA+ и TLCПромышленный инструмент для той же задачи
  3. 03SPINПроверка моделей протоколов, десятилетия практики
  4. 04Amir Pnueli — Turing AwardВременная логика в верификации программ: «всегда» и «когда-нибудь»
  5. 05pnueliИнструмент из этой статьи: MIT, полный перебор рядом с сокращениями
Автор
Дониёр Ботиров
Основатель dbit.one · автор материала

Этот материал подписан именем, в отличие от остальных: за ним не стоит клиент. Код, о котором идёт речь, открыт целиком и лежит под тем же именем — его можно прочитать, запустить и проверить утверждения статьи, не поверив ни одному из них на слово.

Услуги по теме

Читать дальше

Инженерия8 мин чтения

Тест падает раз в триста прогонов. Это не погода

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

Инженерия9 мин чтения

Падает мастер: что на самом деле происходит с вашими данными

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

Инженерия9 мин чтения

Уровни изоляции: что ваша база разрешает на самом деле

Коротко: уровень изоляции — это не настройка производительности, а список того, что вашему приложению разрешено увидеть. Между «read committed» и «serializable» лежат аномалии с именами и с ценой: списание сверх остатка, две смены без дежурного, задвоенный документ. Прочитать в документации, что база «поддерживает snapshot isolation», недостаточно — это свойство исполнений, а не текста.

Остались вопросы по вашему проекту?

Опишите задачу — в течение 24 часов вернёмся с оценкой, сроками и планом.

[email protected]