Перейти к содержимому
6 мин чтения

Находят ли property-based тесты ошибки сгенерированного кода в парсерах?

Property-based тесты для парсеров превращают инварианты в генераторы, которые выявляют дефекты границ, обратных преобразований и валидации, пропущенные сгенерированным кодом.

Находят ли property-based тесты ошибки сгенерированного кода в парсерах?
Содержание

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

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

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

Сгенерированному коду сначала нужен контракт, а уже потом новые примеры

У парсера есть как минимум три контракта, и команды часто смешивают их в один.

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

Возьмём формат конфигурации с полем port. Синтаксис может разрешать и "8080", и 8080. Разобранная структура может представлять оба значения числом JavaScript. Прикладное правило может разрешать только целые числа от 1 до 65535. Если проверить лишь parse("{\"port\":8080}"), вы ещё не решили, допустимы ли строки, округляются ли дроби, отклоняется ли 0 и не теряет ли огромное целое точность до запуска валидатора.

Запишите контракт в форме, которую может использовать тест. Формальная нотация не обязательна, но решения должны быть видны:

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

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

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

Циклы преобразования доказывают разное в каждом направлении

parse(print(value)) и print(parse(text)) звучат как взаимозаменяемые проверки. Это не так. Если считать их одним и тем же свойством, можно получить ложные ошибки в одном направлении и пропустить настоящие дефекты в другом.

Для пары сериализатора и парсера самым сильным обычным свойством обычно будет такое:

const reparsed = parse(print(value));
expect(reparsed).toEqual(normalize(value));

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

Обратное свойство начинается с текста:

const result = parse(text);
if (result.ok) {
  expect(print(result.value)).toBe(canonicalize(text));
}

Оно проверяет, получает ли принятый текст одно стабильное представление. Это корректно только при наличии определённой канонической формы. Объекты JSON могут менять порядок полей. Пробелы могут исчезать. Десятичное 1.0 может выводиться как 1. Дата может преобразовываться из формы со смещением в UTC. Сравнение вывода с исходным текстом ошибочно примет корректную нормализацию за дефект.

Используйте в наборе тестов три отдельных обозначения:

  • Потеря-недопустимый цикл: print(parse(text)) === text. Оставьте его для форматов, обещающих сохранение байтов.
  • Семантический цикл: parse(print(value)) === normalize(value). Это основной вариант для типизированных значений.
  • Каноникализация: print(parse(text)) === canonicalize(text). Применяйте её только после точного определения канонического текста.

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

Вместо этого создайте нормализатор, отражающий контракт. Если отсутствие поля enabled означает true, приводите и {}, и { enabled: true } к одному внутреннему значению. Если вход допускает строковый ID с начальными нулями, а внутреннее значение хранится как число, явно опишите это преобразование. Не позволяйте глубокой проверке равенства решать за вас поведение продукта.

Тесты границ должны атаковать решение, а не число

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

Если поле принимает целое от 1 до 100, случайная генерация из большого диапазона почти полностью расходует запуски впустую. Генератор должен часто выдавать -1, 0, 1, 2, 99, 100 и 101. Он также должен создавать значения, похожие на числа, но не являющиеся допустимыми числами по вашему контракту: "1", "01", "1.0", " 1", "1e2", NaN, Infinity, а также значения за пределами безопасного диапазона целых чисел, если входной путь способен их представить.

Компактный генератор границ на TypeScript может выглядеть так:

import * as fc from "fast-check";

const portCandidates = fc.constantFrom(
  -1, 0, 1, 2, 65534, 65535, 65536,
  Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER + 1
);

const portLike = fc.oneof(
  portCandidates,
  fc.integer({ min: -100, max: 100000 }),
  fc.constantFrom("0", "1", "65535", "65536", "01", "1.0", " 80", "80 ")
);

fc.assert(
  fc.property(portLike, (candidate) => {
    const result = validatePort(candidate);
    const allowed = typeof candidate === "number"
      && Number.isSafeInteger(candidate)
      && candidate >= 1
      && candidate <= 65535;

    expect(result.ok).toBe(allowed);
  })
);

У этого примера есть ограничение, которое стоит назвать прямо. Он предполагает, что validatePort принимает только числовые значения JavaScript. Если API намеренно преобразует числовые строки, измените allowed, чтобы выразить это правило, и добавьте проверку нормализованного результата. Не позволяйте реализации выбирать правила преобразования только потому, что JavaScript делает это удобным.

Границы относятся также к длине, вложенности, размеру коллекции, числу токенов и величине числа. Для максимальной длины строки нужны проверки на max - 1, max и max + 1, но этого мало. Проверьте строку с такой же видимой длиной, составленную из многобайтных символов, если реализация считает байты. Проверьте объединяющие символы, если продукт считает воспринимаемые пользователем символы. Проверьте длинный префикс с некорректным последним токеном, если парсер накапливает данные до отказа.

Вопрос всегда один: где код решает принять, отклонить, выделить память или перейти к рекурсии? Генерируйте случаи вокруг этого решения.

Идемпотентность обнаруживает дрейф нормализации

Идемпотентность означает, что после первой нормализации повторное применение той же операции не меняет результат. Для валидаторов это выявляет класс дефектов, который редко ловят обычные примеры.

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

Свойство простое:

fc.assert(
  fc.property(rawRequestArb, (raw) => {
    const first = validateAndNormalize(raw);
    if (!first.ok) return true;

    const second = validateAndNormalize(first.value);
    expect(second).toEqual(first);
  })
);

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

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

Идемпотентность также проясняет спор о значениях по умолчанию. Значение по умолчанию в схеме не обязательно является значением по умолчанию во время выполнения. Документация JSON Schema отделяет аннотации от проверок, а default часто является метаданными, а не указанием валидатору менять данные. Если приложение подставляет значения, тестируйте это как собственное правило нормализации. Не приписывайте это языку схемы без основания.

Для некорректных входных данных нужен отдельный генератор и модель исходов

Оцените риски сгенерированного парсера
Найдите самые затратные инженерные задачи, прежде чем изменения в сгенерированном парсере попадут в продакшен.

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

Создавайте генераторы некорректных данных по классам нарушений. Синтаксический генератор должен проверять сломанные разделители, оборванные строки, неправильные escape-последовательности, неожиданные токены и некорректные кодировки, если транспорт их допускает. Структурный генератор должен брать документ, который в остальном можно разобрать, но содержать неправильный тип, неожиданное поле, дубликат поля, отсутствие обязательного поля или неверный дискриминатор. Генератор прикладных нарушений должен сохранять форму модели, но нарушать межполяхные правила и ограничения.

Для каждого класса ожидается свой результат. Такая модель помогает не смешивать утверждения:

type Outcome<T> =
  | { kind: "ok"; value: T }
  | { kind: "parse_error"; offset: number; message: string }
  | { kind: "validation_error"; issues: Issue[] };

Сбой не является Outcome. Тайм-аут не является Outcome. Возврат {} после ошибки парсинга не является Outcome. Пусть тестовый стенд отдельно сообщает о выброшенных исключениях, необработанных отклонённых промисах и превышении заданного времени выполнения.

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

Вот повторяющийся дефект в сгенерированных валидаторах. Код проверяет typeof value === "object", а затем читает value.profile.email. В JavaScript null проходит первую проверку, и следующая строка вызывает исключение. Ассистент мог создать тесты корректного объекта, объекта без profile и объекта с некорректным email. Но он часто не генерирует null, массив, функцию, proxy или getter, который выбрасывает исключение.

Не заставляйте каждый валидатор защищаться от экзотических объектов, если граница уже преобразовала вход в обычный JSON. Но явно определите эту границу. Если валидатор принимает произвольные значения времени выполнения, генерируйте произвольные значения. Если он принимает только декодированный JSON, тестируйте декодер и валидатор вместе там, где это обещание становится действительным.

Межполяхные правила особенно часто заставляют ассистентов гадать

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

Предположим, запрос на развёртывание содержит mode, replicas, minReplicas, maxReplicas и regions. Типы полей сообщают немного. Контракт может требовать minReplicas <= replicas <= maxReplicas, одного региона для одного режима, минимум двух регионов для другого и лимита, который меняется при включённом feature flag.

Неправильный подход, генерировать произвольные объекты и помещать большое условие перед утверждением:

fc.property(deploymentArb, (x) =>
  fc.pre(isValidDeployment(x)),
  () => expect(validate(x).ok).toBe(true)
);

Если корректные развёртывания занимают небольшую часть пространства входных данных, фреймворк отбросит большинство случаев. Запуск может выглядеть насыщенным, хотя проверка принятия почти не происходит. Исследования property-based тестирования с управлением покрытием показывают то же практическое следствие: разреженные предусловия тратят сгенерированные входы. Иногда помогают управление покрытием или mutation testing, но прямой генератор обычно быстрее всего сделать первым.

Создайте корректный объект из корректного ядра. Сначала сгенерируйте minReplicas. Затем создайте maxReplicas, не меньше этого значения. После этого выберите replicas внутри диапазона. Для свойств отказа изменяйте ровно одно отношение за раз.

const validScaleArb = fc.integer({ min: 0, max: 20 }).chain((min) =>
  fc.integer({ min, max: 30 }).chain((max) =>
    fc.integer({ min, max }).map((replicas) => ({ min, max, replicas }))
  )
);

const invertedScaleArb = validScaleArb.map(({ min, max, replicas }) => ({
  min: max + 1,
  max,
  replicas
}));

Теперь утверждения могут быть точнее. Любое значение из validScaleArb должно пройти. Любое значение из invertedScaleArb должно завершиться ошибкой, причём проблема должна быть привязана к отношению между значениями масштаба, а не к случайному полю. Если сообщение изменится после рефакторинга, тест сохранится. Если код примет min > max, тест даст компактный контрпример.

Именно здесь человеческое решение нужнее всего. Ассистент может выразить min <= max, если вы явно сообщили это правило. Но по одним названиям он не поймёт, означает ли ноль реплик «пауза», «недопустимо» или «автомасштабирование отключено».

Shrinking является частью интерфейса отладки

Получите ревью валидаторов от CTO
Привлеките fractional CTO, чтобы разобраться в контрактах валидаторов и границах обработки запросов.

Property-based тест, который сообщает о входе длиной 40 000 символов, случайном seed и общем падении утверждения, быстро приобретёт репутацию источника шума. Инженеры снизят число запусков или отключат набор. Проблема не в property-based тестировании. Просто генератор и отчёт об ошибке оставили напоследок.

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

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

Для некорректных случаев решите, нужно ли сохранять некорректность во время уменьшения. Генератор неправильного JSON, который удаляет произвольные символы, может уменьшить пример до корректного JSON, и свойство завершится ошибкой уже по другой причине. При необходимости используйте tagged case:

type InvalidCase =
  | { kind: "unterminated_string"; text: string }
  | { kind: "duplicate_name"; text: string }
  | { kind: "too_deep"; text: string };

Метка задаёт ожидаемую категорию и добавляет в отчёт контекст, которого нет в обычном тексте.

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

Метаморфные тесты полезны, когда идеального эталона нет

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

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

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

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

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

Практическое метаморфное свойство для парсера идентификаторов без учёта регистра выглядит так:

fc.assert(
  fc.property(identifierArb, (id) => {
    const lower = parseIdentifier(id.toLowerCase());
    const upper = parseIdentifier(id.toUpperCase());

    expect(upper).toEqual(lower);
  })
);

Это свойство неверно, если идентификаторы чувствительны к регистру, если регистр зависит от локали или если identifierArb включает символы за пределами контракта. Написать код легко. Сложность в том, чтобы установить контракт.

Размещайте тесты сгенерированного парсера на границе доверия

Наибольшая отдача получается, когда парсер или валидатор тестируется там, где непроверенный вход превращается в типизированный бизнес-объект. Это может быть HTTP-запрос, файл конфигурации, задача импорта, webhook или сообщение из очереди. Тестировать внутренние вспомогательные функции произвольными значениями полезно, но это не оправдывает слабый тест границы.

Используйте несколько уровней. Оставьте несколько написанных вручную примеров для прикладных правил, которые должны быстро читать люди. Разместите property-based тесты вокруг чистых функций парсинга, нормализации и валидации. Добавьте интеграционные тесты на транспортной границе для кодировок, ограничений размера тела и сериализации ошибок. Каждый уровень должен отвечать за свой класс отказов.

Не начинайте с запроса к ассистенту «добавь property-based тесты». Передайте ему выбранные вами инварианты, генераторы и типы исходов. Просите реализовать по одному свойству за раз, затем проверяйте сгенерированные случаи и число отброшенных значений. Если позволить ассистенту выбрать область самостоятельно, он охотно создаст тест, который проходит, почти не генерируя релевантных входных данных.

Команде, внедряющей инструменты разработки с ИИ в зрелую кодовую базу, стоит рано проверить такие границы в рамках Team & AI Audit. Экономия появляется не из-за большего числа тестовых файлов. Она возникает, когда сгенерированные изменения не могут незаметно расширить набор принимаемых входов, ослабить валидацию или превратить некорректные запросы в инциденты во время выполнения.

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

Часто задаваемые вопросы

Почему property-based тесты полезны для парсеров, созданных ИИ?

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

Нужно ли генерировать корректные или некорректные входные данные для тестов парсера?

Используйте оба типа, но поручите им разные задачи. Создавайте корректные модели для проверки циклов парсинга и вывода, а затем генерируйте вредоносный текст для проверки отказов, позиций ошибок, ограничений ресурсов и восстановления после ошибок.

Означает ли parse(print(value)) то же самое, что print(parse(text))?

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

Влияют ли аннотации JSON Schema на валидацию?

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

Каким должен быть хороший генератор для тестов валидаторов?

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

Может ли shrinking компенсировать слабый генератор тестов?

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

Почему тесты со множеством предусловий создают ложную уверенность?

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

Должен ли тест валидатора отличать отказ от сбоя?

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

Как сделать случайные ошибки парсера воспроизводимыми?

Используйте фиксированный seed в CI и выводите при ошибке seed, имя свойства, уменьшенный вход, версию парсера и версию генератора. Сохраните минимальный неудачный пример как регрессионный тест, но не заменяйте им само свойство.

С чего небольшой команде начать property-based тестирование?

Начните с места, где сгенерированный код превращает непроверенный текст в бизнес-объект или отклоняет запрос. Если команда внедряет инструменты разработки с ИИ в большой кодовой базе, Team & AI Audit поможет найти границы, где сгенерированные тесты раньше всего предотвратят дорогостоящие ошибки в продакшене.

Похожие статьи