AI 개발 가이드

AI 테스트 자동화 - pytest + LLM으로 테스트 코드 자동 생성하기

소개왕 탑백귀 2026. 4. 16. 15:51

AI 테스트 자동화 - pytest + LLM으로 테스트 코드 자동 생성하기

2026년 4월 기준 | AI 개발 가이드

요약: 테스트 코드 작성은 중요하지만 귀찮습니다. LLM API를 활용해서 기존 파이썬 코드를 분석하고 pytest 테스트를 자동으로 생성하는 도구를 직접 만들어봅니다. 단순한 유닛 테스트부터 엣지 케이스, 에러 케이스까지 커버하는 테스트를 자동으로 뽑아내는 방법을 코드와 함께 설명합니다.

테스트 자동 생성이 필요한 이유

솔직하게 말하면 대부분의 개발자는 테스트 코드를 나중에 작성합니다. "기능 먼저 만들고 테스트는 나중에"라고 하면서 결국 안 하는 경우가 태반입니다. 저도 그랬습니다.

이유는 단순합니다. 테스트 작성이 반복적이고 지루하기 때문입니다. 함수 하나에 대해 정상 케이스, 엣지 케이스, 에러 케이스를 각각 작성하면 실제 코드보다 테스트 코드가 더 깁니다.

여기서 LLM이 도와줄 수 있습니다. 소스 코드를 분석해서 "이 함수는 어떤 입력을 받고, 어떤 에러가 발생할 수 있고, 어떤 엣지 케이스가 있는지"를 파악한 뒤 pytest 코드를 자동으로 생성하는 겁니다.

물론 LLM이 생성한 테스트를 100% 신뢰하면 안 됩니다. 하지만 80%짜리 초안을 받아서 20%만 수정하는 것과 처음부터 100%를 직접 쓰는 건 완전히 다른 차원의 생산성입니다.

접근 방식: AST 분석 + LLM 생성

단순히 소스 코드를 LLM에게 던지고 "테스트 만들어줘"라고 할 수도 있지만, 더 나은 결과를 위해 두 단계로 나눕니다.

  1. AST(Abstract Syntax Tree) 분석: 파이썬 파일에서 함수명, 파라미터, 타입 힌트, docstring, 예외 처리 등 구조화된 정보를 추출합니다.
  2. LLM 생성: 구조화된 정보 + 원본 코드를 함께 LLM에게 전달해서 더 정확한 테스트를 생성합니다.

AST 분석을 먼저 하는 이유는 LLM이 "어떤 함수에 대해 테스트를 만들어야 하는지"를 명확히 알 수 있게 하기 위함입니다. 코드만 통째로 보내면 helper 함수나 내부 함수에 대한 불필요한 테스트를 생성하는 경우가 많습니다.

프로젝트 세팅

# 프로젝트 구조
test-generator/
├── generator.py      # 메인 도구
├── ast_parser.py     # AST 분석기
├── requirements.txt
└── example/
    ├── calculator.py # 테스트 대상 예제
    └── user_service.py
# requirements.txt
anthropic>=0.40.0
pytest>=8.0
python-dotenv

테스트 대상 예제 코드를 먼저 준비합니다.

# example/calculator.py
class Calculator:
    def __init__(self, precision: int = 2):
        self.precision = precision
        self.history: list[str] = []

    def add(self, a: float, b: float) -> float:
        """두 수를 더합니다."""
        result = round(a + b, self.precision)
        self.history.append(f"{a} + {b} = {result}")
        return result

    def divide(self, a: float, b: float) -> float:
        """나누기. 0으로 나누면 ValueError 발생."""
        if b == 0:
            raise ValueError("0으로 나눌 수 없습니다")
        result = round(a / b, self.precision)
        self.history.append(f"{a} / {b} = {result}")
        return result

    def get_history(self) -> list[str]:
        """계산 히스토리를 반환합니다."""
        return self.history.copy()

AST로 함수 정보 추출하기

파이썬 표준 라이브러리의 ast 모듈을 사용해서 소스 코드의 구조를 분석합니다.

# ast_parser.py
import ast
import json
from pathlib import Path


def extract_functions(file_path: str) -> list[dict]:
    """파이썬 파일에서 함수/메서드 정보를 추출합니다."""
    source = Path(file_path).read_text(encoding="utf-8")
    tree = ast.parse(source)
    functions = []

    for node in ast.walk(tree):
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue

        # private 함수 제외
        if node.name.startswith("_") and node.name != "__init__":
            continue

        func_info = {
            "name": node.name,
            "params": [],
            "return_type": None,
            "docstring": ast.get_docstring(node),
            "raises": [],
            "is_async": isinstance(node, ast.AsyncFunctionDef),
        }

        # 파라미터 추출
        for arg in node.args.args:
            if arg.arg == "self":
                continue
            param = {"name": arg.arg, "type": None}
            if arg.annotation:
                param["type"] = ast.unparse(arg.annotation)
            func_info["params"].append(param)

        # 반환 타입 추출
        if node.returns:
            func_info["return_type"] = ast.unparse(node.returns)

        # raise 문 추출
        for child in ast.walk(node):
            if isinstance(child, ast.Raise) and child.exc:
                if isinstance(child.exc, ast.Call):
                    func_info["raises"].append(
                        ast.unparse(child.exc.func)
                    )

        functions.append(func_info)

    return functions


if __name__ == "__main__":
    result = extract_functions("example/calculator.py")
    print(json.dumps(result, indent=2, ensure_ascii=False))

이 스크립트를 실행하면 함수별로 구조화된 정보가 나옵니다.

[
  {
    "name": "add",
    "params": [{"name": "a", "type": "float"}, {"name": "b", "type": "float"}],
    "return_type": "float",
    "docstring": "두 수를 더합니다.",
    "raises": [],
    "is_async": false
  },
  {
    "name": "divide",
    "params": [{"name": "a", "type": "float"}, {"name": "b", "type": "float"}],
    "return_type": "float",
    "docstring": "나누기. 0으로 나누면 ValueError 발생.",
    "raises": ["ValueError"],
    "is_async": false
  }
]

LLM으로 테스트 코드 생성

추출한 정보와 원본 코드를 Claude API에 전달해서 테스트를 생성합니다.

# generator.py
import anthropic
from pathlib import Path
from ast_parser import extract_functions
import json

client = anthropic.Anthropic()

SYSTEM_PROMPT = """당신은 시니어 파이썬 개발자이며 테스트 전문가입니다.
주어진 소스 코드와 함수 정보를 분석해서 pytest 테스트 코드를 생성합니다.

규칙:
1. 각 public 함수/메서드에 대해 최소 3개의 테스트를 작성합니다:
   - 정상 동작 테스트 (happy path)
   - 엣지 케이스 테스트 (경계값, 빈 입력 등)
   - 에러 케이스 테스트 (예외 발생 상황)
2. pytest.raises를 사용해서 예외를 테스트합니다.
3. fixture를 적절히 활용합니다.
4. 테스트 함수명은 test_{함수명}_{시나리오} 형식입니다.
5. 한국어 주석을 포함합니다.
6. 코드만 출력합니다. 설명은 주석으로 넣습니다."""


def generate_tests(file_path: str) -> str:
    source = Path(file_path).read_text(encoding="utf-8")
    func_info = extract_functions(file_path)

    user_msg = f"""다음 파이썬 코드에 대한 pytest 테스트를 생성해주세요.

## 소스 코드 ({file_path})
```python
{source}
```

## 함수 분석 결과
```json
{json.dumps(func_info, indent=2, ensure_ascii=False)}
```

위 정보를 바탕으로 완전한 pytest 테스트 파일을 생성해주세요."""

    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": user_msg}],
    )

    # 코드 블록 추출
    text = response.content[0].text
    if "```python" in text:
        text = text.split("```python")[1].split("```")[0]

    return text.strip()

전체 도구 완성

CLI에서 바로 쓸 수 있도록 완성합니다.

# generator.py (완성본)
import argparse
import subprocess
import sys
from pathlib import Path

def main():
    parser = argparse.ArgumentParser(
        description="AI 테스트 코드 자동 생성기"
    )
    parser.add_argument("file", help="테스트할 파이썬 파일 경로")
    parser.add_argument(
        "-o", "--output",
        help="출력 파일 경로 (기본: test_{원본파일명})",
    )
    parser.add_argument(
        "--run", action="store_true",
        help="생성 후 바로 pytest 실행",
    )
    args = parser.parse_args()

    source_path = Path(args.file)
    if not source_path.exists():
        print(f"파일을 찾을 수 없습니다: {source_path}")
        sys.exit(1)

    # 출력 경로 결정
    if args.output:
        output_path = Path(args.output)
    else:
        output_path = source_path.parent / f"test_{source_path.name}"

    print(f"분석 중: {source_path}")
    test_code = generate_tests(str(source_path))

    output_path.write_text(test_code, encoding="utf-8")
    print(f"테스트 생성 완료: {output_path}")

    if args.run:
        print("\npytest 실행 중...")
        subprocess.run(["pytest", str(output_path), "-v"])

if __name__ == "__main__":
    main()

실행 방법:

# 테스트 생성만
python generator.py example/calculator.py

# 생성 후 바로 실행
python generator.py example/calculator.py --run

# 출력 경로 지정
python generator.py example/calculator.py -o tests/test_calc.py

실제 생성 결과 분석

calculator.py에 대해 생성된 테스트 코드입니다.

import pytest
from calculator import Calculator


@pytest.fixture
def calc():
    """기본 Calculator 인스턴스"""
    return Calculator(precision=2)


# === add 메서드 테스트 ===

def test_add_positive_numbers(calc):
    """양수 덧셈 정상 동작"""
    assert calc.add(2, 3) == 5.0


def test_add_floating_point_precision(calc):
    """부동소수점 정밀도 처리 확인"""
    result = calc.add(0.1, 0.2)
    assert result == 0.3  # precision=2로 반올림


def test_add_negative_numbers(calc):
    """음수 덧셈"""
    assert calc.add(-5, -3) == -8.0


def test_add_records_history(calc):
    """히스토리에 기록되는지 확인"""
    calc.add(2, 3)
    assert len(calc.get_history()) == 1
    assert "2 + 3 = 5.0" in calc.get_history()[0]


# === divide 메서드 테스트 ===

def test_divide_normal(calc):
    """정상 나눗셈"""
    assert calc.divide(10, 3) == 3.33


def test_divide_by_zero_raises_error(calc):
    """0으로 나누면 ValueError 발생"""
    with pytest.raises(ValueError, match="0으로 나눌 수 없습니다"):
        calc.divide(10, 0)


def test_divide_result_precision(calc):
    """나눗셈 결과 정밀도 확인"""
    result = calc.divide(1, 3)
    assert result == 0.33

상당히 합리적인 테스트입니다. 부동소수점 정밀도, 0 나누기 예외, 히스토리 기록까지 커버합니다. AST 분석에서 ValueError raise를 감지했기 때문에 에러 테스트가 정확하게 나왔습니다.

한계와 개선 방향

현재 한계:

  • 외부 의존성이 있는 코드(DB, API 호출)에 대한 mock 생성이 불완전할 수 있음
  • 복잡한 상태 의존 로직은 테스트 순서가 중요한데, 이를 자동으로 파악하기 어려움
  • LLM이 존재하지 않는 메서드를 호출하는 테스트를 만드는 경우가 간혹 발생

개선 방향:

  • 생성 후 자동 검증: 생성된 테스트를 실행해보고, 실패하면 에러 메시지와 함께 LLM에게 재생성 요청
  • 커버리지 기반 반복: pytest-cov로 커버리지를 측정하고, 커버되지 않는 라인에 대한 추가 테스트 생성
  • import 자동 해석: 프로젝트의 다른 모듈을 분석해서 mock이 필요한 부분 자동 감지

마무리

LLM을 활용한 테스트 자동 생성은 "테스트를 안 쓰는 것보다는 낫다" 수준이 아닙니다. 실제로 써보면 초안의 70~80%는 그대로 사용 가능하고, 나머지만 수동으로 조정하면 됩니다.

핵심은 AST 분석으로 구조화된 정보를 제공하는 것입니다. 코드를 통째로 던지는 것보다 함수 시그니처, 타입 힌트, 예외 정보를 정리해서 전달하면 훨씬 정확한 테스트가 나옵니다.

이 도구를 CI/CD 파이프라인에 통합하면, 새 코드가 push될 때마다 자동으로 테스트 초안을 PR 코멘트로 달아주는 것도 가능합니다. 테스트 커버리지를 올리는 가장 현실적인 방법이라고 생각합니다.

관련 글 추천:

  • AI 코드 리뷰 자동화 - GitHub Actions + Claude API로 PR 자동 리뷰
  • 파이썬으로 Claude API 연동하기 - 자동 요약기 만들기
  • AI Function Calling 실전 활용법 - GPT & Claude 비교 구현