@ExtendWith(MockitoExtension.class)
class OrderServiceMockitoTest {
@Mock OrderRepository repo;
@Mock PaymentGateway pg;
@InjectMocks OrderService service; // repo, pg 를 자동으로 주입
@Test
void 결제_실패시_FAILED_저장() {
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0)); // 넣은 그대로 돌려줌
when(pg.charge(any(), any())).thenThrow(new PaymentGateway.PaymentException("한도 초과"));
assertThrows(PaymentGateway.PaymentException.class,
() -> service.place("kim", new BigDecimal("50000")));
verify(repo).save(argThat(o -> "FAILED".equals(o.status()))); // 정확히 FAILED 로 저장됐는지
}
}
@TestConfiguration
class ClockTestConfig {
@Bean
Clock clock() { return Clock.fixed(Instant.parse("2026-09-10T05:00:00Z"), ZoneId.of("Asia/Seoul")); }
}운영 빈은 @Bean Clock clock() { return Clock.systemDefaultZone(); } 로 등록하고, 테스트에서는 @TestConfiguration 으로 고정 시계를 덮어씁니다.
verify(repo).save(argThat(...)) 는 "정확히 이 조건을 만족하는 인자로 한 번 불렸는가" 를 확인합니다. 호출 자체가 검증 대상일 때만 이렇게 쓰고, 결과값을 확인할 수 있으면 상태 검증을 우선합니다.
@MybatisTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // H2 대신 지정한 DataSource 그대로 사용
@Sql("classpath:schema.sql")
class OrderMapperTest {
@Autowired OrderMapper mapper;
@Test
void 상태_필터가_없으면_전체() {
assertEquals(3, mapper.findByCustomer("kim", null).size());
}
@Test
void 상태_필터를_주면_해당_상태만() {
// <if test="status != null"> 분기를 이 케이스가 검증한다
assertEquals(1, mapper.findByCustomer("kim", "PAID").size());
}
@Test
void 고객이_없으면_빈_리스트() {
assertTrue(mapper.findByCustomer("nobody", null).isEmpty());
}
}매퍼 XML 의 동적 SQL 분기(<if>, <choose>) 는 분기마다 최소 한 테스트를 둬야 실제로 그 SQL 이 실행됐는지 확인됩니다.
schema.sql 은 JdbcOrderRepository.DDL 과 같은 역할을 합니다. 스키마를 파일로 빼 두면 여러 매퍼 테스트 클래스가 같은 스키마를 재사용할 수 있습니다.
class OrderApiTest {
static HttpServer server;
static HttpClient client = HttpClient.newHttpClient();
@BeforeAll
static void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(0), 0); // 포트 0 = 임의 포트
server.createContext("/orders", new OrderHandler(service));
server.start();
}
@AfterAll
static void stop() { server.stop(0); }
@Test
void 존재하지_않는_주문은_404() throws Exception {
var req = HttpRequest.newBuilder(URI.create("http://localhost:" + server.getAddress().getPort() + "/orders/999")).build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
assertEquals(404, res.statusCode());
}
}Spring MockMvc 로는 서버를 안 띄우고도 같은 검증을 합니다.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService service;
@Test
void 없는_주문은_404() throws Exception {
mvc.perform(get("/orders/999")).andExpect(status().isNotFound());
}
}운영에서 "25만 5원 주문의 할인이 25001원으로 나왔다" 는 장애가 들어왔다고 합니다. 원 단위 절사가 안 된 버그입니다.
@Test
@DisplayName("BUG-1042: 할인은 원 단위로 절사돼야 한다")
void discount_truncatesToWon() {
// 이 테스트를 먼저 작성하면 수정 전에는 실패한다
assertEquals(new BigDecimal("25000"), OrderService.discountFor(new BigDecimal("250005")));
}버그 번호를 테스트 이름에 남기면, 나중에 "이 케이스는 왜 있지" 라는 질문에 바로 답이 됩니다. 실패하는 테스트를 먼저 커밋하고, 수정 커밋에서 통과시키는 순서가 원인과 해결을 분리해 보여 줍니다.
이렇게 만든 회귀 테스트는 삭제하지 않고 그대로 남깁니다. 같은 버그가 나중에 다시 생기면 배포 전에 바로 잡아냅니다.