테스트 커버리지 100%가 오히려 독이 되는 이유 - 실무자가 말하는 진짜 테스트 전략
"테스트 커버리지 100% 달성!"이라는 목표를 세우고 계신가요? 많은 개발팀이 높은 테스트 커버리지를 품질 지표로 삼지만, 실제로는 이것이 잘못된 목표가 될 수 있어요. 커버리지 수치에 집착하다 보면 정작 중요한 버그는 놓치고, 유지보수하기 어려운 테스트 코드만 양산하게 되죠. 이 글에서는 10년 이상의 실무 경험을 바탕으로 테스트 커버리지 100%가 왜 의미 없는지, 그리고 진짜 효과적인 테스트 전략은 무엇인지 실전 코드와 함께 알려드릴게요.
테스트 커버리지가 측정하는 것 vs 측정하지 못하는 것
테스트 커버리지는 단순히 "코드의 몇 퍼센트가 실행되었는가"를 측정할 뿐이에요. 하지만 이것이 코드의 품질이나 테스트의 효과성을 보장하지는 않아요.
커버리지가 측정하는 것:
- 라인 커버리지: 실행된 코드 라인의 비율
- 브랜치 커버리지: 실행된 조건문 분기의 비율
- 함수 커버리지: 호출된 함수의 비율
커버리지가 측정하지 못하는 것:
- 테스트의 품질과 의미
- 엣지 케이스 검증 여부
- 비즈니스 로직의 정확성
- 통합 시나리오의 안정성
실제 예시를 볼까요?
// 사용자 권한 검증 함수
function hasPermission(user, resource) {
// 관리자는 모든 권한 보유
if (user.role === 'admin') {
return true;
}
// 일반 사용자는 자신의 리소스만 접근 가능
if (user.id === resource.ownerId) {
return true;
}
return false;
}
// 커버리지 100%지만 품질 낮은 테스트
test('hasPermission coverage test', () => {
const admin = { role: 'admin', id: 1 };
const user = { role: 'user', id: 2 };
const resource = { ownerId: 2 };
hasPermission(admin, resource); // 실행만 하고 검증 없음
hasPermission(user, resource); // 실행만 하고 검증 없음
hasPermission(user, { ownerId: 999 }); // 실행만 하고 검증 없음
});
// 커버리지는 낮지만 품질 높은 테스트
test('hasPermission should validate admin access', () => {
const admin = { role: 'admin', id: 1 };
const resource = { ownerId: 999 };
expect(hasPermission(admin, resource)).toBe(true);
});
test('hasPermission should validate owner access', () => {
const owner = { role: 'user', id: 2 };
const resource = { ownerId: 2 };
expect(hasPermission(owner, resource)).toBe(true);
});
test('hasPermission should deny unauthorized access', () => {
const user = { role: 'user', id: 1 };
const resource = { ownerId: 999 };
expect(hasPermission(user, resource)).toBe(false);
});첫 번째 테스트는 커버리지 100%를 달성하지만 실제로는 아무것도 검증하지 않아요. 반면 두 번째 테스트들은 각 시나리오의 정확성을 명확하게 검증하죠. 테스트 커버리지는 양적 지표일 뿐, 질적 지표가 아니에요.
100% 커버리지를 추구하면 생기는 실무 문제들
1. 의미 없는 테스트 양산
커버리지를 채우기 위해 작성하는 테스트들은 실제 버그를 잡아내지 못해요.
// 단순 getter/setter에 대한 무의미한 테스트
public class User {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
// ... 더 많은 getter/setter
}
// 커버리지 채우기용 테스트 (의미 없음)
@Test
public void testGettersSetters() {
User user = new User();
user.setName("홍길동");
assertEquals("홍길동", user.getName());
user.setAge(30);
assertEquals(30, user.getAge());
// 이런 테스트는 실제 버그를 잡지 못해요
}2. 테스트하기 어려운 코드를 억지로 테스트
100% 커버리지를 위해 테스트하기 어려운 부분까지 억지로 테스트하다 보면, 테스트 코드가 프로덕션 코드보다 복잡해져요.
# 테스트하기 어려운 코드 예시
class ReportGenerator:
def generate_monthly_report(self):
# 현재 시간에 강하게 의존
now = datetime.now()
# 외부 API 호출
data = requests.get(f"https://api.example.com/data?month={now.month}")
# 파일 시스템에 직접 쓰기
with open(f"/reports/{now.year}_{now.month}.pdf", "wb") as f:
f.write(self._create_pdf(data.json()))
# 이메일 발송
send_email("admin@example.com", "보고서 생성 완료")
return True
# 이런 코드의 커버리지를 100%로 만들려면
# 모킹, 패칭, 타임프리징 등으로 테스트가 매우 복잡해져요
# 오히려 코드를 리팩토링하는 것이 더 나아요올바른 접근법:
# 의존성을 주입받도록 리팩토링
class ReportGenerator:
def __init__(self, api_client, file_writer, email_service, clock):
self.api_client = api_client
self.file_writer = file_writer
self.email_service = email_service
self.clock = clock
def generate_monthly_report(self):
"""핵심 비즈니스 로직만 테스트하기 쉽게 분리"""
now = self.clock.now()
data = self.api_client.fetch_data(now.month)
pdf_content = self._create_pdf(data)
filepath = f"/reports/{now.year}_{now.month}.pdf"
self.file_writer.write(filepath, pdf_content)
self.email_service.send("admin@example.com", "보고서 생성 완료")
return True
# 이제 테스트가 훨씬 간단해져요
def test_generate_monthly_report():
# 모든 의존성을 모킹
mock_api = Mock()
mock_api.fetch_data.return_value = {"sales": 1000}
mock_file = Mock()
mock_email = Mock()
mock_clock = Mock()
mock_clock.now.return_value = datetime(2024, 1, 1)
generator = ReportGenerator(mock_api, mock_file, mock_email, mock_clock)
result = generator.generate_monthly_report()
# 핵심 동작만 검증
assert result == True
mock_api.fetch_data.assert_called_once_with(1)
mock_file.write.assert_called_once()
mock_email.send.assert_called_once()3. 유지보수 비용 급증
커버리지 100%를 유지하려면, 코드가 조금만 변경되어도 수많은 테스트를 수정해야 해요.
// 초기 구현
interface User {
id: number;
name: string;
}
function formatUserInfo(user: User): string {
return `${user.name} (ID: ${user.id})`;
}
// 100개의 테스트가 이 함수를 사용 중...
// 요구사항 변경: 이메일 추가
interface User {
id: number;
name: string;
email: string; // 새로 추가
}
function formatUserInfo(user: User): string {
return `${user.name} <${user.email}> (ID: ${user.id})`;
}
// 이제 100개의 테스트를 모두 수정해야 해요!
// 커버리지를 맞추기 위해 작성한 불필요한 테스트들이
// 오히려 개발 속도를 늦추는 족쇄가 되어버려요진짜 중요한 것: 테스트의 가치와 효과성
테스트의 목적은 커버리지 수치를 높이는 것이 아니라 버그를 조기에 발견하고 리팩토링을 안전하게 하는 것이에요. 다음은 실제로 가치 있는 테스트의 특징이에요.
// 가치 있는 테스트 예시: 비즈니스 로직 검증
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(product, quantity) {
// 재고 확인
if (product.stock < quantity) {
throw new Error('재고가 부족합니다');
}
// 할인 적용 로직
const existingItem = this.items.find(item => item.product.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ product, quantity });
}
}
calculateTotal() {
return this.items.reduce((total, item) => {
const price = item.product.price;
const quantity = item.quantity;
// 5개 이상 구매시 10% 할인
const discount = quantity >= 5 ? 0.9 : 1;
return total + (price * quantity * discount);
}, 0);
}
}
// 실제 비즈니스 시나리오를 검증하는 테스트
describe('ShoppingCart', () => {
test('재고 부족시 상품을 추가할 수 없어야 함', () => {
const cart = new ShoppingCart();
const product = { id: 1, name: '노트북', price: 1000000, stock: 3 };
// 이것이 실제 발생할 수 있는 버그를 잡아내요
expect(() => {
cart.addItem(product, 5);
}).toThrow('재고가 부족합니다');
});
test('5개 이상 구매시 10% 할인이 적용되어야 함', () => {
const cart = new ShoppingCart();
const product = { id: 1, name: '마우스', price: 30000, stock: 10 };
cart.addItem(product, 5);
// 예상: 30000 * 5 * 0.9 = 135000
expect(cart.calculateTotal()).toBe(135000);
});
test('동일 상품을 여러 번 추가하면 수량이 합산되어야 함', () => {
const cart = new ShoppingCart();
const product = { id: 1, name: '키보드', price: 50000, stock: 10 };
cart.addItem(product, 2);
cart.addItem(product, 3);
// 할인 적용: 5개 이상이므로 10% 할인
expect(cart.calculateTotal()).toBe(50000 * 5 * 0.9);
});
});이런 테스트들은 실제 사용자 시나리오를 검증하고, 비즈니스 규칙이 올바르게 구현되었는지 확인해요. 커버리지가 60%라도 이런 테스트 10개가 의미 없는 getter 테스트 100개보다 훨씬 가치 있어요.
효과적인 테스트 전략: 리스크 기반 접근법
모든 코드를 동일하게 테스트할 필요는 없어요. 리스크가 높은 부분에 집중해야 해요.
높은 우선순위로 테스트해야 하는 코드:
- 비즈니스 핵심 로직
- 보안 관련 코드
- 금전 거래 관련 코드
- 자주 변경되는 코드
- 복잡한 알고리즘
낮은 우선순위 (테스트 생략 가능):
- 단순 getter/setter
- 프레임워크가 보장하는 기능
- 설정 파일이나 상수
- 외부 라이브러리 래핑 코드
# 우선순위 높음: 결제 로직 - 반드시 철저히 테스트
class PaymentProcessor:
def process_payment(self, user_id, amount, card_info):
"""
결제 처리 - 버그 발생시 금전적 손실 발생
따라서 모든 엣지 케이스를 테스트해야 함
"""
# 금액 검증
if amount <= 0:
raise ValueError("결제 금액은 0보다 커야 합니다")
if amount > 10000000: # 1천만원 초과
raise ValueError("고액 결제는 별도 승인이 필요합니다")
# 카드 정보 검증
if not self._validate_card(card_info):
raise ValueError("유효하지 않은 카드 정보입니다")
# 잔액 확인
balance = self._get_balance(user_id)
if balance < amount:
raise InsufficientFundsError("잔액이 부족합니다")
# 결제 처리
transaction_id = self._execute_payment(user_id, amount, card_info)
# 결제 내역 기록
self._record_transaction(user_id, transaction_id, amount)
return transaction_id
# 철저한 테스트
class TestPaymentProcessor(unittest.TestCase):
def test_negative_amount_raises_error(self):
"""음수 금액 거부"""
processor = PaymentProcessor()
with self.assertRaises(ValueError):
processor.process_payment(1, -100, valid_card)
def test_zero_amount_raises_error(self):
"""0원 거부"""
processor = PaymentProcessor()
with self.assertRaises(ValueError):
processor.process_payment(1, 0, valid_card)
def test_excessive_amount_raises_error(self):
"""고액 결제 거부"""
processor = PaymentProcessor()
with self.assertRaises(ValueError):
processor.process_payment(1, 20000000, valid_card)
def test_invalid_card_raises_error(self):
"""유효하지 않은 카드 거부"""
processor = PaymentProcessor()
with self.assertRaises(ValueError):
processor.process_payment(1, 10000, invalid_card)
def test_insufficient_balance_raises_error(self):
"""잔액 부족시 거부"""
processor = PaymentProcessor()
with self.assertRaises(InsufficientFundsError):
processor.process_payment(user_with_low_balance, 100000, valid_card)
def test_successful_payment_returns_transaction_id(self):
"""정상 결제 처리"""
processor = PaymentProcessor()
transaction_id = processor.process_payment(1, 50000, valid_card)
self.assertIsNotNone(transaction_id)
self.assertTrue(self._verify_transaction_recorded(transaction_id))
# 우선순위 낮음: 단순 모델 클래스 - 테스트 생략 가능
class UserProfile:
"""단순 데이터 컨테이너"""
def __init__(self, name, email, bio):
self.name = name
self.email = email
self.bio = bio
def to_dict(self):
"""프레임워크 기본 기능 - 테스트 불필요"""
return {
'name': self.name,
'email': self.email,
'bio': self.bio
}
# 이런 코드까지 테스트하면 유지보수 비용만 늘어나요실전에서 적용하는 합리적인 커버리지 목표
실무에서는 프로젝트 특성에 맞는 합리적인 목표를 설정해야 해요.
# 프로젝트별 커버리지 목표 예시
# 금융 서비스 - 높은 안정성 요구
coverage_target:
line: 85%
branch: 80%
critical_paths: 100% # 결제, 송금 등 핵심 기능만
# 일반 웹 서비스
coverage_target:
line: 70%
branch: 65%
core_business_logic: 90%
# 스타트업 MVP
coverage_target:
line: 50%
branch: 45%
critical_user_flows: 80% # 회원가입, 로그인 등중요한 것은 숫자가 아니라 무엇을 테스트하느냐에요.
// 실전 팁: 테스트 우선순위 매트릭스
const testPriorityMatrix = {
// 높은 비즈니스 가치 + 높은 복잡도 = 최우선
critical: [
'사용자 인증 및 권한',
'결제 처리',
'데이터 마이그레이션',
'보안 관련 로직'
],
// 높은 비즈니스 가치 + 낮은 복잡도 = 우선
high: [
'핵심 CRUD 연산',
'중요 비즈니스 규칙',
'자주 변경되는 기능'
],
// 낮은 비즈니스 가치 + 높은 복잡도 = 중간
medium: [
'복잡한 유틸리티 함수',
'알고리즘 로직',
'데이터 변환 로직'
],
// 낮은 비즈니스 가치 + 낮은 복잡도 = 낮음 (테스트 생략 가능)
low: [
'단순 getter/setter',
'상수 정의',
'설정값',
'DTO 클래스'
]
};
// 이렇게 우선순위를 정하고 테스트하면
// 적은 노력으로 높은 효과를 낼 수 있어요흔한 실수와 올바른 접근법
실수 1: 커버리지 수치에만 집착하기
잘못된 예:
# 커버리지를 위한 의미 없는 테스트
class Calculator
def add(a, b)
a + b
end
def subtract(a, b)
a - b
end
end
# 나쁜 테스트
RSpec.describe Calculator do
it 'executes all methods' do
calc = Calculator.new
calc.add(1, 2) # 결과 검증 없음
calc.subtract(5, 3) # 결과 검증 없음
# 커버리지 100% 달성했지만 아무것도 검증하지 않음
end
end올바른 예:
# 의미 있는 테스트
RSpec.describe Calculator do
describe '#add' do
it '두 양수를 더하면 올바른 결과를 반환한다' do
calc = Calculator.new
expect(calc.add(2, 3)).to eq(5)
end
it '음수를 포함한 덧셈도 올바르게 처리한다' do
calc = Calculator.new
expect(calc.add(-2, 3)).to eq(1)
expect(calc.add(-2, -3)).to eq(-5)
end
end
describe '#subtract' do
it '큰 수에서 작은 수를 빼면 양수를 반환한다' do
calc = Calculator.new
expect(calc.subtract(5, 3)).to eq(2)
end
it '작은 수에서 큰 수를 빼면 음수를 반환한다' do
calc = Calculator.new
expect(calc.subtract(3, 5)).to eq(-2)
end
end
end실수 2: 구현 세부사항 테스트하기
잘못된 예:
// 구현에 강하게 결합된 테스트
public class UserService {
private UserRepository repository;
public User createUser(String name, String email) {
// 1. 이메일 중복 확인
if (repository.existsByEmail(email)) {
throw new DuplicateEmailException();
}
// 2. 사용자 생성
User user = new User();
user.setName(name);
user.setEmail(email);
user.setCreatedAt(LocalDateTime.now());
// 3. 저장
return repository.save(user);
}
}
// 나쁜 테스트 - 내부 구현을 검증
@Test
public void testCreateUser() {
UserService service = new UserService(mockRepository);
// 내부 메서드 호출 순서까지 검증
when(mockRepository.existsByEmail("test@test.com")).thenReturn(false);
when(mockRepository.save(any(User.class))).thenReturn(savedUser);
service.createUser("홍길동", "test@test.com");
// 문제: 내부 구현이 바뀌면 테스트가 깨져요
verify(mockRepository).existsByEmail("test@test.com"); // 순서 1
verify(mockRepository).save(any(User.class)); // 순서 2
// 리팩토링할 때마다 테스트 수정 필요
}올바른 예:
// 행동(behavior)을 검증하는 테스트
@Test
public void shouldCreateUserWithValidData() {
UserService service = new UserService(mockRepository);
when(mockRepository.existsByEmail("test@test.com")).thenReturn(false);
when(mockRepository.save(any(User.class))).thenReturn(savedUser);
User result = service.createUser("홍길동", "test@test.com");
// 결과만 검증 - 내부 구현은 신경쓰지 않음
assertNotNull(result);
assertEquals("홍길동", result.getName());
assertEquals("test@test.com", result.getEmail());
}
@Test
public void shouldThrowExceptionWhenEmailIsDuplicated() {
UserService service = new UserService(mockRepository);
when(mockRepository.existsByEmail("test@test.com")).thenReturn(true);
// 행동 검증: 중복 이메일이면 예외를 던진다
assertThrows(DuplicateEmailException.class, () -> {
service.createUser("홍길동", "test@test.com");
});
}실수 3: 테스트를 위해 프로덕션 코드 수정하기
잘못된 예:
# 테스트를 위해 억지로 만든 public 메서드
class OrderProcessor:
def process_order(self, order):
self._validate_order(order)
self._calculate_total(order)
self._apply_discount(order)
self._save_order(order)
self._send_notification(order)
# 원래는 private이어야 하는데 테스트를 위해 public으로 노출
def _validate_order(self, order):
if not order.items:
raise ValueError("주문 항목이 없습니다")
def _calculate_total(self, order):
# 복잡한 계산 로직
pass
# ... 나머지 private 메서드들도 모두 노출
# 나쁜 테스트 - private 메서드를 직접 테스트
def test_validate_order():
processor = OrderProcessor()
processor._validate_order(empty_order) # private 메서드 직접 호출
# 캡슐화 깨짐!올바른 예:
# public API만 테스트
class OrderProcessor:
def process_order(self, order):
"""public 인터페이스만 노출"""
self.__validate_order(order)
self.__calculate_total(order)
self.__apply_discount(order)
self.__save_order(order)
self.__send_notification(order)
def __validate_order(self, order):
"""private 메서드는 캡슐화 유지"""
if not order.items:
raise ValueError("주문 항목이 없습니다")
# ... private 메서드들
# 좋은 테스트 - public API를 통해 private 로직 검증
def test_process_order_with_empty_items():
processor = OrderProcessor()
empty_order = Order(items=[])
# public 메서드를 통해 검증
with pytest.raises(ValueError, match="주문 항목이 없습니다"):
processor.process_order(empty_order)
def test_process_order_applies_discount_correctly():
processor = OrderProcessor()
order = Order(items=[
Item("상품A", 10000),
Item("상품B", 20000)
])
processor.process_order(order)
# 최종 결과로 내부 로직 검증
assert order.total == 27000 # 10% 할인 적용됨
assert order.discount_applied == True성능과 유지보수를 고려한 테스트 작성 팁
팁 1: 테스트 속도 최적화
느린 테스트는 개발 생산성을 크게 떨어뜨려요. 테스트는 빠르게 실행되어야 해요.
// 느린 테스트 예시
describe('UserService (slow)', () => {
beforeEach(async () => {
// 매번 DB 초기화 - 느림!
await database.reset();
await database.seed();
});
test('should create user', async () => {
// 실제 DB 사용 - 느림!
const result = await userService.create({
name: '테스트',
email: 'test@test.com'
});
expect(result.id).toBeDefined();
});
// 100개 테스트 실행시간: 30초
});
// 빠른 테스트로 개선
describe('UserService (fast)', () => {
let mockRepository: jest.Mocked<UserRepository>;
beforeEach(() => {
// 인메모리 모킹 - 빠름!
mockRepository = {
save: jest.fn(),
findByEmail: jest.fn()
} as any;
});
test('should create user', async () => {
// DB 없이 로직만 테스트 - 빠름!
mockRepository.findByEmail.mockResolvedValue(null);
mockRepository.save.mockResolvedValue({
id: 1,
name: '테스트',
email: 'test@test.com'
});
const service = new UserService(mockRepository);
const result = await service.create({
name: '테스트',
email: 'test@test.com'
});
expect(result.id).toBe(1);
});
// 100개 테스트 실행시간: 0.5초
});
// 통합 테스트는 별도로 소수만 작성
describe('UserService (integration)', () => {
// 실제 DB 사용하는 테스트는 핵심 시나리오만 몇 개
test('end-to-end user creation flow', async () => {
// 실제 DB 사용
const result = await userService.create({
name: '통합테스트',
email: 'integration@test.com'
});
const saved = await database.users.findOne({ id: result.id });
expect(saved.email).toBe('integration@test.com');
});
});팁 2: 테스트 격리 보장
테스트 간 의존성이 있으면 디버깅이 어려워져요.
// 나쁜 예: 테스트 간 상태 공유
package user_test
var globalDB *sql.DB // 전역 상태 공유 - 위험!
func TestCreateUser(t *testing.T) {
user := CreateUser(globalDB, "홍길동")
// globalDB 상태 변경됨
}
func TestUpdateUser(t *testing.T) {
// 이전 테스트의 영향을 받을 수 있음!
user := FindUser(globalDB, 1)
// 테스트 순서에 따라 결과가 달라짐
}
// 좋은 예: 각 테스트마다 격리된 환경
func TestCreateUser(t *testing.T) {
// 테스트마다 새로운 DB 연결
db := setupTestDB(t)
defer teardownTestDB(t, db)
user := CreateUser(db, "홍길동")
assert.Equal(t, "홍길동", user.Name)
}
func TestUpdateUser(t *testing.T) {
// 완전히 독립적인 환경
db := setupTestDB(t)
defer teardownTestDB(t, db)
// 테스트에 필요한 데이터만 직접 준비
user := CreateUser(db, "홍길동")
updatedUser := UpdateUser(db, user.ID, "김철수")
assert.Equal(t, "김철수", updatedUser.Name)
}
func setupTestDB(t *testing.T) *sql.DB {
// 각 테스트마다 깨끗한 DB
db, _ := sql.Open("sqlite3", ":memory:")
runMigrations(db)
return db
}
func teardownTestDB(t *testing.T, db *sql.DB) {
db.Close()
}팁 3: 명확한 테스트 메시지
테스트가 실패했을 때 원인을 빠르게 파악할 수 있어야 해요.
// 나쁜 예: 불명확한 테스트 메시지
[Test]
public void Test1()
{
var result = calculator.Calculate(10, 5);
Assert.AreEqual(15, result); // 실패시: "Expected: 15, Actual: 50"
// 무엇을 테스트하는지 알 수 없음
}
// 좋은 예: 명확한 의도 표현
[Test]
public void Calculate_WhenAddingTwoNumbers_ShouldReturnCorrectSum()
{
// Arrange (준비)
var calculator = new Calculator();
int operand1 = 10;
int operand2 = 5;
int expectedSum = 15;
// Act (실행)
int actualSum = calculator.Calculate(operand1, operand2);
// Assert (검증)
Assert.AreEqual(expectedSum, actualSum,
$"Expected {operand1} + {operand2} = {expectedSum}, but got {actualSum}");
// 실패시: "Expected 10 + 5 = 15, but got 50"
// 훨씬 명확함!
}
[Test]
public void Calculate_WhenDividingByZero_ShouldThrowDivideByZeroException()
{
var calculator = new Calculator();
var exception = Assert.Throws<DivideByZeroException>(() =>
calculator.Divide(10, 0)
);
Assert.That(exception.Message, Does.Contain("0으로 나눌 수 없습니다"));
}대안적 품질 지표들
커버리지 외에도 코드 품질을 측정할 수 있는 더 나은 지표들이 있어요.
// 1. 뮤테이션 테스팅 (Mutation Testing)
// 코드를 일부러 변경했을 때 테스트가 실패하는지 확인
// 원본 코드
function isAdult(age) {
return age >= 18; // 뮤테이션: >= 를 > 로 변경하면?
}
// 테스트가 이걸 잡아낼까?
test('isAdult', () => {
expect(isAdult(20)).toBe(true); // 여전히 통과
// 18세 경계값을 테스트하지 않아서 뮤테이션을 못 잡음!
});
// 개선된 테스트
test('isAdult boundary cases', () => {
expect(isAdult(17)).toBe(false); // 경계값 테스트
expect(isAdult(18)).toBe(true); // 이제 뮤테이션을 잡아냄!
expect(isAdult(19)).toBe(true);
});
// Stryker 같은 도구로 뮤테이션 스코어 측정 가능
// 높은 뮤테이션 스코어 = 효과적인 테스트
// 2. 변경 기반 테스트 (Change-based Testing)
// 최근 변경된 코드의 테스트 커버리지를 측정
// package.json
{
"scripts": {
"test:changed": "jest --coverage --changedSince=main",
"test:coverage-diff": "diff-coverage --compare-branch=main --fail-under=90"
}
}
// 새로 추가한 코드는 90% 이상 커버리지 요구
// 기존 코드는 낮은 커버리지라도 허용
// 점진적으로 품질 개선 가능
// 3. 실패율 추적
// 프로덕션 버그 수 / 테스트로 잡은 버그 수
const qualityMetrics = {
productionBugs: 5, // 배포 후 발견된 버그
testCaughtBugs: 45, // 테스트로 미리 잡은 버그
escapeRate: 5 / 50, // 10% - 낮을수록 좋음
// 커버리지보다 이게 더 중요해요!
// 커버리지 100%여도 실제 버그를 못 잡으면 의미 없어요
};
// 4. 테스트 유지보수 비용 측정
const maintenanceMetrics = {
avgTimeToFixFailingTest: '15분', // 테스트 수정 시간
testCodeLines: 5000, // 테스트 코드 라인
productionCodeLines: 3000, // 프로덕션 코드 라인
ratio: 1.67, // 1.67:1 비율
// 테스트 코드가 프로덕션보다 훨씬 많으면 문제!
// 이상적 비율: 1:1 ~ 1.5:1
};실전 적용 가이드: 팀에서 어떻게 도입할까?
# 우리 팀의 테스트 전략 (실전 예시)
## 1단계: 현재 상태 파악
- 현재 커버리지: 65%
- 문제점: 의미 없는 getter 테스트가 30%
- 프로덕션 버그: 월평균 8건
## 2단계: 목표 설정
- 전체 커버리지 목표: 70% (올리지 않음)
- 핵심 비즈니스 로직 커버리지: 90%
- 프로덕션 버그: 월평균 3건 이하
## 3단계: 실행 계획
### Week 1-2: 정리
- [ ] getter/setter 테스트 제거
- [ ] 의미 없는 테스트 제거
- [ ] 테스트 분류 (critical/high/medium/low)
### Week 3-4: 보강
- [ ] 핵심 비즈니스 로직 테스트 보강
- [ ] 엣지 케이스 테스트 추가
- [ ] 통합 테스트 시나리오 작성
### Week 5-6: 자동화
- [ ] CI/CD에 뮤테이션 테스팅 추가
- [ ] PR마다 변경된 코드 커버리지 체크
- [ ] 주간 품질 리포트 자동화
## 4단계: 팀 규칙
```yaml
test_policy:
required:
- 새로운 API 엔드포인트는 반드시 테스트 작성
- 결제/인증 관련 코드는 100% 커버리지
- 버그 수정시 재현 테스트 먼저 작성
optional:
- DTO, Entity 같은 데이터 클래스는 테스트 생략 가능
- 단순 CRUD는 통합 테스트만으로 충분
- UI 컴포넌트는 핵심 동작만 테스트
review_checklist:
- [ ] 테스트가 실제 버그를 잡을 수 있는가?
- [ ] 테스트가 너무 구현에 의존적이지 않은가?
- [ ] 테스트 이름이 명확한가?
- [ ] 실패시 원인 파악이 쉬운가?결론: 숫자가 아닌 가치에 집중하세요
테스트 커버리지 100%는 마치 코드 품질의 완벽함을 보장하는 것처럼 보이지만, 실제로는 그렇지 않아요. 제가 10년 넘게 다양한 프로젝트를 경험하면서 내린 결론은 "의미 있는 50%가 의미 없는 100%보다 낫다"는 거예요.
핵심 요약:
커버리지는 양적 지표일 뿐, 질적 지표가 아니에요 - 코드가 실행되었다고 해서 제대로 검증된 건 아니에요.
리스크 기반으로 우선순위를 정하세요 - 모든 코드를 동일하게 테스트할 필요는 없어요. 비즈니스 핵심 로직, 보안, 금전 거래 관련 코드에 집중하세요.
테스트의 목적은 버그를 조기에 발견하는 것 - 커버리지 수치를 높이는 게 아니라 실제 문제를 찾아내는 테스트를 작성하세요.
유지보수 비용을 고려하세요 - 테스트도 코드예요. 변경할 때마다 수정해야 하는 부담을 항상 염두에 두세요.
행동을 테스트하고 구현은 숨기세요 - 내부 구현이 바뀌어도 테스트는 깨지지 않아야 해요.
실무 적용 팁:
- 새 프로젝트를 시작한다면 처음부터 테스트 우선순위 매트릭스를 정의하세요
- 기존 프로젝트라면 의미 없는 테스트부터 제거하고, 핵심 로직 테스트를 보강하세요
- 팀 내에서 "좋은 테스트"의 기준을 함께 논의하고 문서화하세요
- PR 리뷰시 커버리지 수치보다 테스트의 의미를 먼저 봐주세요
- 정기적으로 테스트 스위트를 리팩토링하세요 - 프로덕션 코드만큼 중요해요
관련 추천 주제:
- 통합 테스트 vs 단위 테스트: 언제 무엇을 써야 할까? - 테스트 피라미드 전략과 각 레벨의 적절한 비율
- TDD(테스트 주도 개발) 실전 가이드 - 이론이 아닌 실무에서 TDD를 효과적으로 적용하는 방법
- 테스트 더블(Mock, Stub, Spy) 완벽 정리 - 언제 어떤 테스트 더블을 사용해야 하는지
기억하세요. 테스트는 목적이 아니라 수단이에요. 더 나은 소프트웨어를 만들고, 자신 있게 리팩토링하고, 안심하고 배포하기 위한 도구죠. 숫자에 현혹되지 말고, 진짜 가치 있는 테스트를 작성하는 데 집중하세요!
'테스트' 카테고리의 다른 글
| Playwright 크로스 브라우저 테스트 완벽 가이드 - 3분만에 Chrome, Firefox, Safari 동시 테스트하기 (0) | 2026.03.13 |
|---|