LLM 기반 단위 테스트 자동 생성의 원리와 실전 활용법을 다룹니다. Diffblue, Codium/Qodo 도구를 활용한 pytest/Jest 테스트 생성 실습과 생성된 테스트의 품질 검증 방법을 안내합니다.
AI 기반 단위 테스트 생성은 단순히 템플릿을 채우는 것이 아닙니다. LLM은 소스 코드의 의미를 이해하고, 함수의 계약(입력-출력 관계)을 추론하여 의미 있는 테스트 케이스를 만들어냅니다.
LLM은 다음과 같은 정보를 종합적으로 분석합니다.
최신 도구들은 Symbolic Analysis(심볼릭 분석)와 LLM 추론을 결합합니다. 심볼릭 분석은 코드의 실행 경로를 정적으로 추적하여 가능한 모든 분기를 파악하고, LLM은 각 분기에 대해 의미 있는 입력값을 생성합니다.
def calculate_discount(price: float, membership: str, coupon_code: str | None = None) -> float:
"""회원 등급과 쿠폰에 따른 할인 금액을 계산합니다."""
if price <= 0:
raise ValueError("가격은 0보다 커야 합니다")
discount_rate = 0.0
if membership == "gold":
discount_rate = 0.15
elif membership == "silver":
discount_rate = 0.10
elif membership == "bronze":
discount_rate = 0.05
if coupon_code == "SPECIAL2026":
discount_rate += 0.05
return round(price * discount_rate, 2)이 함수에 대해 AI는 다음과 같은 관점에서 테스트를 생성합니다.
| 관점 | 테스트 케이스 |
|---|---|
| 정상 경로 | 각 회원 등급별 할인율 계산 |
| 경계값 | 가격이 0인 경우, 매우 큰 가격 |
| 에지 케이스 | 알 수 없는 회원 등급, None 쿠폰 코드 |
| 조합 | 회원 할인 + 쿠폰 할인 중복 적용 |
| 예외 | 음수 가격 입력 시 ValueError |
Diffblue Cover는 Java 생태계에서 가장 성숙한 AI 단위 테스트 생성 도구입니다. Oxford 대학의 연구에서 출발한 이 도구는 강화 학습과 심볼릭 실행을 결합하여 JUnit 테스트를 자동 생성합니다.
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findActiveUser(String email) {
User user = userRepository.findByEmail(email);
if (user == null) {
throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + email);
}
if (!user.isActive()) {
throw new InactiveUserException("비활성 사용자입니다: " + email);
}
return user;
}
}Diffblue Cover가 생성하는 테스트 예시는 다음과 같습니다.
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void findActiveUser_WhenUserExistsAndActive_ReturnsUser() {
User activeUser = new User("test@example.com", true);
when(userRepository.findByEmail("test@example.com")).thenReturn(activeUser);
User result = userService.findActiveUser("test@example.com");
assertThat(result).isEqualTo(activeUser);
verify(userRepository).findByEmail("test@example.com");
}
@Test
void findActiveUser_WhenUserNotFound_ThrowsException() {
when(userRepository.findByEmail("unknown@example.com")).thenReturn(null);
assertThrows(UserNotFoundException.class,
() -> userService.findActiveUser("unknown@example.com"));
}
@Test
void findActiveUser_WhenUserInactive_ThrowsException() {
User inactiveUser = new User("inactive@example.com", false);
when(userRepository.findByEmail("inactive@example.com")).thenReturn(inactiveUser);
assertThrows(InactiveUserException.class,
() -> userService.findActiveUser("inactive@example.com"));
}
}Diffblue Cover는 CI 파이프라인에 통합하여 새로운 코드가 푸시될 때마다 자동으로 테스트를 생성하고 PR에 추가하는 워크플로를 구성할 수 있습니다. 이를 통해 개발자가 테스트 작성을 잊더라도 최소한의 커버리지를 보장합니다.
Codium(현재 Qodo로 리브랜딩)은 Python, JavaScript/TypeScript, Java 등 다중 언어를 지원하는 AI 테스트 생성 도구입니다. IDE 확장(VS Code, JetBrains)으로 동작하며, 개발자가 코드를 작성하는 시점에 실시간으로 테스트를 제안합니다.
Codium/Qodo의 차별점은 BDD(Behavior-Driven Development) 스타일의 테스트 시나리오를 먼저 제시한 후, 개발자가 선택한 시나리오에 대해 테스트 코드를 생성한다는 것입니다.
실제 함수를 대상으로 AI 도구가 어떤 테스트를 생성하는지 살펴보겠습니다.
from dataclasses import dataclass
from typing import Optional
@dataclass
class CartItem:
product_id: str
name: str
price: float
quantity: int
class ShoppingCart:
def __init__(self, max_items: int = 50):
self._items: list[CartItem] = []
self._max_items = max_items
def add_item(self, item: CartItem) -> None:
if item.quantity <= 0:
raise ValueError("수량은 1 이상이어야 합니다")
if item.price < 0:
raise ValueError("가격은 0 이상이어야 합니다")
total_quantity = sum(i.quantity for i in self._items) + item.quantity
if total_quantity > self._max_items:
raise OverflowError(f"장바구니 최대 수량({self._max_items})을 초과합니다")
existing = self._find_item(item.product_id)
if existing:
existing.quantity += item.quantity
else:
self._items.append(item)
def remove_item(self, product_id: str) -> None:
item = self._find_item(product_id)
if item is None:
raise KeyError(f"상품을 찾을 수 없습니다: {product_id}")
self._items.remove(item)
def get_total(self, discount_rate: float = 0.0) -> float:
if not 0.0 <= discount_rate <= 1.0:
raise ValueError("할인율은 0.0에서 1.0 사이여야 합니다")
subtotal = sum(item.price * item.quantity for item in self._items)
return round(subtotal * (1 - discount_rate), 2)
def _find_item(self, product_id: str) -> Optional[CartItem]:
return next((i for i in self._items if i.product_id == product_id), None)
@property
def item_count(self) -> int:
return len(self._items)
@property
def total_quantity(self) -> int:
return sum(i.quantity for i in self._items)import pytest
from shopping_cart import ShoppingCart, CartItem
class TestShoppingCartAddItem:
"""add_item 메서드 테스트"""
def test_add_single_item(self):
cart = ShoppingCart()
item = CartItem("P001", "노트북", 1500000.0, 1)
cart.add_item(item)
assert cart.item_count == 1
assert cart.total_quantity == 1
def test_add_multiple_different_items(self):
cart = ShoppingCart()
cart.add_item(CartItem("P001", "노트북", 1500000.0, 1))
cart.add_item(CartItem("P002", "마우스", 50000.0, 2))
assert cart.item_count == 2
assert cart.total_quantity == 3
def test_add_duplicate_item_increases_quantity(self):
cart = ShoppingCart()
cart.add_item(CartItem("P001", "노트북", 1500000.0, 1))
cart.add_item(CartItem("P001", "노트북", 1500000.0, 2))
assert cart.item_count == 1
assert cart.total_quantity == 3
def test_add_item_with_zero_quantity_raises_error(self):
cart = ShoppingCart()
with pytest.raises(ValueError, match="수량은 1 이상"):
cart.add_item(CartItem("P001", "노트북", 1500000.0, 0))
def test_add_item_with_negative_quantity_raises_error(self):
cart = ShoppingCart()
with pytest.raises(ValueError, match="수량은 1 이상"):
cart.add_item(CartItem("P001", "노트북", 1500000.0, -1))
def test_add_item_with_negative_price_raises_error(self):
cart = ShoppingCart()
with pytest.raises(ValueError, match="가격은 0 이상"):
cart.add_item(CartItem("P001", "노트북", -100.0, 1))
def test_add_item_exceeding_max_items_raises_error(self):
cart = ShoppingCart(max_items=3)
cart.add_item(CartItem("P001", "A", 100.0, 2))
with pytest.raises(OverflowError, match="최대 수량"):
cart.add_item(CartItem("P002", "B", 200.0, 2))
def test_add_item_at_exact_max_limit(self):
cart = ShoppingCart(max_items=3)
cart.add_item(CartItem("P001", "A", 100.0, 3))
assert cart.total_quantity == 3
class TestShoppingCartRemoveItem:
"""remove_item 메서드 테스트"""
def test_remove_existing_item(self):
cart = ShoppingCart()
cart.add_item(CartItem("P001", "노트북", 1500000.0, 1))
cart.remove_item("P001")
assert cart.item_count == 0
def test_remove_nonexistent_item_raises_error(self):
cart = ShoppingCart()
with pytest.raises(KeyError, match="상품을 찾을 수 없습니다"):
cart.remove_item("INVALID")
class TestShoppingCartGetTotal:
"""get_total 메서드 테스트"""
def test_total_without_discount(self):
cart = ShoppingCart()
cart.add_item(CartItem("P001", "노트북", 1500000.0, 1))
cart.add_item(CartItem("P002", "마우스", 50000.0, 2))
assert cart.get_total() == 1600000.0
def test_total_with_discount(self):
cart = ShoppingCart()
cart.add_item(CartItem("P001", "노트북", 1000000.0, 1))
assert cart.get_total(discount_rate=0.1) == 900000.0
def test_total_empty_cart(self):
cart = ShoppingCart()
assert cart.get_total() == 0.0
def test_total_with_invalid_discount_raises_error(self):
cart = ShoppingCart()
with pytest.raises(ValueError, match="할인율은"):
cart.get_total(discount_rate=1.5)
def test_total_with_negative_discount_raises_error(self):
cart = ShoppingCart()
with pytest.raises(ValueError, match="할인율은"):
cart.get_total(discount_rate=-0.1)AI가 생성한 테스트를 그대로 사용하기보다, 다음을 점검하는 것이 좋습니다: (1) 비즈니스 로직의 의도를 정확히 반영하는지, (2) 테스트 이름이 동작을 명확히 설명하는지, (3) 불필요한 중복 테스트는 없는지 확인합니다.
프론트엔드 환경에서의 AI 테스트 생성도 살펴보겠습니다.
export function formatCurrency(amount: number, locale: string = "ko-KR"): string {
if (!Number.isFinite(amount)) {
throw new TypeError("유효한 숫자가 아닙니다");
}
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: locale === "ko-KR" ? "KRW" : "USD",
minimumFractionDigits: locale === "ko-KR" ? 0 : 2,
});
return formatter.format(amount);
}
export function truncateText(text: string, maxLength: number, suffix: string = "..."): string {
if (maxLength < 0) {
throw new RangeError("maxLength는 0 이상이어야 합니다");
}
if (text.length <= maxLength) {
return text;
}
return text.slice(0, maxLength - suffix.length) + suffix;
}import { formatCurrency, truncateText } from "./format-utils";
describe("formatCurrency", () => {
it("한국 원화 형식으로 포맷합니다", () => {
expect(formatCurrency(15000)).toBe("\\15,000");
});
it("0원을 올바르게 포맷합니다", () => {
expect(formatCurrency(0)).toBe("\\0");
});
it("음수 금액을 올바르게 포맷합니다", () => {
expect(formatCurrency(-5000)).toBe("-\\5,000");
});
it("US 달러 형식으로 포맷합니다", () => {
const result = formatCurrency(99.99, "en-US");
expect(result).toBe("$99.99");
});
it("NaN 입력 시 TypeError를 던집니다", () => {
expect(() => formatCurrency(NaN)).toThrow(TypeError);
});
it("Infinity 입력 시 TypeError를 던집니다", () => {
expect(() => formatCurrency(Infinity)).toThrow(TypeError);
});
});
describe("truncateText", () => {
it("maxLength 이하 텍스트는 그대로 반환합니다", () => {
expect(truncateText("안녕하세요", 10)).toBe("안녕하세요");
});
it("긴 텍스트를 잘라내고 접미사를 추가합니다", () => {
expect(truncateText("이것은 매우 긴 텍스트입니다", 10)).toBe("이것은 매우...");
});
it("커스텀 접미사를 사용합니다", () => {
expect(truncateText("긴 텍스트 예시", 7, " --")).toBe("긴 텍스 --");
});
it("음수 maxLength 시 RangeError를 던집니다", () => {
expect(() => truncateText("테스트", -1)).toThrow(RangeError);
});
it("maxLength가 0이면 접미사만 반환합니다", () => {
expect(truncateText("테스트", 3)).toBe("...");
});
});AI 테스트 생성 도구를 효과적으로 활용하려면, 명확한 커버리지 목표를 설정해야 합니다.
| 커버리지 유형 | 설명 | 권장 목표 |
|---|---|---|
| Line Coverage(라인 커버리지) | 실행된 코드 라인의 비율 | 80% 이상 |
| Branch Coverage(분기 커버리지) | 실행된 조건 분기의 비율 | 75% 이상 |
| Function Coverage(함수 커버리지) | 호출된 함수의 비율 | 90% 이상 |
| Mutation Score(변이 점수) | 변이체를 잡아낸 비율 (5장 참조) | 70% 이상 |
커버리지 수치만 높다고 좋은 테스트는 아닙니다. assert 없이 코드를 실행하기만 하는 테스트는 라인 커버리지는 올리지만, 실제 결함을 잡아내지 못합니다. 5장에서 다룰 변이 테스트가 이 문제를 검증하는 효과적인 방법입니다.
AI 도구가 생성한 테스트의 커버리지를 확인하고, 부족한 영역을 파악하는 것이 중요합니다.
pytest --cov=src --cov-report=html --cov-report=term-missingnpx jest --coverage --coverageReporters=text --coverageReporters=htmlAI가 생성한 테스트를 무조건 신뢰해서는 안 됩니다. 다음 기준으로 품질을 검증해야 합니다.
test_1, test_2 같은 이름은 유지보수를 어렵게 합니다| 문제 | 원인 | 대응 |
|---|---|---|
| 의미 없는 단언 | AI가 "무엇을 검증해야 하는지" 모름 | 비즈니스 규칙 기반으로 단언 보강 |
| 과도한 모킹 | 실제 동작보다 구현 세부사항 테스트 | 통합 수준의 테스트로 보완 |
| 하드코딩된 값 | 특정 환경에서만 통과 | 상대값 또는 팩토리 패턴 사용 |
| 부족한 에러 경로 | 정상 경로 위주로 생성 | 에러 시나리오 수동 추가 |
이 장에서는 LLM 기반 단위 테스트 자동 생성의 원리와 실전 활용법을 살펴보았습니다.
핵심 내용을 정리하면 다음과 같습니다.
3장에서는 단위 테스트를 넘어 통합 테스트와 API 테스트 자동화를 다룹니다. API 스키마 기반 테스트 자동 생성, 계약 테스트(Contract Testing), 그리고 testcontainers와 AI를 결합한 데이터베이스 통합 테스트를 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
API 스키마 기반 테스트 자동 생성, 계약 테스트(Contract Testing), testcontainers와 AI를 결합한 데이터베이스 통합 테스트, 그리고 CI 파이프라인 통합 방법을 다룹니다.
전통적인 테스트 자동화에서 AI 기반 테스트로의 전환을 살펴봅니다. Agentic QA의 등장, 2026년 도구 생태계, 그리고 70% 이상 기업이 도입한 AI 테스트의 현황과 30-45% 효율 개선 사례를 분석합니다.
자연어를 E2E 테스트로 변환하는 Momentic, testRigor, Functionize와 DOM 변경에 자동 적응하는 셀프 힐링 기능, Playwright와 AI를 결합한 실전 E2E 테스트 자동화를 다룹니다.