"일단 코드를 짜고 보자"는 방식은 그만!

소프트웨어 개발의 품질과 생산성을 혁신하는 방법, 바로 테스트 주도 개발(Test-Driven Development, TDD)입니다.
이번 포스팅에서는 TDD의 기본 원칙부터 Java 21 환경에서 가장 많이 사용되는 JUnit 5 어노테이션, 그리고 복잡한 의존성을 처리하는 Mocking 기술까지, TDD의 기본 가이드를 작성해 보려고해요.
1. 💡 단위 테스트(Unit Test) 클래스를 작성해야 하는 이유
TDD의 핵심인 단위 테스트는 코드를 작성하는 것만큼, 아니 그 이상으로 중요한 작업입니다.
| 중요성 | 상세 설명 |
| 버그 조기 발견 | 개발 초기 단계에서부터 버그를 잡아내어, 나중에 발견했을 때보다 수정 비용을 획기적으로 절감합니다. |
| 코드의 설계 개선 | 테스트 코드를 먼저 작성하면, 자연스럽게 테스트하기 쉬운 코드를 만들게 되어 모듈화되고 깔끔한 설계를 유도합니다. |
| 안전한 리팩터링 | 기존 코드의 기능을 보장하는 테스트가 있다면, 언제든 안심하고 코드를 개선(리팩토링)하거나 기능을 추가할 수 있습니다. |
| 코드 명세서 역할 | 테스트 코드 자체가 이 코드는 이렇게 작동해야 한다라는 가장 정확하고 실행 가능한 문서 역할을 합니다. |
2. 📝 IntelliJ에서 단위 테스트 파일 만들기 및 구조
단위 테스트 파일은 프로덕션 코드와 분리된 src/test/java 경로에 생성됩니다. IntelliJ IDEA는 이 작업을 매우 빠르고 쉽게 할 수 있도록 도와줍니다.
1) IntelliJ를 이용한 테스트 파일 자동 생성 (가장 빠른 방법)
일반 개발 코드(src/main/java)를 작성한 후, 해당 클래스에 대한 테스트 파일을 바로 생성할 수 있습니다.
- 테스트 대상 클래스(UserService.java 등)를 IntelliJ 편집기에서 엽니다.
- 클래스 이름 위에서 커서를 두고 Alt + Enter (Mac: ⌥ + ⏎)를 누릅니다.
- 팝업 메뉴에서 Create Test (테스트 생성)를 선택합니다.
- 열리는 창에서 다음을 설정합니다.
- Testing library: JUnit5를 선택합니다.
- File location: 기본값(src/test/java 경로)을 그대로 둡니다.
- Methods to test: 테스트를 만들고자 하는 메서드들을 체크합니다.
- OK를 클릭하면 src/test/java 경로에 UserServiceTest.java 파일이 자동으로 생성됩니다.
2) 기본 테스트 구조
테스트 클래스는 일반적으로 별도의 상속 없이 작성되며, 테스트 코드를 포함하는 테스트 메서드들로 구성됩니다.
package com.example.service; // 프로덕션 코드와 동일한 패키지 구조
import org.junit.jupiter.api.Test; // JUnit 5의 핵심 어노테이션
import static org.junit.jupiter.api.Assertions.*; // 검증을 위한 static import
class UserServiceTest { // 테스트 대상 클래스 이름 + Test
// 테스트 케이스를 정의하는 메서드
@Test
@DisplayName("사용자 ID가 유효할 때, 유저 객체를 반환해야 함") // 🚨 이 이름으로 출력됩니다.
void shouldReturnUserWhenIdIsValid() {
// 1. Given (준비): 테스트에 필요한 초기 데이터나 객체를 설정
UserService service = new UserService();
// 2. When (실행): 테스트 대상 메서드를 실행
User result = service.findUserById(1);
// 3. Then (검증): 실행 결과를 예상 값과 비교하여 확인
assertNotNull(result);
assertEquals("Alice", result.getName());
}
}
3. ✨ JUnit 5 핵심 어노테이션
JUnit 5 (JUnit Jupiter)는 유닛테스트(단위테스트)를 작성할 때 쓰이는데요, 기본 @Test, @BeforeEach 외에도 자주 사용되는 유용한 어노테이션들을 소개합니다.
| 어노테이션 | 설명 | 용도 |
| @Test | 해당 메서드가 실행 가능한 단위 테스트임을 명시합니다. | 개별 테스트 케이스 정의 |
| @DisplayName | 테스트 결과에 표시될 사람 친화적인 이름을 지정합니다. | 테스트 보고서 가독성 향상 |
| @BeforeAll | 해당 클래스의 모든 테스트 실행 전에 단 한 번 실행됩니다. (반드시 static이어야 함) | DB 연결, 공통 리소스 설정 등 |
| @AfterAll | 해당 클래스의 모든 테스트 실행 후에 단 한 번 실행됩니다. (반드시 static이어야 함) | DB 연결 해제, 공통 리소스 정리 |
| @ParameterizedTest | 여러 인수를 사용하여 동일한 테스트를 반복 실행합니다. | 다양한 입력 값에 대한 테스트 자동화 |
| @ValueSource | @ParameterizedTest와 함께 사용되며, 단일 인자 목록을 제공합니다. | shouldFailForInvalidEmails(@ValueSource(strings = {"a@b", "c@d"}) String email) |
| @Disabled | 해당 테스트 클래스나 메서드를 일시적으로 실행하지 않도록 비활성화합니다. | 실패하거나 작업 중인 테스트 임시 제외 |
4. 🧩 외부 의존성 격리: Mocking 기술과 도구
Mocking은 테스트 대상 객체가 의존하는 외부 객체(예: DB 연결 클래스, 외부 API 클라이언트)의 행동을 흉내 내는 가짜 객체를 만들어 사용하는 기술입니다.
테스트 코드를 작성할 때 가장 큰 장애물인 외부 의존성을 극복하는 방법을 배워야 합니다.
대부분의 비즈니스 로직은 데이터베이스, 외부 API, 파일 시스템 등 외부에 의존합니다. 단위 테스트는 이런 외부 환경을 배제하고 순수한 로직만을 테스트해야 합니다. 이때 필요한 기술이 바로 Mocking (모의 객체화)입니다.
1) Mocking의 핵심 목적
| 목적 | 설명 |
| 테스트 격리 | 테스트 대상 코드(System Under Test, SUT)만 실행되도록 외부 의존성을 차단하고, 순수한 비즈니스 로직만 검증합니다. |
| 환경 통제 | "DB 연결 실패 시", "외부 API가 특정 에러 코드를 반환할 시"와 같이, 실제 환경에서 만들기 어려운 특정 시나리오를 강제로 재현할 수 있습니다. |
| 실행 속도 | 실제 네트워크 통신이나 디스크 I/O 없이 메모리에서만 빠르게 실행되어 테스트 시간을 단축합니다. |
2) Mocking 도구의 종류
| 도구 | 역할 | 사용 예시 |
| Mockito | 객체 Mocking (단위) | 자바 클래스의 메서드 호출을 가로채서 원하는 값을 반환하도록 설정합니다. (가장 일반적인 단위 테스트 도구) |
| WireMock | HTTP Mocking (통합) | 실제 웹 서버처럼 작동하는 가짜 서버를 띄워, 외부 REST API 호출 시 Mocking 응답을 보내도록 설정합니다. |
3) 🔬 Mockito 활용법: @Mock과 @InjectMocks
Java 단위 테스트의 표준 Mocking 프레임워크인 Mockito와, JUnit 5에서 이를 편리하게 사용할 수 있도록 도와주는 어노테이션들을 알아봅니다.
- Mockito 핵심 어노테이션
| 어노테이션 | 용도 | 설명 |
| @Mock | 가짜 객체 생성 | 테스트 대상이 의존하는 클래스의 모의 객체를 만듭니다. (예: UserRepository의 가짜 객체) |
| @InjectMocks | 테스트 대상 정의 | 실제로 테스트하려는 서비스/컨트롤러 객체를 생성하고, 여기에 위에 정의된 @Mock 객체들을 자동으로 주입합니다. |
| @ExtendWith | Mockito 확장 | JUnit 5 환경에서 Mockito 어노테이션을 사용할 수 있도록 환경을 설정합니다. (@ExtendWith(MockitoExtension.class)) |
- Mockito를 사용한 테스트 예시
다음 코드는 OrderService가 OrderRepository에 의존할 때, DB 접속 없이 OrderService의 로직만 테스트하는 방법을 보여줍니다.
// OrderServiceTest.java
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import static org.mockito.Mockito.when;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith; // 🚨 반드시 필요
@ExtendWith(MockitoExtension.class) // Mockito 기능을 사용하도록 JUnit 5 확장
class OrderServiceTest {
@Mock // 가짜 DB 레포지토리
OrderRepository orderRepository;
@InjectMocks // 테스트 대상 (가짜 레포지토리가 자동으로 주입됨)
OrderService orderService;
@Test
void shouldReturnTrueIfOrderIsPlacedSuccessfully() {
// Given (준비)
long orderId = 101L;
// 🚨 When (행동 지정: Stubbing)
// 'orderRepository.save(any())'가 호출되면, true를 반환하도록 가짜 행동을 지정
when(orderRepository.save(any())).thenReturn(true);
// When (실행)
boolean isSuccess = orderService.placeOrder(orderId, 10000);
// Then (검증)
assertTrue(isSuccess);
// verify(orderRepository).save(any()); // 🚨 (선택) 실제로 save 메서드가 호출되었는지 검증할 수도 있습니다.
}
}
- 목 서버 (Mock Server)의 필요성
단순한 객체 주입(Dependency Injection)을 넘어서, 서비스가 HTTP 통신을 통해 외부 시스템과 복잡하게 통신할 때는 WireMock 같은 목 서버가 필요합니다.
- Mock Server 역할: 실제 외부 API의 URL을 가로채서, 개발자가 미리 정의한 JSON 응답을 즉시 반환해 줍니다.
- 장점: RestTemplate이나 WebClient 같은 HTTP 클라이언트 코드를 건드리지 않고, 네트워크 레벨에서 테스트를 격리할 수 있습니다.
작성하게 된 계기는 최근에 단위테스트를 보통 어떻게 작성하냐는 질문에 intelliJ에서 주구장창 만드는게 @UnitTest인데도 설명을 잘 못 하겠더라는거죠... 음.. 목서버.. 디스플레이네임.. 어노테이션붙여서 쪼개서... 테스트 돌려서...
그래서 기본기를 다시 잡고가자는 느낌으로 유닛테스트 작성 가이드문서를 쓰게되었답니다 !
'IT > Backend | All' 카테고리의 다른 글
| 🧪테스트주도개발(TDD) 하기 | Apache Jmeter를 이용한 부하테스트 (1) | 2025.11.19 |
|---|---|
| 비동기 처리의 핵심 | 스레드, 멀티스레드, 큐 (0) | 2025.11.14 |
| 무한대(Infinity) 루프: Integer.MAX_VALUE를 활용한 최대 반복문 (0) | 2025.11.12 |
| MDC를 이용한 로그트레이싱 | Slf4j MDC(Mapped Diagnostic Context) (0) | 2025.11.07 |
| LGU+ 홈IoT | FCM 게이트웨이 개발 프로젝트 (0) | 2025.09.24 |