
ProductTest: 상품 객체 스스로의 로직 검증 (할인, 가격 규칙)ProductServiceTest: Mock을 이용한 비즈니스 로직 검증ProductRepositoryTest: DB 저장 및 조회 검증ProductControllerTest: API 요청/응답 검증자동차 부품 공장에서 '엔진' 하나만 꺼내서 돌려보는 것입니다.
Mockito 같은 가짜 객체(Mock)를 적극 사용합니다.자동차를 다 조립하고 도로주행을 해보는 것입니다.

@SpringBootTest를 사용하여 src/test/java/com/example/tdd_study/product/ProductIntegrationTest.java 파일을 생성합니다.package com.example.tdd_study.product;
import com.example.tdd_study.product.dto.AddProductRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest // [핵심] 스프링 컨테이너를 실제로 띄웁니다.
@AutoConfigureMockMvc // API 요청을 보내기 위한 도구
@Transactional // 테스트 후 DB 롤백
class ProductIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void 상품_등록_통합_테스트() throws Exception {
// given
System.out.println(">>> [통합 테스트] 시작: 상품 등록 요청 준비");
String name = "통합 테스트 커피";
int price = 5000;
AddProductRequest request = new AddProductRequest(name, price);
String jsonBody = objectMapper.writeValueAsString(request);
// when
System.out.println(">>> [통합 테스트] API 호출 수행");
ResultActions response = mockMvc.perform(post("/products")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonBody));
// then
response.andExpect(status().isOk())
.andDo(print()); // 로그 출력
}
}저는 이 테스트를 실행했을 때 실패했습니다. 왜냐하면 지금이 밤 7시였기 때문이죠.

>>> [Controller] 상품 등록 요청 진입
...
>>> [Controller] 생성된 현재 시간(now): 2025-12-16T19:28:25.674834
...
>>> [Service] 수신된 시간: 2025-12-16T19:28:25.674834
>>> [Service-Fail] 영업 시간이 아님! (현재 시각: 19시)LocalDateTime.now()를 호출해서 현재 시간(밤 7시)을 Service에 넘겼고, 영업 시간(09~18) 체크 로직에 걸려 실패한 것입니다.Clock 객체를 빈으로 등록하고 주입받으면, 테스트에서 시간을 멈출 수 있습니다.Clock 빈을 등록합니다.package com.example.tdd_study;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.time.Clock;
@SpringBootApplication
public class TddStudyApplication {
public static void main(String[] args) {
SpringApplication.run(TddStudyApplication.class, args);
}
// [필수] 시간(Clock)를 스프링 빈으로 등록
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
}Clock을 주입받아 사용하도록 수정합니다.
확인을 위해 상세한 로그를 남겨두겠습니다.package com.example.tdd_study.product;
import com.example.tdd_study.product.dto.AddProductRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.time.Clock;
import java.time.LocalDateTime;
@RestController
public class ProductController {
private final ProductService productService;
private final Clock clock; // [추가] 시간를 주입받습니다.
public ProductController(ProductService productService, Clock clock) {
this.productService = productService;
this.clock = clock;
}
@PostMapping("/products")
public void register(@RequestBody @Valid AddProductRequest request) {
System.out.println(">>> [Controller] 상품 등록 요청 진입");
System.out.println(">>> [Controller] 요청 파라미터(Name): " + request.getName());
// [CCTV] 사용 중인 시간 확인
System.out.println(">>> [Controller] 사용 중인 Clock 클래스: " + clock.getClass().getName());
// [핵심] clock을 사용하여 시간을 만듭니다.
LocalDateTime now = LocalDateTime.now(clock);
System.out.println(">>> [Controller] 생성된 현재 시간(now): " + now);
productService.register(request.getName(), request.getPrice(), now);
}
}package com.example.tdd_study.product;
import com.example.tdd_study.notification.NotificationCenter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class ProductService {
private final ProductRepository productRepository;
private final NotificationCenter notificationCenter;
public ProductService(ProductRepository productRepository,
NotificationCenter notificationCenter) {
this.productRepository = productRepository;
this.notificationCenter = notificationCenter;
}
@Transactional
public void register(String name, int price, LocalDateTime now) {
Product product = new Product(name, price);
System.out.println(">>> [Service] register() 진입");
System.out.println(">>> [Service] 수신된 시간: " + now);
if (now.getHour() < 9 || now.getHour() >= 18) {
System.out.println(">>> [Service-Fail] 영업 시간이 아님! (현재 시각: " + now.getHour() + "시)");
throw new IllegalStateException("영업 시간이 아닙니다.");
}
System.out.println(">>> [Service-Pass] 시간 검증 통과!");
productRepository.save(product);
notificationCenter.send("새로운 상품이 등록되었습니다: " + name);
}
}@MockBean을 사용하여 실제 시간 대신 가짜 시간을 주입합니다.
여기서 주의할 점은 ZoneId.systemDefault()를 사용해야 안전합니다.package com.example.tdd_study.product;
import com.example.tdd_study.product.dto.AddProductRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ProductIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean // [핵심] 실제 Clock 대신 가짜(Mock) Clock을 주입합니다.
private Clock clock;
@Test
void 상품_등록_통합_테스트() throws Exception {
// given
System.out.println(">>> [Test] 통합 테스트 시작");
// [핵심] 타임존을 고려하여 '낮 12시'로 시간을 고정합니다.
ZonedDateTime fixedDateTime = ZonedDateTime.of(2025, 1, 1, 12, 0, 0, 0, ZoneId.systemDefault());
System.out.println(">>> [Test] Mock Clock 시간 설정: " + fixedDateTime);
given(clock.instant()).willReturn(fixedDateTime.toInstant());
given(clock.getZone()).willReturn(ZoneId.systemDefault());
String name = "통합 테스트 커피";
int price = 5000;
AddProductRequest request = new AddProductRequest(name, price);
String jsonBody = objectMapper.writeValueAsString(request);
// when
System.out.println(">>> [Test] API 호출 (POST /products)");
ResultActions response = mockMvc.perform(post("/products")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonBody));
// then
response.andExpect(status().isOk())
.andDo(print());
System.out.println(">>> [Test] 검증 완료");
}
}>>> [Controller] 사용 중인 Clock 클래스: org.springframework.boot.test.mock.mockito.MockitoPostProcessor$Spy... (Mock 객체 확인!)
>>> [Controller] 생성된 현재 시간(now): 2025-01-01T12:00 (12시 확인!)
...
>>> [Service-Pass] 시간 검증 통과!다음 시간에는실무에서의 TDD 적용 시 고려사항에 대해 이야기하며, 이 시리즈를 마무리할 준비를 하겠습니다.