@SpringBootTest, RANDOM_PORT 환경에서 테스트 격리 보장하기(개삽질 후기)

이건회·2023년 5월 9일
0

springmvc

목록 보기
29/29

개요


우테코 2단계 장바구니 테스트 미션을 수행하던 중, RandomPort 환경에서 통합 테스트를 수행하는 아래와 같은 테스트 코드를 작성했다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CartViewControllerTest {
	...
}

싱글벙글하게 메소드 단위에서 테스트를 돌리고 나서, 클래스 단위에서 테스트를 돌리니 갑자기 테스트가 실패하게 되었다.
뭐지...? 분명히 따로따로 돌렸을 때는 잘 돌아갔는데...

따라서 처음에는 울며 겨자먹기로 장바구니 추가/삭제 테스트를 없애버렸다. 아쉬움을 뒤로 하고 도메인 단위에서 테스트를 다시 돌렸다. 당연히 통과하겠지...라고 생각했는데...또 테스트가 터졌다. 다른 테스트 클래스에서 전체 성공하던 테스트들이 펑펑 터지고 있었다.

차오르는 눈물을 뒤로 하고...테스트를 격리하기 위해 코드를 계속해서 손봤지만 낭패를 보기 일쑤였다. 당연히 @Transactional 어노테이션을 드르륵 붙여버리면 되겠거니 했지만 전혀 먹히지 않았다.

한 이틀을 삽질하고...하 그냥 @SpringBootTest는 제끼고 @WebMvcTest만으로 단위테스트 하는 선에서 끝낼까...고민했지만 뭔가 계속 찝찝했고, 크루들에게 계속해서 질문을 던지며 시도해 봤다.

테스트가 터진 이유: RANDOM_PORT 환경에서는 @Transactional이 있어도 서버에서 시작된 트랜잭션이 롤백되지 않는다


답은

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

위 코드에 있었다.

spring io의 공식 문서를 확인해 보자

If your test is @Transactional, it rolls back the transaction at the end of each test method by default. However, as using this arrangement with either RANDOM_PORT or DEFINED_PORT implicitly provides a real servlet environment, the HTTP client and server run in separate threads and, thus, in separate transactions. Any transaction initiated on the server does not roll back in this case.

테스트에 @Transactional이 붙어 있으면 롤백을 보장할 수 있다. 단, RANDOM_PORT 나 DEFINED_PORT 환경에서 테스트를 수행하면, 실제 서블릿 환경에서 테스트를 진행한다. 이 때 http 클라이언트(테스트)와 서버는 서로 다른 스레드를 갖는다. 즉 별개의 트랜잭션이 수행되는 것이다. 따라서 서버 쪽에서의 트랜잭션은 롤백되지 않는다.

그니까...@Transactional 이 있고 자시고 실제 서버에서 수행되는 트랜잭션은 얘네랑 아무 상관없는 별개 스레드에서 수행되는 만큼 롤백이 될 수 없다는 것이었다.

즉, 내가 자체적으로 데이터베이스를 롤백시키는 코드를 실행시켜야 했다.

@Sql 어노테이션을 활용해, 테스트가 끝날 때마다 테이블을 Drop 시킨 후 다시 Create 하자!


답은 @Sql 어노테이션을 활용하는 것이었다.

testdata.sql 파일

DROP TABLE IF EXISTS CART;
DROP TABLE IF EXISTS MEMBER;
DROP TABLE IF EXISTS PRODUCT;

CREATE TABLE IF NOT EXISTS PRODUCT
(
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name       VARCHAR(255)    NOT NULL,
    image      VARCHAR(2083),
    price      BIGINT UNSIGNED    NOT NULL,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
    );

CREATE TABLE IF NOT EXISTS MEMBER
(
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    email      VARCHAR(320) NOT NULL,
    password   VARCHAR(15) NOT NULL,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY(id)
    );

CREATE TABLE IF NOT EXISTS CART
(
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    product_id BIGINT UNSIGNED NOT NULL,
    member_id BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY(id),
    FOREIGN KEY (product_id) REFERENCES PRODUCT(id) ON DELETE CASCADE,
    FOREIGN KEY (member_id) REFERENCES MEMBER(id) ON DELETE CASCADE
    );

...

다음과 같이 IF EXISTS 예약어를 활용하여 테이블이 존재할 경우 드랍시킨 후 다시 테이블을 CREATE 하는 방식을 선택했다.

@Sql 어노테이션을 클래스레벨, 혹은 메소드 레벨에 활용하면 각 테스트 메소드가 실행될 때 마다 지정한 sql 스크립트를 실행하도록 할 수 있다. 따라서 위의 sql 파일을

@Sql("/testdata.sql")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CartViewControllerTest {
	...
}

다음과 같이 Random Port 환경의 모든 테스트 클래스의 클래스 레벨에 선언했다!

짜잔...드디어 "Run All Tests"를 성공했다. 이제 각 테스트의 격리가 성공적으로 보장되었다.

메소드가 실행될 때마다 별개의 Application Context를 띄우는 방법도 있다


@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CartViewControllerTest {
	...
}

다음과 같이 @DirtiesContext 어노테이션을 활용하여 각 테스트 메소드마다 별도의 Application Context를 생성하는 방법도 있다.

이를 사용하는 방법은 이 블로그에 잘 설명되어 있으니 첨부하겠다.

그러나 이 방식은 애플리케이션 컨텍스트를 계속해서 로딩하기 때문에 생성 비용이 크고 테스트가 느려지므로, @Sql 어노테이션을 사용하는 방식을 더 선호할 것 같다.

profile
하마드

0개의 댓글