AI-генерация юнит-тестов
Написание тестов — самая нелюбимая часть разработки: понятно, что нужно, скучно делать вручную. AI справляется с генерацией типовых тест-кейсов лучше, чем разработчики в режиме "надо добить coverage". Задача системы — не просто покрыть строки кода, а сгенерировать тесты, которые проверяют реальное поведение: edge cases, граничные значения, обработку ошибок.
Архитектура генератора тестов
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_*.py или *_test.py
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}
Сгенерируй полный тест-файл с pytest. Возвращай только код, без пояснений."""
}]
)
return response.content[0].text
Параметризованные тесты с граничными значениями
def generate_parametrized_tests(
self,
function_source: str,
function_signature: str,
) -> str:
"""Генерирует параметризованные тесты с граничными значениями"""
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=2048,
messages=[{
"role": "user",
"content": f"""Для этой функции сгенерируй pytest.mark.parametrize тест
с граничными значениями и edge cases.
```python
{function_source}
Сигнатура: {function_signature}
Формат ответа — только Python код:
@pytest.mark.parametrize("input,expected", [
# happy path
# edge cases
# boundary values
# error cases (pytest.raises)
])
def test_<function_name>(input, expected):
...
```"""
}]
)
return response.content[0].text
def run_and_fix(self, test_file: str, source_file: str, max_attempts: int = 3) -> str:
"""Запускает тесты и итеративно исправляет ошибки"""
test_content = Path(test_file).read_text()
for attempt in range(max_attempts):
result = subprocess.run(
["python", "-m", "pytest", test_file, "-v", "--tb=short"],
capture_output=True, text=True, timeout=60
)
if result.returncode == 0:
return test_content # Все тесты прошли
# Исправляем ошибки
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
messages=[{
"role": "user",
"content": f"""Тесты не прошли. Исправь тест-файл.
Тест-файл:
```python
{test_content}
Ошибки:
{result.stdout[-2000:]}
Верни исправленный полный тест-файл.""" }] )
test_content = response.content[0].text
Path(test_file).write_text(test_content)
return test_content
### Mutation Testing для оценки качества тестов
Сгенерированные тесты важно проверить — покрывают ли они реальные баги:
```python
import mutmut
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"
}
Интеграция с pytest-cov и автоматический отчёт
def generate_coverage_report(test_file: str, source_file: str) -> dict:
"""Запускает тесты с coverage и возвращает отчёт"""
result = subprocess.run(
[
"python", "-m", "pytest", test_file,
f"--cov={source_file}",
"--cov-report=json:coverage.json",
"--cov-report=term-missing",
"-v"
],
capture_output=True, text=True
)
import json
try:
with open("coverage.json") as f:
coverage_data = json.load(f)
uncovered_lines = []
for file_data in coverage_data.get("files", {}).values():
uncovered_lines.extend(file_data.get("missing_lines", []))
return {
"coverage_percent": coverage_data.get("totals", {}).get("percent_covered", 0),
"uncovered_lines": uncovered_lines,
"passed": result.returncode == 0,
}
except FileNotFoundError:
return {"coverage_percent": 0, "passed": False}
CLI для команды
import click
@click.command()
@click.argument("source_file")
@click.option("--function", "-f", help="Specific function to test")
@click.option("--output", "-o", help="Output test file path")
@click.option("--run/--no-run", default=True, help="Run tests after generation")
def generate(source_file: str, function: str, output: str, run: bool):
"""Генерирует юнит-тесты для Python файла"""
generator = TestGenerator(".")
tests = generator.generate_tests(source_file, function)
if not output:
source_path = Path(source_file)
output = str(source_path.parent / f"test_{source_path.name}")
Path(output).write_text(tests)
click.echo(f"Tests written to {output}")
if run:
click.echo("Running tests...")
fixed_tests = generator.run_and_fix(output, source_file)
coverage = generate_coverage_report(output, source_file)
click.echo(f"Coverage: {coverage['coverage_percent']:.1f}%")
if __name__ == "__main__":
generate()
Практический кейс: legacy Python-сервис без тестов
Задача: 8000 строк Python-кода, 0% coverage, рефакторинг невозможен без тестов.
Процесс:
- Автоматический анализ всех
.pyфайлов через AST - Генерация тестов по файлам (batch, 5 файлов параллельно)
- Авто-запуск и fix цикл (до 3 итераций)
- Ручной просмотр тестов с coverage < 60%
Результаты за 2 недели:
- Сгенерировано 847 тест-функций
- Coverage: 0% → 71%
- Найдено 12 реальных багов в процессе генерации (AI заметил несоответствие поведения и типов)
- 94% сгенерированных тестов прошли без правок
- 6% потребовали ручной доработки (сложные mock-зависимости)
Mutation score итоговых тестов: 0.74 (хорошо, но не excellent — некоторые edge cases AI не покрыл).
Сроки
- Базовый генератор (один файл, выгрузка кода): 1–2 дня
- Авто-запуск и fix-цикл: 2–3 дня
- Интеграция в CI/CD с coverage gate: 1 неделя
- Полный pipeline для legacy кодовой базы: 2–3 недели







