테스트 @Transactional? @Sql? 선택에 대한 내 생각

구범모·2023년 9월 19일
0

테스트 Transactional 어노테이션 → Sql 어노테이션 적용 계기

현재 우리팀은 매 테스트마다 상관관계를 미치지 않기 위해, @Transactional 어노테이션을 이용하여 실제로 db에 저장하지 않고 롤백하는 방법을 채택했다.

하지만

  1. 실제 인수테스트에서는 Random Port를 이용하여 서버를 띄우는데, 클라이언트와 서버가 서로 다른 쓰레드에서 실행된다고 한다. (참고 : https://tecoble.techcourse.co.kr/post/2020-09-15-test-isolation/, https://velog.io/@jhp1115/1주차-인수테스트-코드에서의-팁) 따라서 @Transactional을 붙여준다 하더라도 서버측 트랜잭션은 롤백되지 않는다고 한다. (참고 : https://velog.io/@rg970604/트러블슈팅-SpringbootTest의-RANDOMPORT-환경에서-Transactional-어노테이션을-사용했을-때-RestAssured-GET-요청이-수행되지-않는-경우)
  2. 개인적인 경험으로, 기존에는 성공했지만, Transactional을 제거했을 때 테스트가 실패하는 경우가 몇 번 있었다.(아래서 설명하겠다.) 이는 기존의 테스트 성공이 정말 맞는 테스트 코드를 작성해서 라기 보다, Transactional을 붙여주었기 때문에 통과하는 테스트라고 정의내렸다.

이런 이유 때문에, 테스트에서 @Transactional을 제거하자고 결정을 내렸고,

Transactional 없이 테스트를 격리할 수 있는 방법을 찾아보았다.

(출처 : https://tecoble.techcourse.co.kr/post/2020-09-15-test-isolation/)

  1. 매 테스트 수행 이후 생성한 픽스처 및 데이터 직접 삭제
    1. 단점 : @BeforeEach에 작성해야 하는 코드가 테스트마다 선형적으로 늘어난다.
  2. 매 테스트 이후 Truncate 쿼리로 모든 테이블 초기화
    1. 장점 : 별도의 sql파일 하나로 관리할 수 있으므로, 관리해야 할 테이블이 늘어날 때 sql파일에만 작성해 주면 된다.(동시에 단점이지만, 1번보다는 확실히 나을 듯 하다.)
    2. 단점 : 쿼리문 작성 필요
  3. DirtiesContext로 Spring Bean Reload 하기
    1. 단점 : 매 테스트마다 Spring Context를 비워주어야 하기 때문에, 테스트가 정말 무거워 질 듯 하다.

3개의 선택지 중에서 제일 나아보이는 2번을 선택하였다.

(사실 위 방법을 제외하고도 테스트 격리를 하는 방법이 더 있을수도 있지만, 곧 프로젝트 마감이므로 빠른 선택이 필요할 것 같아 위 방법들에서만 선택지를 고려하였다.)

그런데 왜 이런 선택을 내린걸까 ?

위의 적용 계기의 2번에서 연장해서 설명해 보겠다.

수정 전 테스트코드(Transactional 있을 때).

// test fixture 세팅
@BeforeEach
void setUp() {
	delivery = deliverySetUp.saveOne(1L);
	rider = riderSetUp.saveOne();
}
// 테스트 코드
@Transactional
@Nested
@DisplayName("배달기사를 배정할 수 있다.")
class allocateRider {

	@Test
	@DisplayName("성공한다.")
	void success_test() {
		// when
		DeliveryHistoryResponse deliveryHistoryResponse = 
deliveryService.allocateRider(delivery.getDeliveryId(),rider.getRiderId());
	
		// then
		List<DeliveryHistory> deliveryHistories = deliveryHistoryRepository
.findDeliveryHistoriesByDeliveryId(delivery.getDeliveryId());
		assertThat(deliveryHistories).hasSize(2);
		assertThat(delivery.getRider().getRiderId()).isEqualTo(rider.getRiderId());
		assertThat(deliveryHistoryResponse.deliveryStatus()).isEqualTo(DeliveryStatus.ALLOCATED);
	}
}
// 서비스 코드
@Transactional
public DeliveryHistoryResponse allocateRider(
	Long deliveryId,
	Long riderId
) {
	Delivery delivery = deliveryRepository.findById(deliveryId)
		.orElseThrow(
			() -> new EntityNotFoundException(ErrorCode.DELIVERY_NOT_FOUND)
		);

	Rider rider = riderRepository.findById(riderId)
		.orElseThrow(
			() -> new EntityNotFoundException(ErrorCode.DELIVERY_NOT_FOUND)
		);

	delivery.attach(rider);

	DeliveryHistory deliveryHistory = DeliveryHistory.createAllocatedDeliveryHistory(delivery);
	DeliveryHistory savedDeliveryHistory = deliveryHistoryRepository.save(deliveryHistory);

	DeliveryHistoryResponse response = DeliveryHistoryResponse.of(
		rider,
		savedDeliveryHistory
	);

	return response;
}

배달 기사 배정 테스트코드이다. deliveryService의 allocateRider메소드를 호출한다.(이때, delivery와 rider는 이미 영속화된 상태이다.)

기존의 @Transactional을 적용했을 땐 정상작동되지만, @Transactional 어노테이션을 삭제한 후,

@Sql을 붙여서 매 테스트마다 롤백이 아닌 DB를 truncate해주면 테스트가 실패한다. 아래는 테스트 실패 메시지이다.

분명 deliveryService의 allocateRider에서, delivery객체와 rider를 연관관계 매핑 해주었다. (delivery.attach(rider)가 그 코드이다.)

디버깅 했을때도 연관관계 매핑은 문제가 없었지만, 문제는 서비스 메소드가 끝나고, 나머지 테스트코드를 수행할 때 이다.

현재 테스트코드에는 Transactional이 붙어있지 않아, 서비스 메소드가 수행 된 이후에는 영속성 컨텍스트가 없어져 버린다. 따라서 테스트 코드로 돌아왔을 때, DB상으론 연관관계 매핑이 되어있지만(FK로 delivery)delivery객체 자체는 연관관계 매핑이 되어있지 않은 상태이다.


allocateRider호출 이전


allocateRider호출 이후. DB에는 rider_id가 FK로 들어왔지만, 밑의 delivery객체의 rider는 null이다.

따라서 테스트 코드 자체가 잘못되었다 판단하여, 아래의 코드로 수정하였다.

수정 후 테스트코드(@Transactional 삭제 후 @Sql 어노테이션 적용).

// truncate.sql
SET foreign_key_checks = 0;
TRUNCATE TABLE addresses;
TRUNCATE TABLE deliveries;
TRUNCATE TABLE delivery_histories;
TRUNCATE TABLE members;
TRUNCATE TABLE menu_option_group;
TRUNCATE TABLE menu_options;
TRUNCATE TABLE menus;
TRUNCATE TABLE order_histories;
TRUNCATE TABLE order_items;
TRUNCATE TABLE orders;
TRUNCATE TABLE riders;
TRUNCATE TABLE selected_options;
TRUNCATE TABLE shops;
SET foreign_key_checks = 1;
@Sql("/truncate.sql")
@Nested
@DisplayName("배달기사를 배정할 수 있다.")
class allocateRider {

	@Test
	@DisplayName("성공한다.")
	void success_test() {
		// when
		DeliveryHistoryResponse deliveryHistoryResponse = deliveryService
.allocateRider(delivery.getDeliveryId(),rider.getRiderId());

		// then
		// 연관관계가 매핑된, 최신의 데이터와 비교하기 위해 DB에서 find
		Delivery savedDelivery = deliveryRepository.findById(delivery.getDeliveryId()).get();
		List<DeliveryHistory> deliveryHistories = deliveryHistoryRepository
.findDeliveryHistoriesByDeliveryId(delivery.getDeliveryId());
		assertThat(deliveryHistories).hasSize(2);
		assertThat(savedDelivery.getRider().getRiderId()).isEqualTo(rider.getRiderId());
		assertThat(deliveryHistoryResponse.deliveryStatus()).isEqualTo(DeliveryStatus.ALLOCATED);
	}
}

느낀 점

현재 있는 선택지들 중에 그나마 낫다고 생각하는 것 해결책을 도입했는데, 이마저도 편리한 방법까지는 아닌 듯 싶다. 실무에서는 테스트를 어떻게 진행할까 궁금해지는 계기가 되었다.

profile
우상향 하는 개발자

0개의 댓글