Разработка AI-генерации юнит-тестов

Проектируем и внедряем системы искусственного интеллекта: от прототипа до production-ready решения. Наша команда объединяет экспертизу в машинном обучении, дата-инжиниринге и MLOps, чтобы AI работал не в лаборатории, а в реальном бизнесе.
Показано 1 из 1 услугВсе 1566 услуг
Разработка AI-генерации юнит-тестов
Средняя
~1-2 недели
Часто задаваемые вопросы
Направления AI-разработки
Этапы разработки AI-решения
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1218
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    853
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1047
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    825

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, рефакторинг невозможен без тестов.

Процесс:

  1. Автоматический анализ всех .py файлов через AST
  2. Генерация тестов по файлам (batch, 5 файлов параллельно)
  3. Авто-запуск и fix цикл (до 3 итераций)
  4. Ручной просмотр тестов с 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 недели