Development/썬카(SunCar) - 개인 프로젝트

[썬카/백엔드] 서비스 계층 및 커스텀 리파지터리 테스트 코드 작성, 팩토리/빌더 클래스 설계와 영속성 컨텍스트, Hibernate 통계 관리(Statistics)

양선규 2025. 4. 18. 01:01
728x90
반응형

좋은 테스트 코드를 만들기 위해 작성한 수많은 클래스들

 

 

썬카의 기능이 조금씩 많아지니, 코드를 수정할 때마다 매번 Postman으로 api를 호출해보는 나를 발견할 수 있었다. 이 작업이 번거롭다고 느껴지는 순간, 테스트 코드를 도입할 때가 왔다고 느꼈다. 그래서 잠시 스프린트는 접어두고 테스트 코드를 도입한 후 작업에 들어가기로 했다.

 

처음 작성해 보는 테스트 코드였지만, 나는 말 그대로 테스트만 하는 코드인데 오래 걸리겠어? 라고 생각했다. 그러나 이렇게나 생각할 게 많고 복잡할 줄 몰랐다. 테스트의 종류도 여러 가지였고 각각 어떤 테스트인지, 어떤 상황에서 도입해야 하는지, 우선순위는 어떻게 되는지 전부 생각해야 했다. 테스트 코드 자체도 전부 처음 보는 방식, 메서드들이라 기존의 난 완전 백지 상태나 다름이 없었어서 완전히 하나하나 차근히 공부하면서 도입해야 했기에 시간이 많이 걸렸다. 심지어 지금까지 쌓인 모든 서비스 메서드에 대한 테스트 코드를 한 번에 작성하려니 더 오래 걸린 것도 있었다.

 

아래는 테스트 코드에 대해 공부하고 정리한 포스팅이다.

https://yskisking.tistory.com/319

 

스프링 부트 테스트 전략 - 단위 테스트와 통합 테스트

테스트 코드는 매우 중요하며, 개발자에게 있어 필수적인 핵심 역량이다. 서비스 규모가 작을 때야 코드를 수정한 후 api 몇 개 직접 호출하면 금방 테스트할 수 있겠지만, 규모가 커져서 api가 수

yskisking.tistory.com

 

0. 의존성 및 테스트 환경 설정

// build.gradle

dependencies {
	
    // 생략 ..

    // Test Code
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.mockito:mockito-core'
	testImplementation 'org.junit.jupiter:junit-jupiter-api'
	testImplementation 'com.h2database:h2'
	testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
    
    // 생략 ..
}

 

우선 테스트에 필요한 의존성들을 추가해 준다. 주요 의존성은 Junit5, Mockito, H2 DB이다.

 

// application-test.properties

spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;CASE_INSENSITIVE_IDENTIFIERS=TRUE;IGNORECASE=TRUE
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver

spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.defer-datasource-initialization=true

spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions=true

 

그리고 테스트 환경에 필요한 설정을 추가해 준다. 주로 H2와 데이터베이스에 관련된 설정들이다.

이 테스트 환경은 통합 테스트에서 사용되며, 클래스 레벨에 @ActiveProfile("test") 어노테이션을 달아줌으로써 활성화된다.

 

1. 단위 테스트 데이터 생성을 위한 팩토리 및 CarFacadeService 테스트 코드 작성

서비스 계층의 모든 메서드에 단위 테스트, 그리고 QueryDSL 커스텀 쿼리를 가진 리파지터리를 대상으로 한 통합 테스트를 작성하기로 했다. 그런데 변수였던 것은, 테스트 코드 자체를 작성하는 것 보다 테스트 데이터를 생성하는 팩토리와 빌더 클래스를 설계하고 작성하는 것이 더 오래 걸렸다는 것이다. 워낙 차량 관련 엔티티가 많다보니 테스트 클래스마다 일일이 테스트 객체를 만드는 것은 매우 엄청난 시간낭비 + 반복작업 이었기 때문에, 이건 그냥 처음부터 팩토리와 빌더 클래스를 만들어 두고 시작하는 게 정신건강에 좋겠다고 생각했다.

 

팩토리 클래스

단위 테스트에서 사용하기 위해, 영속화되지 않고 DB에 저장되지 않은 엔티티/객체를 만드는 클래스가 필요했다. 그리고 각 엔티티 서비스의 create() 메서드를 호출할 때 사용할 인자 DTO를 만드는 클래스도 필요했다. 따라서 UserFactory, CarFactory, CarDtoFactory를 만들었다. 

 

1) User 팩토리 클래스

// test/../util/factory/TestUserFactory

public class TestUserFactory {

    private static final String PLAIN_PASSWORD = "1234";
    private static final String ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ5c2s5NTI2IiwiaWF0IjoxNzQ0MTY4NTUzLCJleHAiOjE3NDQyNTQ5NTN9.Hc43KWQbolIXyD3sUnk6b683SsDfniYn8zWZygNWp60";

    public static User createUser() {
        return User.builder()
                .id(1L)
                .userId("testUser")
                .email("testUser@test.com")
                .username("테스트사용자")
                .passwordHash("$2a$10$.YFMGmqbuDQ3hpn8f0mcP.gDufd2liVOlxo.NNWArb9g7qW5Zef0i")
                .phoneNumber("01012345678")
                .role(UserRole.회원)
                .build();
    }

    /**
     * DB에 save 되기 전의 User 객체를 생성합니다.
     *
     * @return - id 필드 값이 null인 User 객체
     */
    public static User createUnSavedUser() {
        User user = createUser();

        return user.toBuilder()
                .id(null)
                .build();
    }

    public static String getPlainPassword() {
        return PLAIN_PASSWORD;
    }

    public static String getAccessToken() {
        return ACCESS_TOKEN;
    }
}

 

User 객체와 id가 빠진 User 객체를 생성한다.

만료된 액세스 토큰과 평문 패스워드를 상수로 정의하여 필요할 때 꺼내서 쓸 수 있게 하였다.

 

2) Car 관련 팩토리 클래스

// test/../util/factory/TestCarFactory

public class TestCarFactory {

    public static Model createModel() {
        return Model.builder()
                .id(1L)
                .brandName("테스트브랜드")
                .modelName("테스트모델")
                .isForeign(true)
                .build();
    }

    public static Car createCar() {
        return Car.builder()
                .id(1L)
                .model(createModel())
                .carName("테스트자동차명")
                .carNumber("123가1234")
                .displacement("2998")
                .fuelType("가솔린")
                .year(2025)
                .bodyShape("왜건")
                .modelType("자가용 승용")
                .firstInsuranceDate(LocalDate.of(2025, 04, 10))
                .identificationNumber("테스트차대번호")
                .build();
    }

    public static CarAccident createCarAccident() {
        return CarAccident.builder()
                .id(1L)
                .car(createCar())
                .accidentDate(LocalDate.of(2025, 04, 10))
                .accidentType("내차 피해")
                .processingType("내차 보험처리")
                .build();
    }

    public static CarAccidentRepair createCarAccidentRepair() {
        return CarAccidentRepair.builder()
                .id(1L)
                .carAccident(createCarAccident())
                .accidType("전손")
                .totalAmount("3000000")
                .partsCost(BigDecimal.valueOf(1000000))
                .laborCost(BigDecimal.valueOf(1000000))
                .paintingCost(BigDecimal.valueOf(1000000))
                .insuranceAmount(BigDecimal.valueOf(3000000))
                .build();
    }

    /**
     *
     * @param isPrimary - 메인/일반 이미지를 결정합니다.
     *                  true
     *                      id: 1L,
     *                      imageUrl: "테스트메인이미지URL",
     *                      isPrimary: true
     *                  false
     *                      id: 2L,
     *                      imageUrl: "테스트이미지URL",
     *                      isPrimary: false
     */
    public static CarListingImage createCarListingImageByIsPrimary(Boolean isPrimary) {

        String mainImageUrl = "테스트메인이미지URL";
        String additionalImageUrl = "테스트이미지URL";

        return CarListingImage.builder()
                .id(isPrimary ? 1L : 2L)
                .carListing(createCarListing())
                .imageUrl(isPrimary ? mainImageUrl : additionalImageUrl)
                .isPrimary(isPrimary)
                .build();
    }

    public static CarOption createCarOption() {
        return CarOption.builder()
                .id(1L)
                .car(createCar())
                .optionName("비행모드")
                .installStatus(OptionInstallStatus.INSTALLED)
                .build();
    }
    
    // 생략 ..
}

 

코드가 너무 길어서 많이 생략하였다. 생략된 곳도 비슷한 맥락이다.

 

이 코드를 보면 여러 개의 데이터가 필요할 경우 Car 객체가 중복 생성된다는 걸 알 수 있다. Car는 여러 엔티티에서 외래키로 참조하는 엔티티이기 때문이다. 그러나 static 메서드이고, 필요하다면 객체 하나만, 또는 여러개를 자유롭게 만들어서 쓸 수 있는 형태가 되어야 했기 때문에 객체의 중복 생성은 감안한 부분이다.

 

객체가 중복 생성되어 리소스 아주 조금 더 먹는 것 보다, 테스트가 지나치게 복잡해지지 않도록 하는 테스트 용이성이 더 중요하다고 생각했다. 이렇게 구현해 두면 복잡하게 생각할 것 없이 필요한 만큼만 메서드를 호출해 버리면 그만이다. 영속화되는 것도 아니고 DB에 저장되는 것도 아니기 때문에 외래키나 테이블 간 관계 문제가 발생할 일도 없다.

 

하지만 DB와 직접 상호작용하는 통합 테스트에서는 달랐다. 그래서 빌더 클래스들을 설계했고, 추후 설명하겠다.

 

3) Car 관련 Dto 팩토리 클래스

// test/../util/factory/TestCarDtoFactory

public class TestCarDtoFactory {

    public static ModelDto createModelDto() {
        Model model = TestCarFactory.createModel();

        return ModelDto.builder()
                .brandName(model.getBrandName())
                .modelName(model.getModelName())
                .isForeign(model.getIsForeign())
                .build();
    }

    public static CarDto createCarDto() {
        Car car = TestCarFactory.createCar();
        Model model = TestCarFactory.createModel();

        return CarDto.builder()
                .modelId(model.getId())
                .carName(car.getCarName())
                .carNumber(car.getCarNumber())
                .displacement(car.getDisplacement())
                .fuelType(car.getFuelType())
                .year(car.getYear())
                .month(car.getMonth())
                .bodyShape(car.getBodyShape())
                .modelType(car.getModelType())
                .firstInsuranceDate(car.getFirstInsuranceDate())
                .identificationNumber(car.getIdentificationNumber())
                .minPrice(car.getMinPrice())
                .maxPrice(car.getMaxPrice())
                .build();

    }
    
    // 생략..
}

 

CarDtoFactory이다. 이것도 CarFactory와 비슷하기 때문에 설명은 생략하겠다.

 

4) 테스트 대상 서비스 클래스

// main/../service/facade/CarFacadeDummyserviceImpl

/**
 * 차량 관련 Facade 서비스 구현체 입니다.
 * - 더미 데이터 입력용으로, 개발환경일 때만 활성화됩니다.
 */
@Service
@Profile("dev")
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CarFacadeDummyServiceImpl implements CarFacadeDummyService {

    private final CarDummyDataGenerator carDummyDataGenerator;
    private final CarMapper carMapper;

    private final UserRepository userRepository;
    private final CarListingRepository carListingRepository;

    /** Car 관련 서비스 */
    private final ModelService modelService;
    private final CarService carService;
    private final CarMileageService carMileageService;
    private final CarAccidentService carAccidentService;
    private final CarAccidentRepairService carAccidentRepairService;
    private final CarOwnershipChangeService carOwnershipChangeService;
    private final CarUsageService carUsageService;
    private final CarOptionService carOptionService;
    private final CarListingService carListingService;
    private final CarListingImageService carListingImageService;

    /**
     * 판매중인 차량 목록을 조회합니다.
     */
    @Override
    public List<CarListResponseDto> getCarList() {
        List<CarListResponseDto> carList = carListingService.getCarList();
        return carList;
    }

    /**
     * 판매 차량 상세정보를 조회합니다.
     * QueryDSL을 사용하여 데이터를 가져온 후, 서비스에서 매퍼를 통해 DTO로 변환됩니다.
     *
     * @param listingId 차량 판매등록 ID
     */
    @Override
    public CarDetailResponseDto getCarDetail(Long listingId) {

        /** 차량 상세정보 엔티티들 조회 */
        CarDetailFetchResult data = carListingRepository.getCarDetailById(listingId)
                .orElseThrow(() -> new NotFoundException(ErrorMessages.CAR_LISTING_NOT_FOUND));

        /** DTO로 변환 */
        CarDetailResponseDto carDetail = carMapper.toCarDetailResponseDto(data.carListing());

        /** 나머지 데이터 매핑 */
        processImages(data.images(), carDetail);
        carDetail.setAccidents(carMapper.toCarAccidentWithRepairsDtos(data.accidents(), data.repairsByAccidentId()));
        carDetail.setMileages(carMapper.toCarMileageDtos(data.mileages()));
        carDetail.setOptions(carMapper.toCarOptionDtos(data.options()));
        carDetail.setOwnershipChanges(carMapper.toCarOwnershipChangeDtos(data.ownershipChanges()));
        if (data.usage() != null) {
            carDetail.setUsage(carMapper.toCarUsageDto(data.usage()));
        }

        return carDetail;
    }

    /**
     * 차량을 판매등록합니다.
     * - 더미 데이터를 이용합니다.
     *
     * @param userId - 판매자 아이디
     */
    @Override
    @Transactional
    public RegisterCarResponseDto registerCar(RegisterCarDummyRequestDto dto, String userId) {

        /**
         * 각 엔티티마다 더미 데이터 생성 후 DB에 저장
         */

        /** Model */
        ModelDto modelDto = carDummyDataGenerator.generateModelDto();
        Model model = modelService.createModel(modelDto);

        /** Car */
        CarDto carDto = carDummyDataGenerator.generateCarDto(model.getId(), dto.getCarNumber());
        Car car = carService.createCar(carDto);

        /**
         * CarListing
         * - 차량 등록 요청자 id를 인자로 전달
         */
        User user = userRepository.findByUserId(userId)
                .orElseThrow(() -> new NotFoundException(ErrorMessages.USER_NOT_FOUND));
        Long userPrimaryId = user.getId();

        CarListingDto listingDto = carDummyDataGenerator.generateCarListingDto(car.getId(), userPrimaryId, dto.getPrice());
        CarListing listing = carListingService.createListing(listingDto);

        /** CarAccident */
        List<CarAccidentDto> accidentDtos = carDummyDataGenerator.generateCarAccidentDtos(car.getId());
        List<CarAccident> accidents = carAccidentService.createAccidents(accidentDtos);

        /**
         * CarAccidentRepair
         * - Accident.id를 추출해 인자로 전달
         */
        List<Long> accidentIds = new ArrayList<>();
        for (CarAccident accident : accidents) {
            accidentIds.add(accident.getId());
        }

        List<CarAccidentRepairDto> accidentRepairDtos = carDummyDataGenerator.generateCarAccidentRepairDtos(accidentIds);
        carAccidentRepairService.createAccidentRepairs(accidentRepairDtos);

        /** CarMileage */
        List<CarMileageDto> mileageDtos = carDummyDataGenerator.generateCarMileageDtos(car.getId());
        carMileageService.createMileages(mileageDtos);

        /** CarOwnershipChange */
        List<CarOwnershipChangeDto> ownershipChangeDtos = carDummyDataGenerator.generateCarOwnershipChangeDtos(car.getId());
        carOwnershipChangeService.createChanges(ownershipChangeDtos);

        /** CarUsage */
        CarUsageDto usageDto = carDummyDataGenerator.generateCarUsageDto(car.getId());
        carUsageService.createUsage(usageDto);

        /** CarOption */
        List<CarOptionDto> optionDtos = carDummyDataGenerator.generateCarOptionDtos(car.getId());
        carOptionService.createOptions(optionDtos);

        /** CarListingImage */
        CarListingImageDto mainImageDto = carDummyDataGenerator.generateCarListingImageDtoFromMainImage(listing.getId(), dto.getMainImage());
        carListingImageService.createMainImage(mainImageDto);

        List<CarListingImageDto> additionalImageDtos = carDummyDataGenerator.generateCarListingDtosFromAdditionalImages(listing.getId(), dto.getAdditionalImages());
        carListingImageService.createImages(additionalImageDtos);

        /**
         * 결과를 dto로 만든 후 리턴
         */
        RegisterCarResponseDto registerCar = carMapper.toRegisterCarResponseDto(listing, car, model);
        return registerCar;
    }

    /**
     * 이미지 데이터를 받아 메인/일반 이미지로 구분하여 dto에 담습니다.
     */
    private void processImages(List<CarListingImage> images, CarDetailResponseDto dto) {

        String mainImageUrl = null;
        List<String> additionalImageUrls = new ArrayList<>();

        for (CarListingImage image : images) {
            if (image.getIsPrimary()) {
                mainImageUrl = image.getImageUrl();
            }
            else {
                additionalImageUrls.add(image.getImageUrl());
            }
        }

        dto.setMainImageUrl(mainImageUrl);
        dto.setAdditionalImageUrls(additionalImageUrls);
    }
}

 

 

5) 서비스 계층 단위 테스트 코드

썬카 프로젝트의 핵심 로직을 담고 있는 CarFacadeService 메서드를 테스트한 코드이다. 매우 많은 테스트 데이터가 필요하다.

코드가 매우 길기 때문에 나눠서 설명한다.

// test/../service/facade/CarFacadeDummyServiceImplTest

@ExtendWith(MockitoExtension.class)
class CarFacadeDummyServiceImplTest {

    /** 실제 테스트 대상이 되는 서비스 객체, Mock객체가 여기에 주입됨 */
    @InjectMocks
    private CarFacadeDummyServiceImpl carFacadeDummyServiceImpl;

    /**
     * InjectMocks가 의존하는 외부 클래스들을 Mock으로 대체
     * 테스트 대상 서비스를 외부 의존성으로부터 격리하기 위함
     */
    @Mock
    private CarDummyDataGenerator carDummyDataGenerator;
    @Mock
    private CarMapper carMapper;
    @Mock
    private UserRepository userRepository;
    @Mock
    private CarListingRepository carListingRepository;
    @Mock
    private ModelService modelService;
    @Mock
    private CarService carService;
    @Mock
    private CarMileageService carMileageService;
    @Mock
    private CarAccidentService carAccidentService;
    @Mock
    private CarAccidentRepairService carAccidentRepairService;
    @Mock
    private CarOwnershipChangeService carOwnershipChangeService;
    @Mock
    private CarUsageService carUsageService;
    @Mock
    private CarOptionService carOptionService;
    @Mock
    private CarListingService carListingService;
    @Mock
    private CarListingImageService carListingImageService;

    /**
     * 테스트 데이터 선언
     */
    private User testUser;
    private Model testModel;
    private Car testCar;
    private CarListing testCarListing;
    private CarMileage testCarMileage;
    private CarUsage testCarUsage;
    private List<CarListResponseDto> testCarListResponseDtos;
    private CarDetailFetchResult testCarDetailFetchResult;
    private CarDetailResponseDto testCarDetailResponseDto;
    private RegisterCarDummyRequestDto testRegisterCarDummyRequestDto;
    private RegisterCarResponseDto testRegisterCarResponseDto;
    
    // 생략..

 

서비스 클래스에서 필요한 의존성들을 전부 @Mock 어노테이션으로 모킹한다. 그리고 테스트 시 필요한 데이터들을 필드에 미리 선언해 둔다.

 

    /**
     * 테스트 데이터 초기화
     */
    @BeforeEach
    void setUp() {

        testUser = TestUserFactory.createUser();
        testModel = TestCarFactory.createModel();
        testCar = TestCarFactory.createCar();
        testCarListing = TestCarFactory.createCarListing();
        testCarUsage = TestCarFactory.createCarUsage();
        testCarMileage = TestCarFactory.createCarMileage();

        /** testCarListResponseDtos 객체 생성 */
        String mainImageUrl = "테스트메인이미지URL";

        List<String> additionalImageUrls = new ArrayList<>();
        additionalImageUrls.add("테스트이미지URL");

        testCarListResponseDtos = new ArrayList<>();
        CarListResponseDto carListResponseDto = CarListResponseDto.builder()
                .carListingId(testCarListing.getId())
                .price(testCarListing.getPrice())
                .brandName(testModel.getBrandName())
                .carName(testCar.getCarName())
                .fuelType(testCar.getFuelType())
                .year(testCar.getYear())
                .month(testCar.getMonth())
                .distance(testCarMileage.getDistance())
                .mainImageUrl(mainImageUrl)
                .otherImageUrls(additionalImageUrls)
                .build();
        testCarListResponseDtos.add(carListResponseDto);

        /** testCarDetailFetchResult 객체 생성 */

        List<CarListingImage> images = new ArrayList<>();
        CarListingImage mainImage = TestCarFactory.createCarListingImageByIsPrimary(true);
        CarListingImage additionalImage = TestCarFactory.createCarListingImageByIsPrimary(false);
        images.add(mainImage);
        images.add(additionalImage);

        List<CarMileage> mileages = new ArrayList<>();
        mileages.add(testCarMileage);

        List<CarAccident> accidents = new ArrayList<>();
        Map<Long, List<CarAccidentRepair>> repairsByAccidentId = new HashMap<>();
        List<CarOption> options = new ArrayList<>();
        List<CarOwnershipChange> ownershipChanges = new ArrayList<>();

        testCarDetailFetchResult = new CarDetailFetchResult(
                testCarListing,
                images,
                accidents,
                repairsByAccidentId,
                mileages,
                options,
                ownershipChanges,
                testCarUsage
        );

        /** testCarDetailResponseDto 객체 생성 */
        CarUsageDto usageDto = TestCarDtoFactory.createCarUsageDto();

        List<CarMileageDto> mileageDtos = new ArrayList<>();
        mileageDtos.add(TestCarDtoFactory.createCarMileageDto());

        List<CarAccidentWithRepairsDto> accidentWithRepairDtos = new ArrayList<>();
        List<CarOptionDto> optionDtos = new ArrayList<>();
        List<CarOwnershipChangeDto> ownershipChangeDtos = new ArrayList<>();

        testCarDetailResponseDto = CarDetailResponseDto.builder()
                .id(testCarListing.getId())
                .price(testCarListing.getPrice())
                .description(testCarListing.getDescription())
                .status(testCarListing.getStatus())
                .sellerId(testUser.getId())
                .sellerUserName(testUser.getUsername())
                .carId(testCar.getId())
                .carName(testCar.getCarName())
                .carNumber(testCar.getCarNumber())
                .displacement(testCar.getDisplacement())
                .fuelType(testCar.getFuelType())
                .year(testCar.getYear())
                .month(testCar.getMonth())
                .bodyShape(testCar.getBodyShape())
                .modelType(testCar.getModelType())
                .firstInsuranceDate(testCar.getFirstInsuranceDate())
                .identificationNumber(testCar.getIdentificationNumber())
                .minPrice(testCar.getMinPrice())
                .maxPrice(testCar.getMaxPrice())
                .brandName(testModel.getBrandName())
                .modelName(testModel.getModelName())
                .isForeign(testModel.getIsForeign())
                .mainImageUrl(mainImageUrl)
                .additionalImageUrls(additionalImageUrls)
                .accidents(accidentWithRepairDtos)
                .mileages(mileageDtos)
                .options(optionDtos)
                .ownershipChanges(ownershipChangeDtos)
                .usage(usageDto)
                .build();

        /** testRegisterCarDummyRequestDto 객체 생성 */
        testRegisterCarDummyRequestDto = RegisterCarDummyRequestDto.builder()
                .mainImage(mainImageUrl)
                .additionalImages(additionalImageUrls)
                .carNumber(testCar.getCarNumber())
                .price(testCarListing.getPrice())
                .build();

        /** testRegisterCarResponseDto 객체 생성 */
        testRegisterCarResponseDto = RegisterCarResponseDto.builder()
                .listingId(testCarListing.getId())
                .price(testCarListing.getPrice())
                .brandName(testModel.getBrandName())
                .carName(testCar.getCarName())
                .carNumber(testCar.getCarNumber())
                .build();
    }

 

필드에 선언했던 테스트 데이터들을, 이곳 setup() 에서 초기화한다.

@BeforeEach가 달린 setup() 메서드는 매 테스트 메서드가 실행되기 전 실행된다. 즉, 각 테스트들은 다른 테스트에 영향받지 않고 초기 상태로 테스트가 가능하다.

 

잘 보면 Factory 메서드를 매우 많이 활용하고 있는 걸 볼 수 있다. 만약 팩토리 클래스를 구현하지 않았고, 저것들을 전부 여기 setup() 메서드에서 한줄한줄 입력하고 있다고 생각하면.. 코드가 얼마나 길어질지 상상도 가지 않는다. 더 무서운 건, 다른 서비스를 테스트할 때 그런 짓을 또 해야 한다는 것이다.

 

    @Test
    @DisplayName("판매중인 차량 목록 조회 테스트")
    void getCarList() {

        // given
        /** 위에서 만들어둔 testCarListResponseDtos를 리턴하게 함 */
        when(carListingService.getCarList()).thenReturn(testCarListResponseDtos);

        // when
        /** 테스트 대상 메서드 */
        List<CarListResponseDto> result = carFacadeDummyServiceImpl.getCarList();

        // then
        /**
         * 결과 array 크기 및 id값 명시적 검증 후 객체 비교 검증
         */
        assertThat(result).hasSize(testCarListResponseDtos.size());
        assertThat(result.get(0).getCarListingId()).isEqualTo(testCarListing.getId());
        assertThat(result.get(0))
                .usingRecursiveComparison()
                .isEqualTo(testCarListResponseDtos.get(0));
		
        /** 핵심 메서드가 의도대로 한 번만 호출되었는지 검증 */
        verify(carListingService).getCarList();
    }

 

판매 차량 목록 조회 테스트이다.

 

when() 을 이용해 리턴값을 모킹하고 테스트 대상 메서드를 호출하여, 의도한 결과를 뱉는지 검증한다.

assertThat과 verify의 순서를 고민했었는데, assertThat이 verify에 비해 더 핵심인 부분을 검증하므로 먼저 위치하게 했다. 의도된 동작을 수행하지 않는지 더 빠르게 검증하고 실패를 리턴할 수 있게 되니까.

 

    @Test
    @DisplayName("판매 차량 상세정보 조회 시 데이터가 존재하지 않을 경우 Not Found 반환하는지 테스트")
    void shouldThrowNotFoundWhenCarListingDoesNotExist() {

        // given
        Long nonExistentListingId = 99999L;
        when(carListingRepository.getCarDetailById(nonExistentListingId)).thenReturn(Optional.empty());

        // when
        NotFoundException exception = assertThrows(NotFoundException.class,
                () -> carFacadeDummyServiceImpl.getCarDetail(nonExistentListingId));

        // then
        assertThat(exception.getMessage()).isEqualTo(ErrorMessages.CAR_LISTING_NOT_FOUND);

        verify(carListingRepository).getCarDetailById(nonExistentListingId);
        verify(carMapper, never()).toCarDetailResponseDto(any());
    }

 

이 메서드는 예외 흐름을 검증하는 테스트이다. 차량 상세정보 조회 시 데이터가 존재하지 않을 경우 Not Found를 반환하는지 검증한다.

 

사실 99999L을 굳이 설정하지 않더라도 어차피 when으로 핵심 메서드가 빈 Optional을 반환하도록 했기 때문에 테스트엔 문제가 없다. 그러나 저렇게 명시적으로 설정하는 것이 테스트의 의도를 더 명확하게 드러내기 때문에 꼭 설정하는 것이 좋다.

 

    @Test
    @DisplayName("차량 판매등록 테스트 - 더미 데이터")
    void registerCar() {

        // given
        String userId = testUser.getUserId();

        /** 테스트에 필요한 기본 객체 생성 */
        CarUsage usage = TestCarFactory.createCarUsage();
        CarListingImage mainImage = TestCarFactory.createCarListingImageByIsPrimary(true);

        ModelDto modelDto = TestCarDtoFactory.createModelDto();
        CarDto carDto = TestCarDtoFactory.createCarDto();
        CarListingDto carListingDto = TestCarDtoFactory.createCarListingDto();
        CarUsageDto usageDto = TestCarDtoFactory.createCarUsageDto();
        CarListingImageDto mainImageDto = TestCarDtoFactory.createCarListingImageDtoByIsPrimary(true);

        List<CarAccident> accidents = new ArrayList<>();
        List<CarAccidentRepair> accidentRepairs = new ArrayList<>();
        List<CarMileage> mileages = new ArrayList<>();
        List<CarOwnershipChange> ownershipChanges = new ArrayList<>();
        List<CarOption> options = new ArrayList<>();
        List<CarListingImage> additionalImages = new ArrayList<>();

        List<CarAccidentDto> accidentDtos = new ArrayList<>();
        List<CarAccidentRepairDto> accidentRepairDtos = new ArrayList<>();
        List<CarMileageDto> mileageDtos = new ArrayList<>();
        List<CarOwnershipChangeDto> ownershipChangeDtos = new ArrayList<>();
        List<CarOptionDto> optionDtos = new ArrayList<>();
        List<CarListingImageDto> additionalImageDtos = new ArrayList<>();

        /** 메서드 모킹 */
        when(carDummyDataGenerator.generateModelDto()).thenReturn(modelDto);
        when(modelService.createModel(modelDto)).thenReturn(testModel);

        when(carDummyDataGenerator.generateCarDto(testModel.getId(), testCar.getCarNumber())).thenReturn(carDto);
        when(carService.createCar(carDto)).thenReturn(testCar);

        when(userRepository.findByUserId(userId)).thenReturn(Optional.of(testUser));

        when(carDummyDataGenerator.generateCarListingDto(testCar.getId(), testUser.getId(), testCarListing.getPrice())).thenReturn(carListingDto);
        when(carListingService.createListing(carListingDto)).thenReturn(testCarListing);

        when(carDummyDataGenerator.generateCarAccidentDtos(testCar.getId())).thenReturn(accidentDtos);
        when(carAccidentService.createAccidents(accidentDtos)).thenReturn(accidents);

        when(carDummyDataGenerator.generateCarAccidentRepairDtos(anyList())).thenReturn(accidentRepairDtos);
        when(carAccidentRepairService.createAccidentRepairs(accidentRepairDtos)).thenReturn(accidentRepairs);

        when(carDummyDataGenerator.generateCarMileageDtos(testCar.getId())).thenReturn(mileageDtos);
        when(carMileageService.createMileages(mileageDtos)).thenReturn(mileages);

        when(carDummyDataGenerator.generateCarOwnershipChangeDtos(testCar.getId())).thenReturn(ownershipChangeDtos);
        when(carOwnershipChangeService.createChanges(ownershipChangeDtos)).thenReturn(ownershipChanges);

        when(carDummyDataGenerator.generateCarUsageDto(testCar.getId())).thenReturn(usageDto);
        when(carUsageService.createUsage(usageDto)).thenReturn(usage);

        when(carDummyDataGenerator.generateCarOptionDtos(testCar.getId())).thenReturn(optionDtos);
        when(carOptionService.createOptions(optionDtos)).thenReturn(options);

        when(carDummyDataGenerator.generateCarListingImageDtoFromMainImage(eq(testCarListing.getId()), any(String.class))).thenReturn(mainImageDto);
        when(carListingImageService.createMainImage(mainImageDto)).thenReturn(mainImage);

        when(carDummyDataGenerator.generateCarListingDtosFromAdditionalImages(eq(testCarListing.getId()), anyList())).thenReturn(additionalImageDtos);
        when(carListingImageService.createImages(additionalImageDtos)).thenReturn(additionalImages);

        when(carMapper.toRegisterCarResponseDto(testCarListing, testCar, testModel)).thenReturn(testRegisterCarResponseDto);

        // when
        RegisterCarResponseDto result = carFacadeDummyServiceImpl.registerCar(testRegisterCarDummyRequestDto, userId);

        // then
        assertThat(result.getListingId()).isEqualTo(testCarListing.getId());
        assertThat(result.getPrice()).isEqualTo(testCarListing.getPrice());
        assertThat(result)
                .usingRecursiveComparison()
                .isEqualTo(testRegisterCarResponseDto);

        verify(userRepository).findByUserId(userId);
        verify(carMapper).toRegisterCarResponseDto(any(CarListing.class), any(Car.class), any(Model.class));
    }

 

차량 판매등록 메서드는 매우 많은 엔티티를 한 번에 DB에 저장하기 때문에 테스트 코드도 함께 매우 길어진다.

만약 Factory를 사용하지 않았다면 아마 2배는 더 길었을 것이다.

 

사실 테스트에 필요한 핵심 인자가 아니라면, 어차피 핵심 메서드는 when으로 리턴값이 모킹되기 때문에 굳이 객체를 만들거나 필드값을 할당하지 않아도 된다. 그러나 null같은 정상적이지 않은 값을 인자로 넣을 경우 NullPointerException이 발생하거나, 의도치 않은 오류가 발생할 가능성이 생긴다. 또한 실제 비즈니스 로직에서도 null을 명시적으로 처리하거나 하지 않는 이상 null이 의도된 인자가 아닐 것이기 때문에 테스트의 명확성을 위해 null은 웬만하면 넣지 않는 게 좋다. 또한 ArrayList는 null이 아닌 빈ArrayList객체라도 넣어두는 것이 좋다. 나는 null을 넣지 않기 위해 Factory 메서드로 단일 객체를 전부 생성했고, 컬렉션은 빈 ArrayList를 넣어 두었다.

 

또한 when, verify 같은 모킹 관련 부분에서 any(), anyLong(), any(String.class) 등 자료형만 일치하면 되게 하는 매처를 사용할 수 있는데, 굳이굳이 데이터를 만들어서 저기에 명확한 인자를 넣어줄 필요는 없지만, 인자로 넣을 수 있는 데이터가 존재한다면 넣어주는 게 테스트의 명확성에 더 도움이 된다.

 

 

2. 통합 테스트를 위한 빌더 클래스 설계와 Custom Repository 통합 테스트 코드

통합 테스트는 단위 테스트보다 더 복잡했다. 다른 컴포넌트들과 상호작용 해야 했기 때문에 명시적으로 Config 클래스나 의존성을 넣어주는 작업도 필요했고, 위에서 만든 팩토리 메서드를 활용하자니 엔티티 영속화 및 테이블 간 관계 문제가 발생했다.

 

빌더 클래스

따라서 영속화와 관계 문제를 해결한 빌더 클래스가 필요했고, 클래스 간 책임 분리 및 확장성을 위하여 구체적인 설계를 할 필요가 있었다. 내가 선택한 방법은 빌더 간 공통적으로 사용할 BaseBuilder를 추상 클래스로 정의하고, 통합 빌더 IntegrationBuilder를 구현하여 모든 빌더 기능을 조합해 쓸 수 있도록, 그리고 추후 다른 엔티티에 대한 빌더가 생길 경우 쉽게 확장이 가능하도록 설계하는 것이었다. 또한 빌더 클래스가 팩토리 클래스 메서드를 활용하게 함으로써 각 클래스가 계층적으로 연결될 수 있게 하였다.

 

이렇게 설계하니 팩토리 클래스와 더불어, 결과적으로 엔티티에 대한 모든 객체와 DTO를 한개 또는 여러개 자유롭게 만들 수 있고, 엔티티의 영속화 및 DB저장도 한 개든 여러개든 자유롭게 할 수 있고, 엔티티 간 관계 문제도 깔끔히 해결되었다. 또한, 클래스 간 책임 분리를 명확히 해 놓았기 때문에 확장이나 수정도 매우 쉽게 할 수 있게 되었다. 빌더 클래스는 팩토리 메서드를 활용하기 때문에, 빌더/팩토리 간 테스트 데이터가 불일치하는 문제도 방지할 수 있었다.

 

1) BaseBuilder 추상 클래스

모든 빌더가 상속받아야 할 추상 클래스이다. 이곳에는 빌더들이 공통적으로 사용하는 메서드를 구현해 두었다.

// test/../util/builder/TestBaseBuilder

public abstract class TestBaseBuilder {

    protected final EntityManager em;

    protected TestBaseBuilder(EntityManager em) {
        this.em = em;
    }

    public EntityManager getEntityManager() {
        return em;
    }

    /**
     * 인자로 받은 엔티티를 영속성 컨텍스트에 등록한 후, DB와 동기화합니다.
     * persist()와 flush()를 동시에 처리합니다.
     */
    protected <T> T persistAndFlush(T entity) {
        em.persist(entity);
        em.flush();
        return entity;
    }
}

 

BaseBuilder를 상속받는 클래스는 EntityManager와 생성자를 사용할 수 있어야 하기 때문에 protected로 정의했다.

persistAndFlush 메서드는 반복되는 persist() flush() 코드를 줄이기 위해 만든 메서드이다. 마찬가지로 하위 클래스에서 사용할 수 있어야 하기 때문에 protected로 정의했다.

 

2) User 엔티티 빌더 클래스

// test/../util/builder/TestUserBuilder

/**
 * 테스트 코드에서 사용할 User 관련 엔티티 영속화를 도와주는 빌더 클래스 입니다.
 */
public class TestUserBuilder extends TestBaseBuilder {

    public TestUserBuilder(EntityManager em) {
        super(em);
    }

    public User createAndPersistAndFlushUser() {
        User user = TestUserFactory.createUnSavedUser();
        persistAndFlush(user);
        return user;
    }
}

 

아까 팩토리 클래스에서는 단순히 객체 생성만 이루어졌지만, 빌더 클래스에서는 다르다.

우선 Factory 메서드를 호출해 id값이 비어있는 엔티티를 생성한다. 그리고 em.persist() 및 em.flush() 를 실행하여 영속화된 엔티티를 DB에 동기화한다. 즉 INSERT 한다.

 

이렇게 하면 엔티티에 id가 할당되고, 관계가 있는 엔티티끼리는 실제로 관계가 형성된다.

 

3) 차량 관련 엔티티 빌더 클래스

// test/../util/builder/TestCarBuilder

/**
 * 테스트 코드에서 사용할 Car 관련 엔티티 영속화를 도와주는 빌더 클래스 입니다.
 * UserBuilder를 포함합니다.
 */
public class TestCarBuilder extends TestBaseBuilder {

    private final TestUserBuilder testUserBuilder;

    public TestCarBuilder(EntityManager em) {
        super(em);
        this.testUserBuilder = new TestUserBuilder(em);
    }

    /** 통합 빌더에서 사용되는 생성자 */
    public TestCarBuilder(EntityManager em, TestUserBuilder testUserBuilder) {
        super(em);
        this.testUserBuilder = testUserBuilder;
    }

    public Model createAndPersistAndFlushModel() {
        Model model = TestCarFactory.createModel().toBuilder().id(null).build();
        persistAndFlush(model);
        return model;
    }

    public Car createAndPersistAndFlushCar() {
        Model model = createAndPersistAndFlushModel();
        return createAndPersistAndFlushCar(model);
    }

    public Car createAndPersistAndFlushCar(Model model) {
        Car car = TestCarFactory.createCar().toBuilder()
                .id(null)
                .model(model)
                .build();
        persistAndFlush(car);
        return car;
    }

    // 생략 ..
}

 

UserBuilder와 마찬가지로 팩토리 메서드를 호출해 객체를 만든 후 persist(), flush() 를 통해 DB에 저장한다.

 

특이한 점은 생성자가 2개인데, 밑에 있는 생성자는 통합 빌더에서 사용하는 생성자이다. 아래에서 볼 통합 빌더는 모든 빌더를 전부 생성해서 필드에 등록하는데, CarBuilder의 생성자는 UserBuilder 객체를 생성하므로 통합 빌더에서는 UserBuilder 객체를 2개 생성하게 된다. 그런 중복을 없애기 위해 UserBuider를 주입받는 생성자를 하나 더 만들어 둔 것이다.

 

빌더 메서드들은 오버로딩 기능을 활용하여 외래키로 참조하는 엔티티를 인자로 넣을 수 있게 만들었다. 넣지 않는다면 메서드 호출 시점에 참조하는 엔티티를 직접 만들어 객체를 생성하게 된다. 이렇게 빌더 클래스들은 엔티티 생성 순서만 잘 지킨다면, 팩토리 클래스가 가지고 있는 외래키 및 엔티티 간 관계 문제를 시원하게 해결할 수 있다.

 

그런데, 이렇게 하나씩만 필요한 게 아니라 한 번에 여러 객체가 필요하다면? 통합 빌더를 이용하면 된다.

 

4) 통합 빌더 클래스

// test/../util/builder/TestIntegrationBuilder

/**
 * 테스트 코드에서 사용할 엔티티 영속화를 도와주는 통합 빌더 클래스 입니다.
 * 모든 빌더 클래스를 포함합니다.
 */
@Component
public class TestIntegrationBuilder extends TestBaseBuilder {

    private final TestUserBuilder testUserBuilder;
    private final TestCarBuilder testCarBuilder;

    public TestIntegrationBuilder(EntityManager em) {
        super(em);
        this.testUserBuilder = new TestUserBuilder(em);
        this.testCarBuilder = new TestCarBuilder(em, testUserBuilder);
    }

    /**
     * 판매 차량(CarListing)과 관련된 모든 테스트 엔티티를 DB에 등록하고 리턴합니다.
     */
    public CarAndUserTestEntities createAndPersistAndFlushCarAndUserAllTestEntities() {
        User user = testUserBuilder.createAndPersistAndFlushUser();
        Model model = testCarBuilder.createAndPersistAndFlushModel();
        Car car = testCarBuilder.createAndPersistAndFlushCar(model);
        CarListing carListing = testCarBuilder.createAndPersistAndFlushCarListing(car, user);
        CarAccident accident = testCarBuilder.createAndPersistAndFlushCarAccident(car);
        CarAccidentRepair repair = testCarBuilder.createAndPersistAndFlushCarAccidentRepair(accident);
        CarListingImage mainImage = testCarBuilder.createAndPersistAndFlushCarListingImageByIsPrimary(true, carListing);
        CarListingImage additionalImage = testCarBuilder.createAndPersistAndFlushCarListingImageByIsPrimary(false, carListing);
        CarMileage mileage = testCarBuilder.createAndPersistAndFlushCarMileage(car);
        CarOption option = testCarBuilder.createAndPersistAndFlushCarOption(car);
        CarOwnershipChange ownershipChange = testCarBuilder.createAndPersistAndFlushCarOwnershipChange(car);
        CarUsage usage = testCarBuilder.createAndPersistAndFlushCarUsage(car);

        em.clear();

        return new CarAndUserTestEntities(
                user,
                model,
                car,
                carListing,
                accident,
                repair,
                mainImage,
                additionalImage,
                mileage,
                option,
                ownershipChange,
                usage
        );
    }

    /** 빌더 접근자 메서드들 */
    public TestUserBuilder getTestUserBuilder() {
        return testUserBuilder;
    }

    public TestCarBuilder getTestCarBuilder() {
        return testCarBuilder;
    }
}

 

통합 빌더 클래스는 모든 빌더를 포함한다. 지금은 Car, User 빌더뿐이니 2개밖에 없지만, 필드를 추가하고 생성자에 한줄 추가하기만 하면 다른 빌더를 언제든 추가할 수 있도록 확장성을 생각하여 구현했다.

 

통합 빌더 클래스는 단일 빌더가 아닌 다수의 빌더를 거치는 메서드를 제공한다. 지금은 CarListing, 즉 판매 차량과 관련된 모든 엔티티를 한 번에 제공하는 메서드만 구현해 둔 상태이다. 저 메서드를 호출하면 테스트에 필요한 모든 영속화된 엔티티를 한 번에 얻을 수 있다. 리턴할 때 생성하는 CarAndUserTestEntities는 영속화된 엔티티 데이터를 담기 위한 Dto이다.

 

또한 getter로 빌더 접근자 메서드를 만듦으로써, 통합 빌더를 import하면 언제는 하위 빌더의 메서드에도 접근할 수 있도록 구현했다. 모든 빌더는 Entity Manager를 공유하기 때문에 하나의 빌더인 것 처럼 편하게 사용할 수 있다.

 

이렇게 팩토리 -> 빌더 -> 통합 빌더 계층 형태가 완성되었다. 이제 이걸 가지고 자유롭게 테스트 코드를 작성하기만 하면 된다.

 

5) 테스트 환경 설정용 Config 클래스

@DataJpaTest 어노테이션이 달린 리파지터리 통합 테스트 코드는, 기본적으로 JPA에 관련된 Bean과 @Repository가 달린 클래스만 자동으로 구성하기 때문에 나머지 의존성들은 수동으로 추가 해줘야 한다. 개발 환경에서 쓰이는 Config 클래스 보다는 테스트용 Config 클래스를 만드는 것이 테스트를 격리된 환경에서 수행하기 좋기 때문에 따로 Config 클래스를 만들어 주입해야 한다.

// test/../config/TestJpaConfig

/**
 * 테스트 환경 전용 JPA 설정 클래스 입니다.
 */
@TestConfiguration
@EnableJpaAuditing
public class TestJpaConfig {

    /** 쿼리 통계 활성화 */
    @Bean
    public JpaProperties jpaProperties() {
        JpaProperties properties = new JpaProperties();
        properties.getProperties().put("hibernate.generate_statistics", "true");
        return properties;
    }
}

 

jpaProperties() 메서드는 Hibernate가 쿼리에 대한 통계를 수집할 수 있도록 활성화하는 설정이다.

 

이 설정이 활성화되어 있으면, 애플리케이션이 실행되는 동안 Hibernate에 의해 실행되는 모든 쿼리에 대한 통계를 수집하게 된다.

이것은 통합 테스트 코드에서 쿼리가 개발자가 의도한 횟수만큼만 실행되었는지를 검증하는 데 사용되거나, 성능 테스트 등에서도 사용될 수 있다.

 

// test/../config/TestQuerydslConfig

/**
 * 테스트 환경 전용 QueryDsl 설정 클래스 입니다.
 */
@TestConfiguration
public class TestQuerydslConfig {

    /** 영속성 컨텍스트를 EntityManager로 관리 */
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

커스텀 리파지터리에서 매우 중요한 QueryDsl도 외부 의존성이므로, 이렇게 직접 Config 클래스를 만들어 줘야 한다.

영속성 컨텍스트 관리를 위한 EntityManager를 선언하고 JPAQueryFactory를 빈으로 등록해 둔다. 

 

6) 테스트 쿼리용 유틸 클래스

쿼리를 다루는 테스트(통합 테스트 등)에서 사용될 유틸 클래스이다. 자주 사용되는 기능들일 것 같아 미리 유틸 클래스로 분리해 놓았다.

// test/../util/TestQueryUtil

/**
 * 쿼리 테스트 시 필요한 유틸 메서드를 담는 클래스 입니다.
 */
public class TestQueryUtils {

    /**
     * Hibernate 쿼리 실행 횟수를 반환합니다.
     *
     * @param em 엔티티 매니저
     * @return 실행된 쿼리 횟수
     */
    public static int getQueryCount(EntityManager em) {
        SessionFactory sessionFactory = em.unwrap(Session.class).getSessionFactory();
        return (int) sessionFactory.getStatistics().getQueryExecutionCount();
    }

    /**
     * 엔티티 매니저 캐시를 비우고 Hibernate 쿼리 통계를 초기화합니다.
     *
     * @param em 엔티티 매니저
     */
    public static void clearCacheAndResetStatistics(EntityManager em) {
        em.clear();
        SessionFactory sessionFactory = em.unwrap(Session.class).getSessionFactory();
        sessionFactory.getStatistics().clear();
    }

    /**
     * LocalDateTime을 특정 정밀도까지만 비교하는 Comparator를 반환합니다.
     * 기본값으로 초(SECONDS) 단위까지만 비교합니다.
     *
     * @return LocalDateTime 비교용 Comparator
     */
    public static Comparator<LocalDateTime> getDateTimeComparator() {
        return getDateTimeComparator(ChronoUnit.SECONDS);
    }

    /**
     * LocalDatetime을 지정된 정밀도까지만 비교하는 Comparator를 반환합니다.
     *
     * @param precision 비교할 정밀도 (예: ChronoUnit.SECONDS, ChronoUnit.MILLIS)
     * @return LocalDateTime 비교용 Comparator
     */
    public static Comparator<LocalDateTime> getDateTimeComparator(ChronoUnit precision) {
        return (dt1, dt2) -> {
            if (dt1 == null && dt2 == null) { return 0; }
            if (dt1 == null) { return -1; }
            if (dt2 == null) { return 1; }
            return dt1.truncatedTo(precision).compareTo(dt2.truncatedTo(precision));
        };
    }
}

 

위 TestJpaConfig에서 Hibernate 쿼리 통계를 활성화해 놓았었다. 그것을 여기 유틸 클래스에서 활용한다.

 

getQueryCount() 는 말 그대로 현재까지 Hibernate에 의한 쿼리가 몇 번 실행되었느냐를 리턴하는 메서드다. 통계가 시작되고 끝나는 기준은, 애플리케이션이 실행되는 시점부터 종료되는 시점까지이다. 단, 테스트 코드에서는 테스트의 시작과 끝을 기준으로 한다.

 

clearCacheAndResetStatistics() 메서드는 엔티티 매니저 캐시를 비우고 Hibernate 쿼리 통계를 초기화하는 메서드다. 이걸 통합 테스트 @BeforeEach가 달린 setup() 메서드 마지막 라인에 추가할 것이다.

 

em.clear() 는 엔티티 매니저 캐시를 비우는 메서드인데, 영속성 컨텍스트를 비우는 것이다. 비운 후 엔티티를 조회하면 DB에서 새로 가져오게 된다. 그 아래 sessionFactory.getStatistics().clear()는 Hibernate 쿼리 통계를 초기화한다. 영속성 컨텍스트나 DB와 직접적인 관련은 없으며, 그냥 지금까지 쌓인 쿼리 통계를 명시적으로 초기화하는 메서드다.

 

7) 테스트 대상 커스텀 리파지터리 코드

// main/repository/car/CarListingRepositoryCustomImpl

/**
 * CarListing Custom Repository 구현체입니다.
 */
@Repository
public class CarListingRepositoryCustomImpl extends Querydsl4RepositorySupport implements CarListingRepositoryCustom {

    public CarListingRepositoryCustomImpl(JPAQueryFactory queryFactory) {
        super(queryFactory, CarListing.class);
    }

    /**
     * 모든 판매중인 자동차 정보를 찾습니다.
     */
    @Override
    public List<CarListResponseDto> getCarList() {

        /** 사용할 Q타입 객체 선언 */
        QCarListing carListing = QCarListing.carListing;
        QCar car = QCar.car;
        QModel model = QModel.model;
        QCarMileage carMileage = QCarMileage.carMileage;
        QCarListingImage carListingImage = QCarListingImage.carListingImage;

        /** 쿼리 제작 */
        List<Tuple> results = getQueryFactory()

            /** SELECT */
            .select(
                    carListing.id,
                    carListing.price,
                    car.carName,
                    car.fuelType,
                    car.year,
                    car.month,
                    model.brandName,
                    carMileage.distance,
                    carListingImage.imageUrl,
                    carListingImage.isPrimary
            )

            /** FROM */
            .from(carListing)

            /**
             * LEFT JOIN을 사용하면, 해당 테이블 데이터가 없더라도 결과에 포함시킬 수 있다.
             *
             * 해당 데이터가 없어도 포함시켜야 한다면, LEFT JOIN을 사용
             * 해당 데이터가 없으면 제외해야 하거나/무조건 있는 상황이라면 INNER JOIN 사용
             * 아래는 무조건 있는 상황이기 때문에 INNER JOIN
             */
            /** INNER JOIN */
            .join(car).on(carListing.car.id.eq(car.id))
            .join(model).on(car.model.id.eq(model.id))
            .join(carMileage).on(car.id.eq(carMileage.car.id))
            .join(carListingImage).on(carListing.id.eq(carListingImage.carListing.id))

            /** 가장 최근 주행거리 기록을 선택 */
            .where(carMileage.id.in(
                    JPAExpressions
                            .select(carMileage.id.max())
                            .from(carMileage)
                            .groupBy(carMileage.car.id)
            ))
            .fetch();

        /** carListingId 기준으로 그룹화, DTO로 변환 */
        Map<Long, CarListResponseDto> dtoMap = new HashMap<>();

        for (Tuple tuple : results) {

            /** 쿼리결과에서 listing.id, imageUrl과 대표이미지 여부 가져오기 */
            Long carListingId = tuple.get(0, Long.class);
            String imageUrl = tuple.get(8, String.class);
            Boolean isPrimary = tuple.get(9, Boolean.class);

            /** 해당 carListingId의 DTO가 없다면 새로 생성 */
            if (!dtoMap.containsKey(carListingId)) {
                CarListResponseDto dto = CarListResponseDto.builder()
                        .carListingId(carListingId)
                        .price(tuple.get(1, BigDecimal.class))
                        .carName(tuple.get(2, String.class))
                        .fuelType(tuple.get(3, String.class))
                        .year(tuple.get(4, Integer.class))
                        .month(tuple.get(5, Integer.class))
                        .brandName(tuple.get(6, String.class))
                        .distance(tuple.get(7, Integer.class))
                        .build();

                /** 만들어진 dto를 dtoMap에 넣기 */
                dtoMap.put(carListingId, dto);
            }

            /** 대표이미지 여부에 따라 dto 필드에 추가 */
            CarListResponseDto dto = dtoMap.get(carListingId);
            if (isPrimary) {
                dto.setMainImageUrl(imageUrl);
            }
            else {
                /** List의 참조를 get 하기에 add로 값이 변경된다 */
                dto.getOtherImageUrls().add(imageUrl);
            }
        }

        /** 만들어진 dto 리스트를 반환 */
        return new ArrayList<>(dtoMap.values());
    }

    /**
     * 차량 상세정보를 조회합니다.
     */
    @Override
    public Optional<CarDetailFetchResult> getCarDetailById(Long listingId) {

        QUser user = QUser.user;
        QCar car = QCar.car;
        QCarAccident carAccident = QCarAccident.carAccident;
        QCarAccidentRepair carAccidentRepair = QCarAccidentRepair.carAccidentRepair;
        QCarListing carListing = QCarListing.carListing;
        QCarListingImage carListingImage = QCarListingImage.carListingImage;
        QCarMileage carMileage = QCarMileage.carMileage;
        QCarOption carOption = QCarOption.carOption;
        QCarOwnershipChange carOwnershipChange = QCarOwnershipChange.carOwnershipChange;
        QCarUsage carUsage = QCarUsage.carUsage;
        QModel model = QModel.model;

        /** 1. 기본 차량 정보 조회 (CarListing, Car, Model, User) */
        CarListing result = getQueryFactory()
                .selectFrom(carListing)
                .join(carListing.car, car).fetchJoin()
                .join(car.model, model).fetchJoin()
                .join(carListing.user, user).fetchJoin()
                .where(carListing.id.eq(listingId))
                .fetchOne();

        if (result == null) {
            return Optional.empty();
        }
        Long carId = result.getCar().getId();


        /** 2. 모든 이미지 정보 조회 */
        List<CarListingImage> images = getQueryFactory()
                .selectFrom(carListingImage)
                .where(carListingImage.carListing.id.eq(listingId))
                .fetch();

        /** 3. 사고 정보와 수리 정보 조회 */
        // 사고이력 전부 가져오기
        List<CarAccident> accidents = getQueryFactory()
                .selectFrom(carAccident)
                .where(carAccident.car.id.eq(carId))
                .fetch();

        // 사고이력 id 리스트 생성
        List<Long> accidentIds = accidents.stream()
                .map(CarAccident::getId)
                .collect(Collectors.toList());

        // 사고이력들의 수리정보 전부 조회
        List<CarAccidentRepair> repairs = accidentIds.isEmpty()
                ? List.of()
                : getQueryFactory()
                    .selectFrom(carAccidentRepair)
                    .where(carAccidentRepair.carAccident.id.in(accidentIds))
                    .fetch();

        // 수리정보를 사고이력 id 기준으로 그룹핑 리스트화
        Map<Long, List<CarAccidentRepair>> repairsByAccidentId = repairs.stream()
                .collect(Collectors.groupingBy(repair -> repair.getCarAccident().getId()));

        /** 4. 주행거리 조회 */
        List<CarMileage> mileages = getQueryFactory()
                .selectFrom(carMileage)
                .where(carMileage.car.id.eq(carId))
                .orderBy(carMileage.recordDate.desc())
                .fetch();

        /** 5. 옵션/안전장치 조회 */
        List<CarOption> options = getQueryFactory()
                .selectFrom(carOption)
                .where(carOption.car.id.eq(carId))
                .fetch();

        /** 6. 소유자/번호 변경이력 조회 */
        List<CarOwnershipChange> ownershipChanges = getQueryFactory()
                .selectFrom(carOwnershipChange)
                .where(carOwnershipChange.car.id.eq(carId))
                .orderBy(carOwnershipChange.changeDate.desc())
                .fetch();

        /** 7. 사용이력 조회 */
        CarUsage usage = getQueryFactory()
                .selectFrom(carUsage)
                .where(carUsage.car.id.eq(carId))
                .fetchOne();

        /** 결과 엔티티 리턴 */
        return Optional.of(new CarDetailFetchResult(
                result,
                images,
                accidents,
                repairsByAccidentId,
                mileages,
                options,
                ownershipChanges,
                usage
        ));
    }
}

 

이게 테스트 대상 커스텀 리파지터리인데... 엔티티가 워낙 많다 보니 좀 길고 복잡하긴 하다. 아래쪽 getCarDetailById() 는 쿼리와 역할분담이 마음에 드는데, 위쪽 getCarList() 는 dto 변환 과정까지 함께 묶여 있어서 마음에 안 든다. 쿼리만 남기고 변환 로직은 서비스 계층으로 빼야 하는데 일단 나중에 할 예정이다.

 

8) 커스텀 리파지터리 통합 테스트 코드

JPA에서 기본적으로 지원하는 findById() 같은 메서드들은 이미 닳도록 테스트 되어 왔기 때문에 테스트 우선순위가 낮다. 따라서 QueryDSL 등으로 작성된 커스텀 쿼리를 가진 Custom Repository를 우선적으로 테스트 하는 것이 좋다.

// test/../repository/car/CarListingRepositoryCustomImplTest

@DataJpaTest
@ActiveProfiles("test") // 테스트 환경 활성화
@Import({ // 필요한 클래스 import
        CarListingRepositoryCustomImpl.class,
        TestQuerydslConfig.class,
        TestJpaConfig.class,
        TestIntegrationBuilder.class
})
class CarListingRepositoryCustomImplTest {

    @Autowired
    private CarListingRepository carListingRepository;
    @Autowired
    private EntityManager em;
    @Autowired
    private TestIntegrationBuilder testIntegrationBuilder;

    /** 테스트 데이터 선언 */
    private User testUser;
    private Model testModel;
    private Car testCar;
    private CarAccident testCarAccident;
    private CarAccidentRepair testCarAccidentRepair;
    private CarListing testCarListing;
    private CarListingImage testMainImage;
    private CarListingImage testAdditionalImage;
    private CarMileage testCarMileage;
    private CarOption testCarOption;
    private CarOwnershipChange testCarOwnershipChange;
    private CarUsage testCarUsage;
    private CarListResponseDto testCarListResponseDto;
    private CarDetailFetchResult testCarDetailFetchResult;

    /** 테스트 데이터 초기화 */
    @BeforeEach
    void setup() {

        /** 모든 Car, User 관련 엔티티 DB에 저장하고 가져오기 */
        CarAndUserTestEntities testEntities = testIntegrationBuilder.createAndPersistAndFlushCarAndUserAllTestEntities();
        testUser = testEntities.user();
        testModel = testEntities.model();
        testCar = testEntities.car();
        testCarAccident = testEntities.accident();
        testCarAccidentRepair = testEntities.repair();
        testCarListing = testEntities.carListing();
        testMainImage = testEntities.mainImage();
        testAdditionalImage = testEntities.additionalImage();
        testCarMileage = testEntities.mileage();
        testCarOption = testEntities.option();
        testCarOwnershipChange = testEntities.ownershipChange();
        testCarUsage = testEntities.usage();

        /** testCarListResponseDto */
        List<String> otherImageUrls = new ArrayList<>();
        otherImageUrls.add(testAdditionalImage.getImageUrl());
        testCarListResponseDto = CarListResponseDto.builder()
                .carListingId(testCarListing.getId())
                .price(testCarListing.getPrice())
                .brandName(testModel.getBrandName())
                .carName(testCar.getCarName())
                .fuelType(testCar.getFuelType())
                .year(testCar.getYear())
                .month(testCar.getMonth())
                .distance(testCarMileage.getDistance())
                .mainImageUrl(testMainImage.getImageUrl())
                .otherImageUrls(otherImageUrls)
                .build();

        /** testCarDetailFetchResult */
        testCarDetailFetchResult = new CarDetailFetchResult(
                testCarListing,
                List.of(testMainImage, testAdditionalImage),
                List.of(testCarAccident),
                Map.of(testCarAccident.getId(), List.of(testCarAccidentRepair)),
                List.of(testCarMileage),
                List.of(testCarOption),
                List.of(testCarOwnershipChange),
                testCarUsage
        );

        /** 캐시 및 쿼리 통계 초기화 */
        TestQueryUtils.clearCacheAndResetStatistics(em);
    }
    
    // 생략 ..

 

통합 테스트는 H2 데이터베이스와 추가 테스트 환경 설정들이 필요하다. 따라서 @ActiveProfiles("test") 어노테이션으로 테스트 환경을 활성화해 준다.

 

그리고 위에서 말했듯,@DataJpaTest는 JPA 관련 빈과 @Repository가 달린 클래스만 가져오기 때문에 @Import로 필요한 클래스나 추가 설정을 가져온다.

 

그리고 통합 빌더로 User와 차량 관련 모든 영속화된 엔티티를 생성한다. 만약 저기에 빌더가 없었다면 코드가 얼마나 길어질지.. 생각만 해도 끔찍하다. 그리고 필요한 Dto를 초기화해 주고, 마지막에 Entity Manager 캐시와 Hibernate 쿼리 통계를 초기화해 준다. 이렇게 하면 모든 테스트 메서드가 깔끔한 초기 상태로 동작할 수 있다.

 

    @Test
    @DisplayName("판매중인 차량 목록 조회 테스트")
    void getCarList() {

        // when
        /** 실제로 쿼리를 실행 */
        List<CarListResponseDto> result = carListingRepository.getCarList();

        // then
        /** 결과가 비어 있지 않아야 함 */
        assertThat(result).isNotEmpty();
		
        /** setup() 에서 만든 expected dto(testCarListResponseDto) 의 결과와 일치하는지 확인 */
        CarListResponseDto dto = result.get(0);
        assertThat(dto.getCarListingId()).isEqualTo(testCarListing.getId());
        assertThat(dto.getPrice()).isEqualByComparingTo(testCarListing.getPrice());
        assertThat(dto)
                .usingRecursiveComparison()
                .withComparatorForType(BigDecimal::compareTo, BigDecimal.class)
                .isEqualTo(testCarListResponseDto);
    }

    @Test
    @DisplayName("판매중인 차량 목록 조회 시 N+1 문제 없이 한 번의 쿼리로 조회되는지 테스트")
    void shouldFetchCarListWithSingleQuery() {

        // when
        /** 실제로 쿼리를 실행 */
        carListingRepository.getCarList();

        // then
        /** 정확히 한 번의 쿼리가 실행되었는지 검증 */
        int queryCount = TestQueryUtils.getQueryCount(em); // 직접 구현한 TestQueryUtils 클래스 메서드
        assertThat(queryCount).isEqualTo(1);
    }

 

이런 식으로 통합 테스트는 실제로 DB에 쿼리를 날려 상호작용을 검증하게 된다.

또한 아까 만들어 두었던 TestQueryUtils 클래스 메서드를 이용해서, 몇 번의 쿼리가 실행되었는지도 정확하게 검증할 수 있다.

 

    @Test
    @DisplayName("판매중인 차량이 없을 경우 빈 ArrayList 반환하는지 테스트")
    void shouldReturnEmptyArrayListWhenNoDataExists() {

        // given
        /** 이 테스트는 데이터가 존재하지 않아야 하므로 삭제한다 */
        em.createQuery("DELETE FROM CarListingImage").executeUpdate();
        em.createQuery("DELETE FROM CarListing").executeUpdate();
        em.flush();
        em.clear();

        // when
        /** 실제로 쿼리를 실행 */
        List<CarListResponseDto> result = carListingRepository.getCarList();

        // then
        /**
         * null이 아닌지(빈 ArrayList는 null이 아님)
         * ArryList가 비어 있는지
         */
        assertThat(result).isNotNull();
        assertThat(result).isEmpty();
    }

 

setup() 에서 항상 데이터를 insert 해두기 때문에, 데이터가 없어야 하는 상황에는 이렇게 하면 된다.

해당 테스트 메서드에서 em.createQuery() 메서드로 직접 쿼리를 작성해 데이터를 지워주면 된다.

이렇게 하면 모든 테스트 메서드는 데이터가 존재하는 상태에서 시작하되, 필요한 경우에만 특별히 데이터를 지워줄 수 있다.

 

이렇게 테스트 코드를 작성해 보았다. 물론 생략된 부분도 많기에 더 확인하고 싶다면 맨 아래 GitHub에 가서 확인하면 된다.

 

================

 

 

테스트 코드 작성은 생각보다 너무나 복잡했다. 지금이야 완성된 코드만 보면 그렇게 어려워 보이지 않지만, 이 코드를 작성하기 위해 정말 많이 공부했다. 개발과 기능 구현, 성능에만 초점을 두고 코드를 짜다가 테스트 관점에서 코드를 짜보니 새롭게 배우는 것도 많았고, 이런 관점이 적응이 안 되기도 했다. 그러나 확실히 공부하고 정리해 두니 뭔가 제대로 얻어간 것 같아 뿌듯하기도 하다. 이 테스트 코드 전부 짜는데 8일이나 걸렸고, 이 글을 쓰는데만 하루가 꼬박 걸렸다. 그러나 제대로 하지 않으면 안 하느니만 못하다고 생각한다. 어차피 되돌아가게 되니까.

 

 

=============

 

728x90
반응형