개발자들이 모인 컨퍼런스나 술자리에서 "TDD(Test-Driven Development, 테스트 주도 개발)를 실무에서 엄격하게 지키시나요?"라는 질문이 나오면 십중팔구 깊은 한숨과 변명이 쏟아집니다. "스타트업이라 일정이 너무 바빠서요", "기획과 요구사항이 매일같이 뒤집어지는데 테스트부터 짜면 코드를 두 번씩 고쳐야 해요". 네, 맞습니다. 실무의 치열하고 급변하는 비즈니스 환경에서 모든 코드를 교과서적인 TDD 방식(Red-Green-Refactor)으로 짜는 것은 이상론에 가깝습니다. 하지만 그렇다고 해서 '단위 테스트(Unit Test)' 자체를 작성하는 것을 포기하는 것은 개발자로서의 책임을 방기하는 죄악입니다. 내가 심혈을 기울여 짠 결제 로직이 장바구니에 중복 쿠폰을 먹이고 마일리지를 최대치로 차감했을 때 결제 금액이 정확히 0원이 떨어지는지, 아니면 마이너스가 되어 시스템이 고객에게 오히려 돈을 입금해 주는 대형 사고를 치고 회사에 막대한 재무적 손해를 끼칠지 어떻게 확신할 수 있습니까? 개발서버에 띄워놓고 포스트맨(Postman)이나 스웨거(Swagger)로 수동 클릭을 100번씩 반복하는 것은 인간이 할 짓이 아닙니다.
이번 52단계 포스팅에서는 단순히 `@Test` 애노테이션을 껍데기만 붙이는 수준을 아득히 넘어, 기획자의 인간 언어를 그대로 코드로 번역하는 위대한 테스트 철학인 BDD(Behavior-Driven Development, 행위 주도 개발)의 정수와, 가짜 객체(Mock)를 내 마음대로 조종하는 Mockito 프레임워크의 심화 스킬, 그리고 실서버를 붕괴시키는 악마 같은 엣지 케이스(Edge Case)를 사전에 100% 방어해 내는 커버리지 달성법에 대해 상세히 알아보겠습니다.

1. 인간의 언어로 테스트를 설계하다: BDD (Given-When-Then) 철학
과거 주니어 시절의 테스트 코드들을 열어보면 그야말로 중구난방입니다. 객체를 생성하는 코드, 메서드를 호출하는 코드, `System.out.println`으로 찍어보는 코드, 그리고 `assert` 검증문이 한데 뒤엉켜 있어서 한 달 뒤에 코드를 열어보면 내가 도대체 어떤 비즈니스 요구사항을 검증하려고 했는지조차 알아볼 수 없습니다. 이를 해결하기 위해 TDD의 진화형으로 등장한 개념이 바로 BDD(행위 주도 개발)의 Given-When-Then 프레임워크입니다. 이는 단순한 코딩 기법이 아니라, 기획자가 워드나 지라(Jira)에 쓴 요구사항 정의서를 토씨 하나 틀리지 않고 그대로 코드로 번역하는 기적의 아키텍처입니다.

- Given (주어진 상황 / 전제 조건): 테스트를 수행하기 위한 완벽한 사전 무대를 세팅합니다. "등급이 VIP인 회원이 10,000원짜리 상품과 10% 할인 쿠폰을 보유하고 있는 상태"라는 초기 데이터(Mock Data)를 선언합니다. 이 단계에서는 비즈니스 로직이 절대 실행되어서는 안 됩니다. 오직 환경 세팅만이 존재합니다.
- When (행동 발생 / 트리거): 실제로 우리가 검증하고자 하는 타겟 메서드(Target Method) 단 하나만을 정확히 실행합니다. "이 회원이 '결제하기' 버튼을 누른다(메서드 호출)." When 단계는 단 한 줄, 혹은 두 줄을 넘지 않는 것이 원칙입니다. 여러 행동을 섞으면 무엇 때문에 테스트가 실패했는지 추적할 수 없습니다.
- Then (결과 검증 / 상태 확인): 행동이 끝난 후 시스템의 상태가 기대한 대로 변했는지 확인합니다. "최종 반환된 결제 금액이 9,000원이 맞는지 Assert(단언)하고, 회원의 쿠폰 상태가 '사용 완료(USED)'로 DB에 업데이트 요청(save)이 들어갔는지 검증한다."
// 💡 완벽한 BDD 패턴과 Mockito가 결합된 실무 단위 테스트 코드
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@InjectMocks
private PaymentService paymentService; // 테스트 대상 (진짜 객체)
@Mock
private CouponRepository couponRepository; // 외부 의존성 (가짜 객체)
@Test
@DisplayName("VIP 회원이 10% 할인 쿠폰을 적용하면 상품 가격에서 정확히 10%가 깎여야 한다")
void applyCouponForVip_Success() {
// 1. Given (기획서의 상황 세팅)
Member vipMember = new Member("VIP", 10000);
Coupon tenPercentCoupon = new Coupon(0.1, CouponStatus.ACTIVE);
// Mock 객체에게 "내 서비스 코드가 너한테 findById(1L)을 부르면, 무조건 tenPercentCoupon을 반환해!" 라고 사전 지시 (Stubbing)
BDDMockito.given(couponRepository.findById(1L)).willReturn(Optional.of(tenPercentCoupon));
// 2. When (실제 결제 서비스 로직 실행)
OrderResult result = paymentService.pay(vipMember, 1L);
// 3. Then (결과 및 행위 검증)
assertThat(result.getFinalPrice()).isEqualTo(9000); // 10000원의 10% 할인
// couponRepository.save()가 파라미터에 상관없이 정확히 1번 호출되었는가? (행위 검증)
BDDMockito.then(couponRepository).should(times(1)).save(any(Coupon.class));
}
}
2. 가짜 객체 조종술의 끝판왕: Mockito 심화 (Mock, Spy, Captor)
위의 테스트 코드에서 볼 수 있듯이, paymentService 내부에서 DB와 연결된 couponRepository를 진짜로 호출하게 된다면 그것은 더 이상 0.001초 만에 실행되는 '단위 테스트(Unit Test)'가 아니라 '통합 테스트'로 변질되어 버립니다. 단위 테스트는 오직 paymentService 내부의 수학적이고 논리적인 if-else 흐름과 가격 계산 '로직'만을 초고속으로 검증해야 합니다. 따라서 외부 의존성(DB, Redis, 외부 결제사 API 등)은 철저히 가짜 객체(Mock)로 껍데기를 덮어씌워야 합니다. 이때 자바 진영에서 사용하는 절대적인 표준 프레임워크가 바로 Mockito입니다.
@Mock과 @InjectMocks의 하모니
스프링 부트 컨텍스트(스프링 컨테이너)를 띄울 필요가 전혀 없습니다. 테스트 클래스 상단에 @ExtendWith(MockitoExtension.class)만 붙여주십시오. 가짜로 껍데기만 만들 객체(Repository, 외부 Client 등)에는 @Mock을 붙이고, 그 가짜 객체들을 주입받아 실제로 비즈니스 로직을 수행할 대상(Service)에는 @InjectMocks를 붙입니다. Mockito 프레임워크가 알아서 가짜 부품들을 조립하여 타겟 서비스 객체를 생성해 줍니다. 매우 가볍고 빠릅니다.
Mock과 Spy의 치명적 차이점
실무에서는 단순히 @Mock만 쓰지 않습니다. 상황에 따라 @Spy를 섞어 써야 할 때가 있습니다.
- Mock: 100% 깡통 가짜 껍데기입니다.
BDDMockito.given()으로 어떤 값을 반환하라고 명시적으로 지시(Stubbing)하지 않으면 무조건null이나0을 뱉어냅니다. 외부 API 호출이나 DB 레이어 등 내가 제어할 수 없는 바깥세상을 흉내 낼 때 적합합니다. - Spy: 반인반수(반은 진짜 로직, 반은 가짜 껍데기)입니다. 실제 객체의 메서드 코드를 그대로 실행하면서, 특정 메서드 단 하나만 가짜로 가로채어(Stub) 반환값을 조작하고 싶을 때 사용합니다. 레거시 코드를 테스트하거나, 거대한 내부 유틸리티 클래스의 기능은 그대로 쓰되 외부 결제 모듈 호출 부분만 덮어씌우고 싶을 때 매우 유용합니다. 하지만 Spy를 남용하면 테스트의 격리(Isolation)가 깨져서 원인을 알 수 없는 사이드 이펙트가 터질 수 있으므로 주의해야 합니다.
비밀 병기: @Captor (ArgumentCaptor)
테스트 고수들이 숨겨놓고 쓰는 비밀 무기가 있습니다. 바로 ArgumentCaptor입니다. When 단계에서 로직이 실행된 후, 내 서비스가 repository.save()를 호출할 때 도대체 '어떤 데이터(파라미터)'를 밀어 넣었는지 낚아채서(Capture) 까보고 싶을 때 사용합니다. "서비스가 쿠폰을 USED 상태로 바꾼 엔티티를 정확히 save()에 넘겼는가?"를 단언(Assert)할 때 없어서는 안 될 필수 도구입니다.
3. 악마는 디테일에 숨어 있다: 엣지 케이스(Edge Case) 방어술
주니어 개발자가 환호하며 제출하는 테스트 코드는 늘 "해피 패스(Happy Path, 모든 조건이 정상적으로 들어맞는 상황)"만 단 한 개 존재하고 끝납니다. VIP 회원이 정상적인 쿠폰을 넣었을 때 결제가 잘 되는지만 봅니다. 하지만 실무에서 대형 장애(Incident)와 금전적 손실은 항상 기괴하고 말도 안 되는 엣지 케이스(Edge Case, 경계값 및 비정상 상황)에서 터집니다.
진정한 방탄 테스트를 짜려면 다음과 같은 악마적인 질문을 끈질기게 스스로에게 던져야 합니다.
- "쿠폰 할인 금액(15,000원)이 원본 상품 가격(10,000원)보다 커서, 뺄셈 로직 후 결제 금액이 마이너스(-5000원)가 되면 어떡하지? 마이너스 결제 승인이 PG사로 넘어가나?"
- "이미 어제 날짜로 만료(EXPIRED)되었거나, 남이 이미 사용한(USED) 쿠폰 ID를 파라미터로 악의적으로 밀어 넣으면, 결제가 진행되는가 아니면 커스텀 예외(Custom Exception)가 정확히 터지는가?"
- "파라미터로
null값이 넘어왔을 때NullPointerException이 터지면서 서버가 기절하는가, 아니면 우아하게 400 Bad Request로 포장되어 나가는가?"
JUnit 5의 assertThrows()나 AssertJ의 assertThatThrownBy() 문법을 적극적으로 활용하여, 이런 기괴하고 비정상적인 데이터를 밀어 넣었을 때 내가 의도한 예외(예: CouponExpiredException)가 정확히 뿜어져 나오는지(Then)를 검증하는 것이, 해피 패스를 테스트하는 것보다 100배는 더 숭고하고 중요한 작업입니다. 실무의 테스트 코드는 해피 패스 1개당 엣지 케이스 테스트가 5개 이상 붙어있는 것이 정상입니다.
정리하며
이번 52단계 포스팅에서는 바쁘다는 핑계로 테스트 코드를 단순히 짐 덩어리로 여기던 낡은 시각을 완전히 박살 내고, 기획서의 인간 언어를 그대로 코드로 번역하는 BDD(Given-When-Then) 철학과 Mockito를 활용한 극강의 레이어 격리 기술, Spy와 Captor의 활용법, 그리고 실서버 붕괴 및 금전적 손실을 막아주는 엣지 케이스 검증술에 대해 뼈 때리게 파헤쳐 보았습니다. 잘 작성된 테스트 코드는 그 자체로 미래의 나와 내 동료, 새로 입사한 신입 사원을 위한 가장 완벽하고 100% 동작을 보장하는 '살아 숨 쉬는 기술 문서(Living Documentation)'입니다. 자, 이렇게 개발자들이 각자 열심히 테스트 코드를 짰습니다. 그런데 팀장님이나 CTO가 "우리 프로젝트의 테스트 커버리지가 도대체 몇 퍼센트(%) 야? 안전한 거 맞아?"라고 물어본다면 어떻게 증명해야 할까요? 소스 코드의 빈틈을 감시하는 피도 눈물도 없는 사이버 감사관 시스템!
다음 53단계 포스팅에서는 "Jacoco와 SonarQube: 코드 커버리지 80% 의무 달성 게이트 구축과 정적 분석으로 레거시 악취(Code Smell) 완벽하게 제거하기"에 대해 아주 치밀하게 딥다이브 해보겠습니다!