단위 테스트로 회귀 버그를 줄이는 코드 품질 관리
단위 테스트의 격리와 결정성, AAA 패턴, 테스트 더블 선택, 커버리지 관리와 품질 게이트 적용 방법을 정리한다.
2026-08-14 · 최초 발행 2025-12-20
작은 변경을 안전하게 만드는 검증 단위
단위 테스트는 함수, 메서드, 클래스처럼 애플리케이션을 이루는 최소 단위의 동작을 외부 요인과 분리해 확인하는 자동화 테스트 스위트다. 요구사항 변경과 배포가 반복되는 환경에서는 회귀 버그를 조기에 드러내고, 코드 구조를 바꾸더라도 기존 행위를 지키는 안전망 역할을 한다.
검증 대상은 SUT(System Under Test)라 부른다. SUT가 네트워크, 파일 시스템, 시간처럼 통제하기 어려운 협력자에 의존할 때는 테스트 더블을 사용한다. Mock, Stub, Fake, Spy는 각각 외부 협력자를 대체하거나 호출을 기록해 테스트 범위를 좁히는 수단이다.
테스트는 같은 입력에 같은 결과를 내야 한다. 시간, 네트워크, 파일 IO, 랜덤성과 같은 요소를 제거하거나 추상화하는 이유도 여기에 있다.
Arrange, Act, Assert로 실패 지점을 드러내기
AAA(Arrange-Act-Assert)는 테스트를 준비, 실행, 검증으로 나누는 방식이다. Given/When/Then 구조로도 표현할 수 있으며, 어떤 상태를 만들고 어떤 동작을 수행한 뒤 무엇을 확인하는지 읽기 쉽게 만든다.
픽스처는 필요한 만큼만 만들고 의도를 드러내는 편이 좋다. 반복되는 준비 과정에는 빌더나 팩토리를 둘 수 있다. 테스트 하나에는 하나의 주된 행위를 두면 실패 원인을 더 좁게 파악할 수 있다.
테스트 더블은 검증 목적에 맞춘다
Mock은 협력자와의 상호작용이나 호출을 검증하는 데 초점을 둔다. Stub은 결과를 주입하고, Fake는 단순화한 구현으로 동작한다. Spy는 호출 기록을 남긴다.
API 상호작용 검증의 비중이 높다면 Mock을 더 많이 사용할 수 있다. 반대로 도메인 규칙이나 순수 로직이 중심이라면 Stub이나 Fake가 더 적합하다. 과도한 Mock은 인터랙션 변경에 민감해져 리팩터링 저항성을 키울 수 있으므로, 상태와 출력 중심의 검증도 함께 고려해야 한다.
| 전략 | 성능(속도) | 확장성(테스트 추가 용이) | 일관성(결정성) | 안정성(리팩터링 내성) | 운영 편의(유지보수) |
|---|---|---|---|---|---|
| Mock 중심 | 높음 | 높음 | 높음 | 중간(인터랙션 변경에 민감) | 중간 |
| 더블 최소(가벼운 Stub/Fake) | 중간 | 중간 | 높음 | 높음(행위 중심) | 중간~높음 |
| 균형형(Mock+Stub 혼합) | 높음 | 높음 | 높음 | 높음 | 높음 |
팀 차원에서는 테스트 피라미드, 명명 규칙, 픽스처 표준 같은 합의된 가이드로 균형을 유지한다.
경계와 실패 경로를 테스트에 남긴다
할인 규칙, 한도 검사, 상태 전이처럼 도메인 규칙이 있는 곳에서는 최소값·최대값·직전·직후를 보는 경계값 분석이 유용하다. 비정상 입력이나 외부 시스템 타임아웃, 예외 래핑 규약도 검증 대상이 된다. 재시도와 회로차단기 사이의 상호작용은 Fake 타이머로 확인할 수 있다.
버그가 발견되면 먼저 재현 테스트를 추가한 뒤 수정한다. 리팩터링 전후에 동등성 테스트를 실행하면 구현을 바꾸면서도 행위를 보존하는지 확인할 수 있다. TDD에서는 실패하는 테스트를 먼저 작성하고, 이를 통과하는 최소 구현을 만든 뒤 구조를 개선하는 RED→GREEN→REFACTOR 사이클을 반복한다. 사례는 가장 단순한 경우부터 쌓아 과적합을 피한다.
단위 테스트는 밀리초~수 밀리초 단위 실행 시간을 목표로 하며, PR이나 커밋 훅에서 즉시 실행하고 메인 브랜치 보호 규칙과 연결할 수 있다.
코드로 보는 경계값과 예외 검증
전제조건은 다음과 같다.
- Python 3.11+, pytest 8.x 설치
- Java 17, JUnit Jupiter 5.10+, Gradle/Maven 사용
Python에서 pytest 사용하기
# file: test_discount.py
# 목적: 경계값(100, 101)에서 할인 정책 검증
def calc_discount(amount: int) -> int:
if amount >= 100:
return int(amount * 0.9)
return amount
import pytest
@pytest.mark.parametrize("amount,expected", [
(99, 99), # 경계 직전
(100, 90), # 경계
(101, 90), # 경계 직후: 내림 처리에 유의
])
def test_calc_discount_boundary(amount, expected):
assert calc_discount(amount) == expected
def test_calc_discount_typeerror():
with pytest.raises(TypeError):
calc_discount("100") # 타입 오류 기대
Java에서 JUnit 5 사용하기
// file: DiscountCalculatorTest.java
// 목적: 예외/경계 처리 검증
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DiscountCalculator {
int calc(int amount) {
if (amount < 0) throw new IllegalArgumentException("amount");
return amount >= 100 ? (int)(amount * 0.9) : amount;
}
}
public class DiscountCalculatorTest {
@Test
void 경계값_검증() {
DiscountCalculator c = new DiscountCalculator();
assertEquals(99, c.calc(99));
assertEquals(90, c.calc(100));
assertEquals(90, c.calc(101));
}
@Test
void 음수_입력_예외() {
DiscountCalculator c = new DiscountCalculator();
assertThrows(IllegalArgumentException.class, () -> c.calc(-1));
}
}
커버리지 수치보다 검증 강도를 본다
라인·분기 커버리지는 Mutation Testing과 함께 사용해 테스트 강도를 확인할 수 있다. 커버리지 목표는 품질 게이트가 될 수 있지만, 숫자를 채우는 것보다 의미 있는 케이스를 먼저 검증해야 한다.
분기 커버리지 목표로 예를 들어 80% 이상을 두고 Mutation Score를 예를 들어 ≥ 60%로 병행할 수 있다. Flaky 테스트는 격리하고, 격리 실패 시 즉시 Quarantine 후 수정한다.
도입 효과는 배포 전 결함 검출율이 2550%p 향상되고, 평균 변경 리드타임이 1030% 단축되며 코드 리뷰 지연이 감소하는 방식으로 나타날 수 있다. 재현 가능한 단위 케이스를 기반으로 MTTR은 평균 20~40% 단축될 수 있다.
테스트는 실행 가능한 문서가 되어 사양을 드러낸다. 도메인 규칙을 이해하는 데 도움을 주고 온보딩 학습 곡선을 완화하며, 리팩터링에 대한 심리적 안전망과 코드 구조의 단순화를 뒷받침한다.