@TransactionEventHandler를 어떻게 테스트 해야 할까 + 테스트의 단위를 어디까지 제한해야 할까

eora21·2024년 4월 8일
0

너나드리 개발기

목록 보기
7/8

@TransactionEventHandler를 통해 이미지를 저장하는 로직이 있습니다. 이를 테스트하기 위해 고민한 기록을 작성합니다.

PictureSupplier의 supply() 메서드는 Picture Entity를 영속화한 후 이벤트를 통해 MultipartFile을 트랜잭션 커밋 직전에 저장하고, 트랜잭션 롤백 시 삭제시킵니다.

PictureSupplier 테스트코드를 작성하면서 해당 이벤트를 어떻게 처리해야 할 지 고민했습니다.

강제 트랜잭션을 통한 이벤트 동작 테스트

코드

@DisplayName("Picture 영속화 요청 시 커밋 직전에 이미지를 저장한다")
@TestFactory
Stream<DynamicTest> pictureSupplyCommit() {

    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    def.setName("save");
    TransactionStatus status = transactionManager.getTransaction(def);
    
    return Stream.of(
            dynamicTest("트랜잭션을 시작하고 PictureSupplier에게 supply를 의뢰한다", () -> {
                
                // given
                MockMultipartFile multipartFile = new MockMultipartFile("image", "image.jpg", "image/jpg",
                        "".getBytes());
                
                // when
                Picture picture = pictureSupplier.supply("/test", multipartFile);
                
                // then
                Assertions.assertThat(picture)
                        .extracting(Picture::getFolderName, Picture::getOriginName, Picture::getExtension)
                        .containsExactly("/test", "image", "jpg");
                verify(imageSaveEventPublisher, times(1))
                        .provide(List.of(new Image(picture.getSavePath(), multipartFile)));
            }),
            
            dynamicTest("커밋이 발생하고 이미지 저장을 요청한다", () -> {
            
                // when
                transactionManager.commit(status);
                
                // then
                verify(imageSaveEventListener, times(1)).writeImages(any(ImageEvent.class));
            })
    );
}

첫번째 시나리오에서는 저장된 Entity의 값을 비교하고, 이벤트가 publish 되었는지 확인했습니다.
두번째 시나리오에서는 커밋을 발생시키고, 이벤트가 Listen되었는지 확인했습니다.

롤백이 발생했을 때의 시나리오도 대동소이하게 작성했습니다.

생각한 문제점

이벤트 퍼블리셔와 리스너를 테스트한다는 것은 결과가 아닌 과정을 테스트하는 것과 같다는 생각이 들었습니다.
따라서 이벤트 리스너가 사용하는 ImageProcessor에 대해 모킹 후 테스트하기로 결정했습니다.

강제 트랜잭션을 통한 이벤트 결과 테스트

코드

 @Test
 @DisplayName("Picture 영속화 요청 시 커밋 직전에 이미지를 저장한다")
 void pictureSupplyCommit() {
 
     // given
     DefaultTransactionDefinition def = new DefaultTransactionDefinition();
     def.setName("save");
     def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
     TransactionStatus status = transactionManager.getTransaction(def);
     
     MockMultipartFile multipartFile = new MockMultipartFile("image", "image.jpg", "image/jpg",
             "".getBytes());
     
     // when
     Picture picture = pictureSupplier.supply("/test", multipartFile);
     transactionManager.commit(status);
     
     // then
     Assertions.assertThat(picture)
             .extracting(Picture::getFolderName, Picture::getOriginName, Picture::getExtension)
             .containsExactly("/test", "image", "jpg");
     verify(super.imageProcessor, atLeastOnce()).writeImage(any(Image.class));
 }

이벤트 자체를 모킹하지 않으니, 시나리오 테스트가 아닌 일반적인 테스트로 결과를 확인할 수 있도록 수정하였습니다.

생각한 문제점

테스트에서 트랜잭션의 이름을 설정한 이유는 EventPublisher가 MultipartFile 저장 단위를 트랜잭션 이름으로 나누기 때문입니다.
이는 구현 세부 사항에 속하며, 이같은 설정이 변경될 시 해당 테스트코드는 거짓 양성을 띄게 될 것입니다.

리팩터링 내성을 지니기 위해서는 실제 코드처럼 @Transactional을 통한 AOP proxy로 동작하도록 설정하는 게 좋을 것 같다는 판단이 들었습니다.

트랜잭션 어노테이션을 통한 이벤트 결과 테스트

코드

트랜잭션 래핑 클래스

@Component
@Transactional
public class WrapWithTransaction {
    public <T> T commit(Supplier<T> supplier) {
        return supplier.get();
    }

    public void rollback(Runnable runnable) {
    	runnable.run();
    	throw new RuntimeException();
	}
}

단순 커밋과 롤백 기능을 하는 코드를 작성하였습니다.
메인 코드에서 rollbackFor 및 noRollbackFor 관련 아무것도 설정하지 않았기 때문에, 단순하게 RuntimeException을 반환하는 것으로 롤백이 실행되도록 했습니다.
추후 해당 설정이 부여된다면 메서드 오버로딩을 통해 던져줄 예외를 파라미터로 받도록 설정하면 될 것 같습니다.

테스트 통합 클래스

@SpringBootTest
@Import(WrapWithTransaction.class)
@ExtendWith(MockitoExtension.class)
public abstract class IntegrationTestSupport {
    
    @MockBean
    protected ImageProcessor imageProcessor;
    
    @InjectMocks
    protected ImageSaveEventListener imageSaveEventListener;
}

컨텍스트 생성을 최적화하기 위해 유지하고 있는 가상 클래스입니다.
@Import를 통해 트랜잭션 래핑 컴포넌트를 추가했고, 앞으로 있을 모든 테스트에 대해 ImageProcessor를 모킹하여 실제 파일이 저장되는 경우를 막았습니다.

테스트 코드

@Test
@DisplayName("Picture 영속화 요청 시 커밋 직전에 이미지를 저장한다")
void pictureSupplyCommit() {
    
    // given
    MockMultipartFile multipartFile = new MockMultipartFile("image", "image.jpg", "image/jpg",
            "".getBytes());
    
    // when
    Picture picture = wrapWithTransaction.commit(() -> pictureSupplier.supply("/test", multipartFile));
    
    // then
    Assertions.assertThat(picture)
            .extracting(Picture::getFolderName, Picture::getOriginName, Picture::getExtension)
            .containsExactly("/test", "image", "jpg");
    verify(super.imageProcessor, atLeastOnce()).writeImage(any(Image.class));
}

@Test
@DisplayName("Picture 영속화 요청 중 롤백에 의한 이미지 삭제를 요청한다")
void pictureSupplyRollback() {

    // given
    MockMultipartFile multipartFile = new MockMultipartFile("image", "image.jpg", "image/jpg",
            "".getBytes());
            
    // when
    try {
        wrapWithTransaction.rollback(() -> pictureSupplier.supply("/test", multipartFile));
    } catch (Exception e) {
        // ignore
    }
    
    // then
    verify(super.imageProcessor, atLeastOnce()).deleteImage(any(Image.class));
}

생각한 문제점

Picture 영속화 과정을 테스트하며, 이미지 저장과 삭제에 대한 테스트를 진행하는 것이 맞는지에 대한 생각이 들었습니다.

물론 영속화 과정에서 이미지 저장이라는 사이드 이펙트가 발생하는 것은 맞지만, 이를 'Picture 영속화 테스트'와 '이벤트 발행 테스트'로 나누는 게 맞지 않을까 하는 생각이 들었습니다.

하지만 이미지 저장 이벤트 발행은 현재 트랜잭션의 propagation이 MANDATORY로 설정되어 있고, Picture의 영속화 과정을 위해 존재하기에 이 이상으로 분리하게 될 시 영속화 과정 중 이벤트가 발생하지 않는 경우를 추후 탐지할 수 없으리란 생각(거짓 음성)이 들었습니다. 따라서 우선은 위와 같은 테스트를 남겨두도록 했습니다.

허나 아직까지도 고민이 많습니다. 더 좋은 의견을 남겨주시면 감사하겠습니다.

Reference

단위 테스트(블라디미르 코리코프 저)

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글