12k
All articles

Тестирование в эпоху ИИ: как использовать ИИ для написания тестов

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

OpenReplay Team
OpenReplay Team
Тестирование в эпоху ИИ: как использовать ИИ для написания тестов

Тест, ожидаемое значение которого получено запуском тестируемого кода, способен подтвердить лишь то, что код и так делает; если баг уже есть, тест его закрепляет.

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

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

Ключевые выводы

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

Почему покрытие растёт, а баги всё равно доезжают до продакшена?

Покрытие измеряет выполнение, а не верификацию. Провайдеры покрытия в Jest (по умолчанию Babel/Istanbul, опционально V8) и провайдеры покрытия в Vitest (по умолчанию V8, опционально Istanbul) выдают одни и те же четыре метрики: statements, branches, functions и lines. Ни один из них не оценивает, может ли хоть какой-нибудь ассерт упасть. Тест, который вызывает функцию и делает ассерт на то, что она вернула, покрывает каждую задетую строку ровно так же, как это сделал бы тест с корректным ассертом.

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

Провал первый: тест утверждает то, что код и так возвращает

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

Возьмём функцию расчёта скидки с багом копипаста:

// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
  const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
  return price * (1 - rate);
}

API describe/it/expect ниже работает и в Jest, и в Vitest; Jest предоставляет его глобально, тогда как Vitest требует импорта или globals: true.

import { calculateDiscount } from "./discount";

// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
  expect(calculateDiscount(100, "silver")).toBe(75);
});

// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
  expect(calculateDiscount(100, "silver")).toBe(85);
});

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

Провал второй: сгенерированные тесты покрывают только happy path

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

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

Провал третий: тестируемый юнит замокан

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

Этот паттерн легче разглядеть на внедряемой зависимости — так пример остаётся свободным от различий между jest.mock и vi.mock:

// cart.ts
import type { calculateDiscount } from "./discount";

export function cartTotal(price: number, tier: "silver" | "gold",
                          discount: typeof calculateDiscount): number {
  return Math.round(discount(price, tier) * 100) / 100;
}

// cart.test.ts
import { cartTotal } from "./cart";

// Before: the fake is the thing being checked.
it("returns discounted total", () => {
  const fake = () => 85;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
  const fake = () => 85.004999;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

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

Исправление первое: давайте генерации тестов ИИ требование, а не код

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

Слабый промпт:

Write unit tests for calculateDiscount.

Более сильный промпт:

Write unit tests for calculateDiscount in discount.ts.

Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.

Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.

Второй промпт выдаёт expect(calculateDiscount(100, "silver")).toBe(85), потому что 85 — это то, что говорит правило, и такой тест падает на баговой функции с первого прогона. Названия тестов, читающиеся как правила («charges 85 for a 100 silver order»), к тому же позволяют ревьюить набор тестов как спецификацию. Промпт не гарантирует, что модель проигнорирует исходник; он даёт модели источник истины, который стоит выше исходника.

Исправление второе: сломайте код и ждите красного

Самая быстрая проверка сгенерированного набора тестов — изменить одну константу или инвертировать одно сравнение в тестируемом коде и прогнать тесты заново. Если всё осталось зелёным, набор этот код не защищал.

1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
   If nothing fails, no test asserts the silver rate.

Мутационное тестирование автоматизирует этот процесс. StrykerJS (@stryker-mutator/core) вносит в исходник небольшие изменения, перезапускает набор тестов для каждого из них и выдаёт mutation score — отношение пойманных вашими тестами мутантов (тест упал или прогон завершился по таймауту) к числу валидных мутантов. Его поддерживаемые мутаторы инвертируют операторы сравнения, отрицают булевы значения, подменяют арифметические операторы, опустошают строковые литералы и удаляют тела блоков. Плагины-раннеры существуют для Jest и для Vitest; перед внедрением проверьте совместимость плагина с установленной у вас версией.

Исправление третье: читайте моки прежде ассертов

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

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

Сохраняйте суждение за собой

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

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

Заменяет ли мутационное тестирование покрытие кода?

Нет. Они сообщают о разных пробелах. Покрытие показывает, какие строки не выполнялись ни в одном тесте; мутационное тестирование показывает, какие выполненные строки не защищены ни одним ассертом. StrykerJS сообщает и то, и другое: мутант в невыполненном коде получает состояние No coverage, а мутант в выполненном коде, при котором все тесты по-прежнему проходят, получает состояние Survived. Mutation score считает оба случая необнаруженными, так что низкое покрытие напрямую снижает счёт.

Работает ли StrykerJS с Vitest и Jest?

Да, через плагины-раннеры. Для Vitest установите @stryker-mutator/vitest-runner и укажите testRunner как vitest в конфиге Stryker; плагин поставляется без самого Vitest, поэтому загляните в его package.json, чтобы узнать самый старый принимаемый релиз Vitest. Тесты, выполняемые через Browser Mode в Vitest, выходят за рамки того, что раннер поддерживает. Для Jest используйте @stryker-mutator/jest-runner. Под раннером Vitest Stryker отключает собственный сбор покрытия Vitest и заставляет прогон каждого мутанта останавливаться на первом упавшем тесте, поскольку одного падения достаточно, чтобы убить мутанта.

Как отловить сгенерированные ИИ тесты, в которых вообще нет ассертов?

Заставьте фреймворк заваливать любой тест с нулём ассертов. В Vitest задайте expect.requireAssertions в конфиге или передайте --expect.requireAssertions в CLI; тест, завершившийся без вызова expect, упадёт. В Jest вызовите expect.hasAssertions() внутри тела теста или в хуке beforeEach, чтобы применить это везде. Ни одна из этих настроек не проверяет, может ли ассерт упасть, поэтому тест, утверждающий собственное возвращаемое значение мока, всё равно пройдёт.

Можно ли доверять сгенерированным ИИ snapshot-тестам?

Не без ревью. Снапшот, записанный с текущего вывода, утверждает лишь то, что код продолжает выдавать то же, что выдавал в момент записи, включая баги, — это та же тавтология, что и скопированный литерал. Перезапись отделена одним флагом: jest -u и vitest -u переписывают каждый упавший снапшот. В CI флаг --ci в Jest заваливает сборку на новых снапшотах вместо того, чтобы молча их записывать. Ревьюйте диффы снапшотов относительно требования, а не предыдущего вывода.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.