본문 바로가기

테스트

테스트 커버리지 100%가 오히려 독이 되는 이유 - 실무자가 말하는 진짜 테스트 전략

100% { } 테스트 테스트 커버리지 100%가 의미 없는 이유 100%

테스트 커버리지 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개보다 훨씬 가치 있어요.

효과적인 테스트 전략: 리스크 기반 접근법

모든 코드를 동일하게 테스트할 필요는 없어요. 리스크가 높은 부분에 집중해야 해요.

높은 우선순위로 테스트해야 하는 코드:

  1. 비즈니스 핵심 로직
  2. 보안 관련 코드
  3. 금전 거래 관련 코드
  4. 자주 변경되는 코드
  5. 복잡한 알고리즘

낮은 우선순위 (테스트 생략 가능):

  1. 단순 getter/setter
  2. 프레임워크가 보장하는 기능
  3. 설정 파일이나 상수
  4. 외부 라이브러리 래핑 코드
# 우선순위 높음: 결제 로직 - 반드시 철저히 테스트
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%보다 낫다"는 거예요.

핵심 요약:

  1. 커버리지는 양적 지표일 뿐, 질적 지표가 아니에요 - 코드가 실행되었다고 해서 제대로 검증된 건 아니에요.

  2. 리스크 기반으로 우선순위를 정하세요 - 모든 코드를 동일하게 테스트할 필요는 없어요. 비즈니스 핵심 로직, 보안, 금전 거래 관련 코드에 집중하세요.

  3. 테스트의 목적은 버그를 조기에 발견하는 것 - 커버리지 수치를 높이는 게 아니라 실제 문제를 찾아내는 테스트를 작성하세요.

  4. 유지보수 비용을 고려하세요 - 테스트도 코드예요. 변경할 때마다 수정해야 하는 부담을 항상 염두에 두세요.

  5. 행동을 테스트하고 구현은 숨기세요 - 내부 구현이 바뀌어도 테스트는 깨지지 않아야 해요.

실무 적용 팁:

  • 새 프로젝트를 시작한다면 처음부터 테스트 우선순위 매트릭스를 정의하세요
  • 기존 프로젝트라면 의미 없는 테스트부터 제거하고, 핵심 로직 테스트를 보강하세요
  • 팀 내에서 "좋은 테스트"의 기준을 함께 논의하고 문서화하세요
  • PR 리뷰시 커버리지 수치보다 테스트의 의미를 먼저 봐주세요
  • 정기적으로 테스트 스위트를 리팩토링하세요 - 프로덕션 코드만큼 중요해요

관련 추천 주제:

  • 통합 테스트 vs 단위 테스트: 언제 무엇을 써야 할까? - 테스트 피라미드 전략과 각 레벨의 적절한 비율
  • TDD(테스트 주도 개발) 실전 가이드 - 이론이 아닌 실무에서 TDD를 효과적으로 적용하는 방법
  • 테스트 더블(Mock, Stub, Spy) 완벽 정리 - 언제 어떤 테스트 더블을 사용해야 하는지

기억하세요. 테스트는 목적이 아니라 수단이에요. 더 나은 소프트웨어를 만들고, 자신 있게 리팩토링하고, 안심하고 배포하기 위한 도구죠. 숫자에 현혹되지 말고, 진짜 가치 있는 테스트를 작성하는 데 집중하세요!