Skip to the content.

Query Adapter 통합 테스트 가이드

목적: Query Adapter의 Repository 위임 + Mapper 변환 전체 흐름 검증


1️⃣ 테스트 전략

왜 통합 테스트가 필요한가?

Query Adapter의 역할 (1:1 매핑):

@Component
public class OrderQueryAdapter implements OrderQueryPort {

    private final OrderQueryDslRepository queryDslRepository;  // 1:1 매핑
    private final OrderJpaEntityMapper mapper;                  // Mapper

    public OrderQueryAdapter(
        OrderQueryDslRepository queryDslRepository,
        OrderJpaEntityMapper mapper
    ) {
        this.queryDslRepository = queryDslRepository;
        this.mapper = mapper;
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return queryDslRepository.findById(id.getValue())
            .map(mapper::toDomain);
    }

    @Override
    public boolean existsById(OrderId id) {
        return queryDslRepository.existsById(id.getValue());
    }

    @Override
    public List<Order> findByCriteria(OrderSearchCriteria criteria) {
        return queryDslRepository.findByCriteria(criteria).stream()
            .map(mapper::toDomain)
            .toList();
    }

    @Override
    public long countByCriteria(OrderSearchCriteria criteria) {
        return queryDslRepository.countByCriteria(criteria);
    }
}

핵심 원칙:

검증해야 할 것:

  1. Repository 위임: QueryDslRepository 호출이 올바른가?
  2. Mapper 변환: Entity → Domain 변환이 올바른가?
  3. 전체 흐름: Repository → Mapper → Domain 전체가 통합되어 동작하는가?

단위 테스트와의 차이:

N+1 해결은 Application Layer에서

// ✅ Application Layer(UseCase)에서 여러 Adapter 조합으로 N+1 해결
@Component
public class OrderQueryUseCase {

    private final OrderQueryPort orderQueryPort;       // Order Adapter
    private final CustomerQueryPort customerQueryPort; // Customer Adapter

    @Transactional(readOnly = true)
    public List<OrderWithCustomerResponse> findOrdersWithCustomer(OrderSearchCriteria criteria) {
        // 1. Order 조회 (OrderQueryAdapter)
        List<Order> orders = orderQueryPort.findByCriteria(criteria);

        // 2. Customer ID 추출
        Set<Long> customerIds = orders.stream()
            .map(Order::getCustomerId)
            .collect(Collectors.toSet());

        // 3. Customer 일괄 조회 (CustomerQueryAdapter) - N+1 해결
        Map<Long, Customer> customerMap = customerQueryPort
            .findByIds(customerIds).stream()
            .collect(Collectors.toMap(Customer::getId, Function.identity()));

        // 4. Response 조합
        return orders.stream()
            .map(order -> new OrderWithCustomerResponse(
                order,
                customerMap.get(order.getCustomerId())
            ))
            .toList();
    }
}

N+1 해결 책임 분리:


2️⃣ 테스트 환경 설정

Gradle 의존성

dependencies {
    // 기존 의존성
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'

    // 테스트 의존성
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.testcontainers:testcontainers:1.19.0'
    testImplementation 'org.testcontainers:mysql:1.19.0'
    testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
}

테스트 클래스 구조

package com.company.adapter.out.persistence.adapter;

import com.company.adapter.out.persistence.entity.OrderJpaEntity;
import com.company.adapter.out.persistence.entity.OrderStatus;
import com.company.adapter.out.persistence.mapper.OrderJpaEntityMapper;
import com.company.adapter.out.persistence.repository.OrderRepository;
import com.company.adapter.out.persistence.repository.OrderQueryDslRepository;
import com.company.application.port.in.dto.query.OrderSearchCriteria;
import com.company.domain.order.Order;
import com.company.domain.order.OrderId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * OrderQueryAdapter 통합 테스트 (1:1 매핑)
 *
 * <p><strong>핵심 원칙:</strong></p>
 * <ul>
 *   <li>1:1 매핑: OrderQueryAdapter ↔ OrderQueryDslRepository</li>
 *   <li>필드 2개: QueryDslRepository + Mapper</li>
 *   <li>단순 위임: Repository → Mapper → Domain</li>
 * </ul>
 *
 * <p><strong>검증 내용:</strong></p>
 * <ul>
 *   <li>Repository 위임: QueryDslRepository 호출</li>
 *   <li>Mapper 변환: Entity → Domain 정확도</li>
 *   <li>전체 흐름: Repository → Mapper → Domain</li>
 * </ul>
 *
 * @author Development Team
 * @since 1.0.0
 */
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
@Import({
    OrderQueryAdapter.class,        // 테스트 대상 Adapter
    OrderQueryDslRepository.class,  // 1:1 매핑된 Repository
    OrderJpaEntityMapper.class      // Mapper
})
@TestPropertySource(properties = {
    "spring.flyway.enabled=true",
    "spring.jpa.hibernate.ddl-auto=validate",
    "spring.jpa.show-sql=true"
})
@DisplayName("Query Adapter 통합 테스트 (1:1 매핑)")
class OrderQueryAdapterIntegrationTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");

    @Autowired
    private OrderQueryAdapter adapter;

    @Autowired
    private OrderRepository orderRepository;  // 테스트 데이터 준비용 (JpaRepository)

    @BeforeEach
    void setUp() {
        orderRepository.deleteAll();
    }

    // ====================================
    // 1. findById 통합 테스트
    // ====================================

    @Test
    @DisplayName("findById: Entity → Domain 변환 정확도")
    void findById_EntityToDomain_CorrectConversion() {
        // Given
        OrderJpaEntity order = orderRepository.save(
            createOrder(1L, OrderStatus.PENDING, 10000L, LocalDate.of(2024, 1, 1))
        );

        // When
        Optional<Order> result = adapter.findById(new OrderId(order.getId()));

        // Then
        assertThat(result).isPresent();
        Order domain = result.get();

        // Entity 필드 검증
        assertThat(domain.getId().getValue()).isEqualTo(order.getId());
        assertThat(domain.getStatus()).isEqualTo(OrderStatus.PENDING);
        assertThat(domain.getTotalAmount()).isEqualTo(10000L);
        assertThat(domain.getOrderDate()).isEqualTo(LocalDate.of(2024, 1, 1));
        assertThat(domain.getCustomerId()).isEqualTo(1L);  // Long FK 검증
    }

    @Test
    @DisplayName("findById: 존재하지 않는 ID는 Empty 반환")
    void findById_NonExistingId_ReturnsEmpty() {
        // When
        Optional<Order> result = adapter.findById(new OrderId(999L));

        // Then
        assertThat(result).isEmpty();
    }

    // ====================================
    // 2. existsById 통합 테스트
    // ====================================

    @Test
    @DisplayName("existsById: 존재하는 ID는 true 반환")
    void existsById_ExistingId_ReturnsTrue() {
        // Given
        OrderJpaEntity order = orderRepository.save(
            createOrder(1L, OrderStatus.PENDING, 10000L)
        );

        // When
        boolean exists = adapter.existsById(new OrderId(order.getId()));

        // Then
        assertThat(exists).isTrue();
    }

    @Test
    @DisplayName("existsById: 존재하지 않는 ID는 false 반환")
    void existsById_NonExistingId_ReturnsFalse() {
        // When
        boolean exists = adapter.existsById(new OrderId(999L));

        // Then
        assertThat(exists).isFalse();
    }

    // ====================================
    // 3. findByCriteria 통합 테스트
    // ====================================

    @Test
    @DisplayName("findByCriteria: 목록 조회 + Domain 변환")
    void findByCriteria_ReturnsOrderDomainList() {
        // Given
        orderRepository.save(createOrder(1L, OrderStatus.PENDING, 10000L));
        orderRepository.save(createOrder(1L, OrderStatus.CONFIRMED, 20000L));
        orderRepository.save(createOrder(2L, OrderStatus.PENDING, 30000L));

        OrderSearchCriteria criteria = OrderSearchCriteria.builder().build();

        // When
        List<Order> results = adapter.findByCriteria(criteria);

        // Then
        assertThat(results).hasSize(3);
        assertThat(results).allMatch(order -> order.getId() != null);
        assertThat(results).allMatch(order -> order.getCustomerId() != null);  // Long FK 포함
    }

    @Test
    @DisplayName("findByCriteria: 동적 조건 + Domain 변환")
    void findByCriteria_WithDynamicConditions() {
        // Given
        orderRepository.save(createOrder(1L, OrderStatus.PENDING, 10000L, LocalDate.of(2024, 1, 1)));
        orderRepository.save(createOrder(1L, OrderStatus.CONFIRMED, 20000L, LocalDate.of(2024, 1, 15)));
        orderRepository.save(createOrder(2L, OrderStatus.PENDING, 30000L, LocalDate.of(2024, 1, 20)));

        OrderSearchCriteria criteria = OrderSearchCriteria.builder()
            .status(OrderStatus.PENDING)
            .startDate(LocalDate.of(2024, 1, 1))
            .endDate(LocalDate.of(2024, 1, 31))
            .build();

        // When
        List<Order> results = adapter.findByCriteria(criteria);

        // Then
        assertThat(results).hasSize(2);  // PENDING 주문만
        assertThat(results).allMatch(order -> order.getStatus() == OrderStatus.PENDING);
        assertThat(results).allMatch(order ->
            !order.getOrderDate().isBefore(LocalDate.of(2024, 1, 1)) &&
            !order.getOrderDate().isAfter(LocalDate.of(2024, 1, 31))
        );
    }

    @Test
    @DisplayName("findByCriteria: 페이징 + Domain 변환")
    void findByCriteria_WithPaging() {
        // Given
        for (int i = 0; i < 15; i++) {
            orderRepository.save(createOrder(1L, OrderStatus.PENDING, 10000L));
        }

        OrderSearchCriteria criteria = OrderSearchCriteria.builder()
            .page(0)
            .size(10)
            .build();

        // When
        List<Order> results = adapter.findByCriteria(criteria);

        // Then
        assertThat(results).hasSize(10);
    }

    // ====================================
    // 4. countByCriteria 통합 테스트
    // ====================================

    @Test
    @DisplayName("countByCriteria: 동적 조건으로 카운트")
    void countByCriteria_WithConditions_ReturnsCorrectCount() {
        // Given
        orderRepository.save(createOrder(1L, OrderStatus.PENDING, 10000L));
        orderRepository.save(createOrder(1L, OrderStatus.CONFIRMED, 20000L));
        orderRepository.save(createOrder(2L, OrderStatus.PENDING, 30000L));

        OrderSearchCriteria criteria = OrderSearchCriteria.builder()
            .status(OrderStatus.PENDING)
            .build();

        // When
        long count = adapter.countByCriteria(criteria);

        // Then
        assertThat(count).isEqualTo(2);
    }

    // ====================================
    // Test Fixture 메서드
    // ====================================

    private OrderJpaEntity createOrder(Long customerId, OrderStatus status, Long totalAmount) {
        return OrderJpaEntity.builder()
            .customerId(customerId)  // Long FK
            .status(status)
            .totalAmount(totalAmount)
            .orderDate(LocalDate.now())
            .build();
    }

    private OrderJpaEntity createOrder(Long customerId, OrderStatus status, Long totalAmount, LocalDate orderDate) {
        return OrderJpaEntity.builder()
            .customerId(customerId)  // Long FK
            .status(status)
            .totalAmount(totalAmount)
            .orderDate(orderDate)
            .build();
    }
}

3️⃣ 쿼리 검증 방법 (선택적)

방법 1: Hibernate Statistics

@Test
@DisplayName("쿼리 개수 검증 - Hibernate Statistics")
void verifyQueryCount_UsingHibernateStatistics() {
    // Given
    EntityManagerFactory emf = entityManager.getEntityManagerFactory();
    SessionFactory sessionFactory = emf.unwrap(SessionFactory.class);
    Statistics stats = sessionFactory.getStatistics();
    stats.setStatisticsEnabled(true);
    stats.clear();

    OrderSearchCriteria criteria = OrderSearchCriteria.builder().build();

    // When
    List<Order> results = adapter.findByCriteria(criteria);

    // Then
    assertThat(stats.getQueryExecutionCount()).isEqualTo(1);  // Order 조회 1개
}

방법 2: P6Spy

build.gradle:

testImplementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'

application-test.yml:

decorator:
  datasource:
    p6spy:
      enable-logging: true

방법 3: 수동 검증

application-test.yml:

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

테스트 실행 후 콘솔 출력 확인:


4️⃣ 실행 방법

Gradle 실행

# 전체 통합 테스트 실행
./gradlew test

# Query Adapter 통합 테스트만 실행
./gradlew test --tests "*QueryAdapterIntegrationTest"

# 특정 테스트 메서드만 실행
./gradlew test --tests "*QueryAdapterIntegrationTest.findByCriteria_WithCustomerInfo_ResolvedN1Problem"

IDE 실행


5️⃣ 테스트 작성 체크리스트

Query Adapter 통합 테스트 작성 시:

핵심 원칙 검증

findById 통합 테스트

existsById 통합 테스트

findByCriteria 통합 테스트

countByCriteria 통합 테스트

N+1 해결 (Application Layer 책임)


6️⃣ 참고 문서


작성자: Development Team 최종 수정일: 2025-11-13 버전: 1.0.0