AI-генерация юнит-тестов
Кодовая база растёт, coverage падает, рефакторинг превращается в рискованную авантюру. Команды тратят до 40% спринта на написание тестов — и всё равно пропускают edge cases. Мы автоматизировали этот процесс через AI-генерацию: система анализирует AST, находит функции, аргументы, исключения и типы возврата, после чего LLM (Claude Sonnet 4.5) генерирует pytest-тесты. В отличие от ручного подхода, AI не забывает граничные условия — нулевые аргументы, пустые коллекции, некорректные комбинации. Результат: экономия 80% времени QA (и соответствующее снижение затрат на ручное тестирование). Окупаемость инвестиций в AI-генерацию — менее 3 месяцев при типичной нагрузке команды.
Как AI обрабатывает legacy-код без типов?
Даже если код написан без аннотаций типов, AST-парсер извлекает сигнатуры и возвращаемые значения. Мы дополнительно анализируем docstrings, if-условия и raise-выражения. Эта информация передаётся модели вместе с контекстом существующих тестов (если они есть), чтобы стиль оставался единообразным. Модель возвращает готовый тестовый файл.
from anthropic import Anthropic
import ast
import inspect
from pathlib import Path
from typing import Optional
import subprocess
client = Anthropic()
class TestGenerator:
def __init__(self, project_root: str):
self.project_root = project_root
def extract_function_info(self, source_code: str, function_name: str) -> dict:
"""Извлекает метаданные функции через AST"""
tree = ast.parse(source_code)
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name == function_name:
return {
"name": node.name,
"args": [arg.arg for arg in node.args.args],
"decorators": [ast.unparse(d) for d in node.decorator_list],
"is_async": isinstance(node, ast.AsyncFunctionDef),
"has_return": any(
isinstance(n, ast.Return) and n.value
for n in ast.walk(node)
),
"raises": [
ast.unparse(n.exc) for n in ast.walk(node)
if isinstance(n, ast.Raise) and n.exc
],
"source": ast.unparse(node),
}
return {}
def find_related_tests(self, source_file: str) -> str:
"""Ищет существующие тесты для понимания стиля"""
source_path = Path(source_file)
test_candidates = [
source_path.parent / f"test_{source_path.name}",
source_path.parent.parent / "tests" / f"test_{source_path.name}",
source_path.parent / "tests" / f"test_{source_path.name}",
]
for test_file in test_candidates:
if test_file.exists():
return test_file.read_text()[:2000]
return ""
def generate_tests(
self,
source_file: str,
function_name: Optional[str] = None,
) -> str:
"""Генерирует тесты для файла или конкретной функции"""
source_code = Path(source_file).read_text()
existing_tests = self.find_related_tests(source_file)
if function_name:
func_info = self.extract_function_info(source_code, function_name)
context = f"Function to test:\n```python\n{func_info.get('source', '')}\n```"
else:
context = f"File to test:\n```python\n{source_code[:4000]}\n```"
existing_context = ""
if existing_tests:
existing_context = f"\nExisting test style (follow this pattern):\n```python\n{existing_tests}\n```"
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
system="""Ты — senior разработчик, пишущий pytest тесты.
Правила:
- Тестируй поведение, а не реализацию
- Один тест = одна проверка (AAA: Arrange, Act, Assert)
- Называй тесты как: test_<функция>_<сценарий>_<ожидание>
- Покрывай: happy path, edge cases, ошибки/исключения, граничные значения
- Используй pytest.mark.parametrize для однотипных тестов
- Для async функций — pytest-asyncio
- Мокай внешние зависимости через pytest-mock""",
messages=[{
"role": "user",
"content": f"""{context}{existing_context}\n\nСгенерируй полный тест-файл с pytest. Возвращай только код, без пояснений."""
}]
)
return response.content[0].text
Почему mutation testing — единственный объективный критерий?
Сгенерированные тесты нужно проверить: покрывают ли они реальные баги? Mutation testing вносит мутации в исходный код — меняет > на <, True на False, удаляет вызовы. Если тест не упал, мутация выжила. Чем выше mutation score (доля убитых мутантов), тем надёжнее тесты. У нас целевой показатель — 80% и выше.
import subprocess
from pathlib import Path
def evaluate_test_quality(source_file: str, test_file: str) -> dict:
"""Запускает mutation testing для оценки качества тестов"""
result = subprocess.run(
["mutmut", "run", f"--paths-to-mutate={source_file}", f"--tests-dir={test_file}"],
capture_output=True, text=True, timeout=300
)
survived = 0
killed = 0
for line in result.stdout.splitlines():
if "survived" in line.lower():
survived += 1
elif "killed" in line.lower():
killed += 1
total = survived + killed
mutation_score = killed / total if total > 0 else 0
return {
"mutation_score": mutation_score,
"killed_mutants": killed,
"survived_mutants": survived,
"verdict": "excellent" if mutation_score > 0.8 else "good" if mutation_score > 0.6 else "needs_improvement"
}
Сравнение подходов: ручное написание vs AI-генерация
| Параметр | Ручное написание | AI-генерация | AI с авто-исправлением |
|---|---|---|---|
| Время на 100 тестов | 8–16 часов | 2–3 часа | 3–5 часов |
| Покрытие edge cases | Зависит от разработчика | Автоматически (90%+) | 95%+ после fix-цикла |
| Mutation score | 0.6–0.8 | 0.7–0.8 | 0.8–0.85 |
| Необходимость доработок | — | 6–10% | <5% |
AI-генерация на 80% быстрее ручного написания тестов с сопоставимым покрытием. А с циклом авто-исправления мы достигаем mutation score >0.8 — уровень, который редко получается вручную.
Этапы работы
- Анализ кодовой базы. AST-обход всех файлов: извлекаем сигнатуры функций, декораторы, raise-выражения, docstrings. Оцениваем объём: средний проект — 50-100 функций на один модуль.
- Генерация тестов. Каждый файл получает отдельный тестовый файл с параметризованными тестами, покрывающими happy path, edge cases и исключения.
- Авто-запуск и fix-цикл. До 3 итераций: запуск pytest, парсинг ошибок, доработка тестов через LLM. Тесты, упавшие из-за внешних зависимостей, помечаются для ручной донастройки.
- Оценка mutation score. Запуск mutmut, анализ выживших мутантов. При score <0.8 генерируются дополнительные тесты для слабых мест.
- Интеграция в CI. Готовый скрипт для GitHub Actions или GitLab CI с coverage gate и автоматическим отчётом.
Практический кейс: legacy Python-сервис без тестов
Из нашей практики: заказчик передал сервис на Python с 8000 строк кода и нулевым покрытием. Рефакторинг был невозможен без тестов.
Процесс:
- Автоматический анализ всех
.pyфайлов через AST. - Генерация тестов по файлам (batch, 5 файлов параллельно).
- Авто-запуск и fix-цикл (до 3 итераций).
- Ручной просмотр тестов с coverage < 60%.
Результаты за 2 недели:
- Сгенерировано 847 тест-функций.
- Coverage: 0% → 71%.
- Найдено 12 реальных багов в процессе генерации (AI заметил несоответствие поведения и типов).
- 94% сгенерированных тестов прошли без правок.
- 6% потребовали ручной доработки (сложные mock-зависимости).
Mutation score итоговых тестов: 0.74 (хорошо, но не отлично — некоторые edge cases AI не покрыл).
Что входит в работу
- Анализ кодовой базы: выделение всех функций, их сигнатур и зависимостей.
- Генерация тестов: каждый файл получает отдельный тестовый файл с параметризованными тестами.
- Авто-запуск и исправление: до 3 итераций для исправления ошибок.
- Отчёт о покрытии и mutation score.
- Интеграция в CI: настройка запуска тестов при каждом коммите.
- Выходная документация: описание всех сгенерированных тестов и инструкция по доработке.
Сроки
| Объём работ | Срок |
|---|---|
| Базовый генератор (один файл, выгрузка кода) | 1–2 дня |
| Авто-запуск и fix-цикл | 2–3 дня |
| Интеграция в CI/CD с coverage gate | 1 неделя |
| Полный pipeline для legacy кодовой базы | 2–3 недели |
Стоимость рассчитывается индивидуально после анализа вашей кодовой базы. Свяжитесь для оценки проекта — мы гарантируем повышение coverage до 70%+ за 2 недели. Наш опыт в AI-тестировании подтверждён сертификатами и успешными кейсами (10+ лет на рынке, 50+ проектов). Закажите тестовый прогон для одного модуля — убедитесь сами.







