728x90
💡 TDD(Test-Driven Development): 실패에서 시작하는 개발의 예술
1. TDD(Test-Driven Development)
🎯1.1 TDD란 무엇인가요?
- TDD는 개발자의 ‘습관’
- “테스트부터 작성하자. 그리고 통과시키자!”
- 정의: 테스트를 먼저 작성하고, 그 테스트를 통과하기 위해 코드를 작성하는 개발 방법론.
- 왜 중요한가요?
- 실패를 두려워하지 않고, 더 나은 코드를 만들어 가는 과정!
- Red-Green-Refactor라는 단순하지만 강력한 루틴으로 코드 품질을 높임.
🚦 1.2 기본 사이클: Red-Green-Refactor
1.2.1 Red: 실패를 즐기자!
- 테스트부터 작성 (아직 구현은 X).
- 실패를 통해 요구사항과 문제를 명확히 이해.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class CalculatorTest {
@Test
void testAddition() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result); // 실패! 왜? 구현 코드가 없으니까.
}
}
1.2.2 Green: 테스트 통과는 최소 노력으로.
- 테스트를 통과시키는 코드 작성.
- 복잡하게 고민하지 말고 “빨리 테스트 통과”에 집중!
public class Calculator {
public int add(int a, int b) {
return a + b; // 통과! 왜? 테스트가 요구한 기능만 구현했으니까.
}
}
1.2.3 Refactor: 이제 진짜 멋지게!
- 읽기 좋은 코드, 유지보수하기 쉬운 코드로 개선.
- 코드의 동작은 그대로, 구조는 깔끔하게!
public class Calculator {
// 향후 확장을 고려해 더 나은 구조로 개선
public int add(int a, int b) {
return calculate(a, b, "+");
}
// 공통 로직 처리 메서드 추가
private int calculate(int a, int b, String operation) {
switch (operation) {
case "+":
return a + b;
default:
throw new UnsupportedOperationException("Operation not supported");
}
}
}
✨ 1.3 왜 중요한가요?
- 🛡 안정성: 모든 기능에 대한 자동화된 테스트 보장.
- 📈 생산성 향상: 리팩토링 부담 감소.
“TDD를 하면 시간 낭비라고?” 아니요! 미래의 당신이 감사할 겁니다. 😎
🔥 1.4 장단점
- 장점
- 코드의 신뢰성 증가.
- 요구사항 변경에도 흔들리지 않는 튼튼한 구조.
- 버그 감소로 디버깅 시간 절약.
- 단점
- 초기 진입장벽 존재.
- 테스트 작성에 시간 투자 필요.
- 잘못된 테스트 설계 시 오히려 발목을 잡을 수 있음.
🙀 1.5 DDD(Domain Driven Design)와의 관계
- TDD는 테스트를 통해 코드를 어떻게 구현할지에 초점을 둔 개발 방법론
- DDD는 도메인 모델을 설계하여 무엇을 구현할지를 정의하는 철학
질문 | TDD | DDD |
---|---|---|
왜 사용하는가? | 기능의 동작을 보장하기 위해 | 비즈니스 요구사항을 시스템에 반영하기 위해 |
왜 시작하는가? | 코드를 테스트하면서 명확한 피드백을 얻기 위해 | 요구사항과 도메인 모델을 기반으로 설계하기 위해 |
왜 중요한가? | 코드의 안정성과 품질을 높이기 위해 | 복잡한 비즈니스 로직을 제대로 반영하기 위해 |
어떻게 다른가? | 테스트 작성 → 코드 작성 → 리팩토링 | 요구사항 분석 → 도메인 설계 → 코드 작성 |
2. 테스트 코드
💡2.1 테스트 코드란 무엇인가요?
- “내 코드, 정말 제대로 작동할까?”라는 질문에 대한 답을 미리 준비하는 개발자의 무기
- 프로그램의 기능이 의도대로 동작하는지 자동으로 확인
🛠 2.2 종류
2.2.1 단위 테스트 (Unit Test)
- 특징: 순수 비즈니스 로직(단일 메서드나 클래스)을 테스트
- 도구: @Mock, Mockito.
- 예시: UserService가 UserRepository를 올바르게 호출하는지 테스트
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Optional;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
@Mock
private UserRepository userRepository;
private UserService userService;
@Test
void should_ReturnUser_When_ValidIdProvided() {
// Mock 초기화
MockitoAnnotations.openMocks(this);
userService = new UserService(userRepository);
// Given
User mockUser = new User(1L, "Alice");
when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));
// When
User result = userService.getUserById(1L);
// Then
assertEquals("Alice", result.getName());
verify(userRepository, times(1)).findById(1L);
}
}
2.2.2 통합 테스트 (Integration Test)
- 특징: 여러 계층(Service, Repository 등)이 올바르게 연동되는지 확인
- 도구: @SpringBootTest
- 예시: 데이터 저장 및 조회가 잘 동작하는지 확인.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional // 테스트 후 DB 롤백
class UserIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void should_SaveAndRetrieveUser() {
// Given
User newUser = new User(null, "Bob");
// When
userService.saveUser(newUser);
User retrievedUser = userService.getUserById(newUser.getId());
// Then
assertNotNull(retrievedUser);
assertEquals("Bob", retrievedUser.getName());
}
}
2.2.3 End-to-End 테스트 (E2E Test)
- 특징: 시스템의 전체 흐름이 사용자 관점에서 잘 작동하는지 검증
- 도구: @SpringBootTest, TestRestTemplate
- 예시: 사용자 생성 후 조회 API가 올바르게 동작하는지 확인.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserE2ETest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
void should_CreateAndRetrieveUser() {
// Given
String baseUrl = "http://localhost:" + port + "/users";
UserRequest userRequest = new UserRequest("Charlie");
// Step 1: 사용자 생성
UserResponse createdUser = restTemplate.postForObject(baseUrl, userRequest, UserResponse.class);
assertNotNull(createdUser);
assertEquals("Charlie", createdUser.getName());
// Step 2: 생성된 사용자 조회
UserResponse retrievedUser = restTemplate.getForObject(baseUrl + "/" + createdUser.getId(), UserResponse.class);
assertNotNull(retrievedUser);
assertEquals("Charlie", retrievedUser.getName());
}
}
🚦 2.3 Spring Boot 테스트에서 사용하는 Mock의 종류
2.3.1 @Mock
@Mock
은 객체의 동작을 정의해서 의존성을 가짜로 만들어 줍니다.- 주로 단위 테스트에서 사용하며, Mock 라이브러리인 Mockito를 함께 사용합니다.
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
@Mock
private UserRepository userRepository; // 독립된 Mock 객체 생성
private UserService userService;
@Test
void should_ReturnUser_When_ValidIdProvided() {
// Mock 초기화
MockitoAnnotations.openMocks(this);
userService = new UserService(userRepository);
// Given
when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));
// When
User result = userService.getUserById(1L);
// Then
assertEquals("Alice", result.getName());
verify(userRepository, times(1)).findById(1L);
}
}
2.3.2 @MockBean
@MockBean
은Spring Context
에 등록된 빈(bean)을Mock
객체로 바꿔줍니다.- 덕분에 Controller, Service, Repository가 연동된 통합 테스트에서도 유용합니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.Optional;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@MockBean
private OrderRepository orderRepository; // Repository 빈을 Mock으로 대체
@Test
void should_ReturnOrder_When_OrderExists() {
// Given
Order mockOrder = new Order(1L, "ProductA", 3, "CREATED");
when(orderRepository.findById(1L)).thenReturn(Optional.of(mockOrder));
// When
Order result = orderService.getOrderById(1L);
// Then
assertNotNull(result);
assertEquals("ProductA", result.getProductName());
verify(orderRepository, times(1)).findById(1L);
}
}
2.3.3 @Spy
@Spy
는 실제 객체를 사용하면서 특정 메서드만 Mocking할 수 있습니다.- 로직의 일부만 제어하고 싶을 때 유용합니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Spy;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class OrderServiceTest {
@Spy
private OrderService orderService = new OrderService();
@Test
void should_MockSpecificMethod() {
// Given
doReturn(500).when(orderService).calculateTotalPrice(100, 5);
// When
int totalPrice = orderService.calculateTotalPrice(100, 5);
String status = orderService.getOrderStatus(1L); // 실제 메서드 실행
// Then
assertEquals(500, totalPrice); // Mocking된 결과 검증
assertEquals("COMPLETED", status); // 실제 동작 검증
// Verify
verify(orderService, times(1)).calculateTotalPrice(100, 5);
verify(orderService, times(1)).getOrderStatus(1L);
}
}
2.3.4 @SpyBean
@SpyBean
은Spring Context
에 등록된 실제 빈을Spy
객체로 감싸줍니다.- 즉, Spring 빈의 실제 동작을 수행하면서 일부 메서드만 Mocking 할 수 있습니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@SpyBean
private OrderService spyOrderService;
@Test
void should_UseSpyBeanToMockSpecificMethod() {
// Given
doReturn(1000).when(spyOrderService).calculateTotalPrice(200, 5);
// When
int mockedResult = spyOrderService.calculateTotalPrice(200, 5); // Mock 동작
String status = spyOrderService.getOrderStatus(1L); // 실제 동작 실행
// Then
assertEquals(1000, mockedResult); // Mocking 결과
assertEquals("COMPLETED", status); // 실제 메서드 결과
// Verify
verify(spyOrderService, times(1)).calculateTotalPrice(200, 5);
verify(spyOrderService, times(1)).getOrderStatus(1L);
}
}
어노테이션 | 대상 | 설명 | 사용 예 |
---|---|---|---|
@Mock | 독립된 가짜 객체 | Spring Context와 무관, 가짜 객체 생성 | 순수 단위 테스트 |
@MockBean | Spring 빈을 가짜로 대체 | Spring Context에서 Mock 객체로 교체 | Controller와 Service 연결 테스트 |
@Spy | 실제 객체 (부분만 Mocking) | 일부 메서드만 Mocking, 나머지는 실제 실행 | 실제 객체의 부분 테스트 |
@SpyBean | Spring 빈을 Spy 객체로 감싸기 | Spring 빈을 감싸서 일부만 Mocking | 실제 Spring 빈의 동작과 호출 검증 |
✨ 2.4 테스트코드를 구조적으로 깔끔하게 짜는 꿀팁
2.4.1 @Nested
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("OrderService 단위 테스트")
class OrderServiceTest {
private OrderService orderService = new OrderService();
@Nested
@DisplayName("주문 생성 테스트")
class CreateOrderTests {
@Test
@DisplayName("정상적으로 주문을 생성한다.")
void should_CreateOrder_Successfully() {
Order order = orderService.createOrder("ProductA", 3);
assertNotNull(order);
assertEquals("ProductA", order.getProductName());
}
}
@Nested
@DisplayName("주문 조회 테스트")
class GetOrderTests {
@Test
@DisplayName("존재하는 주문을 조회한다.")
void should_ReturnOrder_When_OrderExists() {
Order order = orderService.createOrder("ProductA", 3);
Order retrievedOrder = orderService.getOrderById(order.getId());
assertEquals(order, retrievedOrder);
}
}
}
2.4.2 @DisplayName
@DisplayName
은 테스트의 목적이나 동작을 설명하는 이름을 추가
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("OrderService 테스트")
class OrderServiceTest {
private OrderService orderService = new OrderService();
@Test
@DisplayName("주문 생성 시 상품 이름과 수량이 올바르게 설정된다.")
void should_CreateOrder_With_CorrectDetails() {
Order order = orderService.createOrder("ProductA", 3);
assertNotNull(order);
assertEquals("ProductA", order.getProductName());
assertEquals(3, order.getQuantity());
}
}
2.4.3 @BeforeEach / @AfterEach
@BeforeEach
: 각 테스트 실행 전 공통 작업을 수행@AfterEach
: 각 테스트 실행 후 정리 작업을 수행
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class OrderServiceTest {
private OrderService orderService;
@BeforeEach
void setUp() {
orderService = new OrderService();
}
@Test
@DisplayName("주문 생성 시 상품 이름이 설정된다.")
void should_CreateOrder_With_ProductName() {
Order order = orderService.createOrder("ProductA", 3);
assertEquals("ProductA", order.getProductName());
}
@AfterEach
void tearDown() {
System.out.println("테스트 정리 작업 실행");
}
}
2.4.4 @TestMethodOrder + @Order
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import static org.junit.jupiter.api.Assertions.*;
@TestMethodOrder(OrderAnnotation.class) // @Order 어노테이션 기준으로 순서 지정
@DisplayName("OrderService 순서 지정 테스트")
class OrderServiceTest {
private static OrderService orderService;
@BeforeAll
static void setUp() {
orderService = new OrderService();
}
@Test
@Order(1)
@DisplayName("1. 주문을 생성한다.")
void testCreateOrder() {
Order order = orderService.createOrder("ProductA", 3);
assertNotNull(order);
assertEquals("ProductA", order.getProductName());
}
@Test
@Order(2)
@DisplayName("2. 주문을 조회한다.")
void testGetOrder() {
Order order = orderService.getOrderById(1L); // 1번 ID의 주문 조회
assertNotNull(order);
assertEquals("ProductA", order.getProductName());
}
@Test
@Order(3)
@DisplayName("3. 주문 상태를 변경한다.")
void testUpdateOrderStatus() {
Order updatedOrder = orderService.updateOrderStatus(1L, "COMPLETED");
assertEquals("COMPLETED", updatedOrder.getStatus());
}
}
2.4.5 @TestMethodOrder(MethodName.class)
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.MethodName;
import static org.junit.jupiter.api.Assertions.*;
@TestMethodOrder(MethodName.class) // 메서드 이름 순서대로 실행
class OrderServiceTest {
@Test
void a_testCreateOrder() {
System.out.println("1. 주문 생성");
}
@Test
void b_testGetOrder() {
System.out.println("2. 주문 조회");
}
@Test
void c_testUpdateOrderStatus() {
System.out.println("3. 주문 상태 변경");
}
}
반응형
'Study > etc...' 카테고리의 다른 글
소프트웨어 아키텍처 (0) | 2025.01.03 |
---|---|
클린 코드 (1) | 2024.12.13 |