테스트는 세 가지를 줍니다. 코드를 고칠 때의 안전망(회귀를 바로 잡아냄), 설계에 대한 피드백(테스트하기 어려우면 결합이 높다는 신호), 그리고 실행 가능한 명세(주석보다 코드가 거짓말을 안 함)입니다. "찍어 보기" 는 이 세 가지를 하나도 못 줍니다.
| 항목 | main 으로 찍어보기 | JUnit 테스트 |
|---|---|---|
| 판정 | 사람이 눈으로 | 자동, assert |
| 반복 실행 | 매번 손으로 다시 | 명령 한 줄 |
| 격리 | DB·시계가 매번 다름 | 페이크·고정 시계 |
| 실패 위치 | 로그를 뒤져야 함 | 실패한 줄·값 표시 |
| 종류 | 대상 | 실행 시간 | 무엇을 잡나 |
|---|---|---|---|
| 단위 | 클래스 하나, 의존은 가짜 | 밀리초 | 로직·경계값 오류 |
| 통합 | 진짜 DB·매퍼·SQL | 수백 ms~초 | SQL·스키마 불일치 |
| E2E | 배포된 전체 시스템 | 초~분 | 배선·설정 오류 |
단위 테스트가 압도적으로 많고, 통합 테스트는 핵심 경로만, E2E 는 소수만 둡니다. 이 레슨의 OrderServiceTest 가 단위, JdbcOrderRepositoryTest 가 통합에 해당합니다.
OrderService 는 저장소·결제·시계 세 의존을 전부 생성자로 받습니다. 메서드 안에서 new JdbcOrderRepository() 나 LocalDateTime.now() 를 직접 부르면 그 순간 테스트가 불가능해집니다. 실행할 때마다 결과가 달라지고 진짜 DB 없이는 돌릴 수 없기 때문입니다.
OrderRepository·PaymentGateway 인터페이스는 "바꿔 끼우기 위해" 존재합니다. 운영에서는 JdbcOrderRepository, 테스트에서는 메모리 구현을 꽂습니다. Spring 의 @Autowired 생성자 주입도 같은 원리입니다.
시계는 Clock 을 빈으로 등록하고 실제로는 Clock.systemDefaultZone(), 테스트에서는 Clock.fixed(...) 를 씁니다. LocalDateTime.now() 를 직접 부르지 않고 LocalDateTime.now(clock) 으로 감싸는 것이 핵심입니다.
| 종류 | 무엇을 하나 | 언제 쓰나 |
|---|---|---|
| 더미 | 값만 채움, 동작 없음 | 생성자 인자를 채우기만 할 때 |
| 스텁 | 정해진 답을 돌려줌 | 특정 응답을 강제할 때 |
| 페이크 | 실제 동작을 간단히 재구현 | 저장소처럼 상태를 가진 의존 |
| 목 | 호출 여부·인자를 검증 | "호출됐는지" 자체가 검증 대상일 때 |
InMemoryOrderRepository 는 페이크, StubGateway 는 스텁입니다. 둘 다 손으로 8~10줄이면 충분해서 라이브러리가 필요 없습니다.
Mockito 를 쓰면 같은 스텁을 아래처럼 씁니다.
@Mock PaymentGateway pg;
@InjectMocks OrderService service;
when(pg.charge(any(), any())).thenThrow(new PaymentGateway.PaymentException("한도 초과"));
verify(pg, never()).charge(eq("kim"), any()); // 호출 안 됐는지 검증손으로 쓴 페이크는 읽기 쉽고 다른 테스트에서도 재사용됩니다. Mockito 는 호출 여부·횟수·인자를 검증해야 하거나 인터페이스가 커서 손으로 다 구현하기 번거로울 때 낫습니다.
테스트 하나는 준비(Arrange)·실행(Act)·검증(Assert) 세 덩어리로 나뉩니다. 이 순서를 지키면 테스트가 무엇을 확인하는지 코드만 보고도 알 수 있습니다. 테스트 하나에는 검증할 이유가 하나여야 합니다.
이름은 상황_기대 형식이나 @DisplayName 한글 문장으로 씁니다. place_ok 보다 place_paymentFails_savesFailed 처럼 상황이 드러나야 실패 메시지만 보고도 원인을 짐작할 수 있습니다.
경계값(99999/100000/100001)은 @ParameterizedTest 와 @CsvSource 로 표처럼 나열합니다. 여러 필드를 한 번에 검증할 때는 assertAll 을 써서 하나가 깨져도 나머지 결과까지 다 봅니다. assertThrows 는 예외 객체를 돌려주므로 메시지까지 검증할 수 있습니다.
테스트 간에는 상태를 공유하면 안 됩니다. @BeforeEach 로 매번 새 페이크·스텁·서비스를 만들어야 어떤 순서로 실행되든 결과가 같습니다.
Clock.fixed(instant, zone) 은 특정 순간에 멈춘 시계를 만듭니다. Clock.offset(clock, duration) 은 기존 시계를 앞뒤로 밀어, 이 레슨의 취소 테스트에서 "24시간 경과" 를 실제로 24시간 기다리지 않고 시뮬레이션합니다.
같은 원리로 난수는 Random 을 주입받게 만들고, 파일은 @TempDir 로 매 테스트마다 새 임시 폴더를 받습니다. 외부 HTTP 는 REST 레슨의 FakePartner 처럼 로컬 HttpServer 를 띄워 대체합니다. 세 가지 모두 "밖에서 주입 가능하게 만들기" 라는 같은 해법입니다.
JdbcOrderRepositoryTest 는 H2 메모리 DB 에 진짜 SQL 을 던집니다. 클래스당 커넥션·스키마는 @BeforeAll 에서 한 번만 만듭니다. 테스트마다 @BeforeEach 에서 트랜잭션을 시작하고 @AfterEach 에서 롤백해, 테스트가 남긴 행이 다음 테스트에 보이지 않게 합니다.
H2 와 Oracle 은 문법이 다릅니다. H2 의 MODE=Oracle 옵션으로 상당수는 맞출 수 있지만, 시퀀스·MERGE·ROWNUM 은 06 레슨에서 다뤘듯 방언 차이가 남아 실제 Oracle 로 최종 확인이 필요합니다.
Spring 은 @MybatisTest/@JdbcTest 에 @Transactional 을 붙이면 롤백을 자동으로 해 줍니다. @SpringBootTest 는 컨텍스트 전체를 띄워 느리므로 꼭 필요한 곳에만 씁니다.
이 레슨은 junit-platform-console-standalone jar 하나로 Gradle 없이 테스트를 실행합니다. 실무 Gradle 프로젝트에서는 ./gradlew test 가 내부적으로 같은 콘솔 런처를 호출하고, 결과는 build/reports/tests 에 HTML 로 남습니다.
CI 에서는 테스트가 실패하면 배포를 멈춥니다. 커버리지(JaCoCo)는 "코드 중 안 본 곳을 찾는 도구" 일 뿐, 80% 같은 숫자를 채우는 것 자체가 목표가 되면 안 됩니다. 실행이 느린 테스트는 @Tag("slow") 로 분리해 평소에는 빼고 돌립니다.
.\gradlew test # build\reports\tests\test\index.html 에 결과
.\gradlew test jacocoTestReport # build\reports\jacoco 에 커버리지 리포트 추가
.\gradlew test -x test --tests "*Slow*" # slow 태그만 빼고 돌리는 식으로 필터링