PROJECT/Stamp Crush

[우테코] E2E 테스트에서 데이터 격리 (템플릿 공유합니다!!!)

깃짱 2025. 10. 29. 16:30
반응형

🌏 인트로

서비스를 실제로 개발하다 보면 단위 테스트(Unit Test)보다 훨씬 현실적인 End-to-End(E2E) 테스트를 작성할 일이 많습니다.

E2E 테스트는 실제 API를 호출하고, 데이터베이스와의 연동, 인증 절차, 외부 요청 등까지 통합적으로 검증하는 테스트입니다.

✅ E2E 테스트 예시

(인수테스트입니다)

@Test
void 고객이_자신의_리워드를_조회한다() {
    String ownerAccessToken = 카페_사장_회원_가입_요청하고_액세스_토큰_반환(OWNER_CREATE_REQUEST);
    Long savedCafeId = 카페_생성_요청하고_아이디_반환(ownerAccessToken, CAFE_CREATE_REQUEST);

    String customerToken = 가입_고객_회원_가입_요청하고_액세스_토큰_반환(...);
    Long customerId = authTokensGenerator.extractMemberId(customerToken);

    Long couponId = 쿠폰_생성_요청하고_아이디_반환(ownerAccessToken, new CouponCreateRequest(savedCafeId), customerId);

    쿠폰에_스탬프를_적립_요청(ownerAccessToken, customerId, couponId, new StampCreateRequest(10));

    ExtractableResponse<Response> response = 리워드_목록_조회(customerToken);
    VisitorRewardsFindResponse result = response.body().as(VisitorRewardsFindResponse.class);

    assertAll(
            () -> assertThat(response.statusCode()).isEqualTo(OK.value()),
            () -> assertThat(result.getRewards()).isNotEmpty()
    );
}

 

이 테스트는 실제로 API를 호출하고 DB에 데이터를 쌓습니다. 문제는 이런 테스트가 DB 상태를 오염시킨다는 점입니다.

✅ DB 오염의 문제점: 비결정적 테스트

테스트가 실행될수록 데이터가 계속 누적되며, 다음 테스트가 기존 데이터에 영향을 받는 비결정적 테스트(Non-deterministic Test)로 변질됩니다.

 

테스트는 ‘언제 실행하더라도 같은 결과’를 내야 하는데, DB가 오염되면 테스트를 신뢰할 수 없게 됩니다ㅠㅠ

🌏 테스트 데이터 격리

✅ 모든 테스트는 깨끗한 DB에서 시작해야 한다

End-to-End 테스트에서는 테스트 전후에 항상 DB를 초기화해야 합니다.

단순히 @Transactional을 사용하면 테스트가 끝난 뒤 자동으로 롤백되어 DB가 깨끗해지지만, 이 방식은 @SpringBootTest(webEnvironment = RANDOM_PORT) 환경에서는 통하지 않습니다.

 

 

왜냐하면 이 경우에는 실제로 서버를 띄워서 API 요청을 보내기 때문에, 요청이 테스트 메서드의 트랜잭션 경계 밖에서 수행됩니다.

테스트 코드가 아무리 @Transactional로 감싸져 있어도 HTTP 요청을 통해 실행된 비즈니스 로직은 별도의 트랜잭션에서 커밋되어 버리기 때문에 단순 롤백으로는 DB를 되돌릴 수 없습니다.

 

 

물론 테스트마다 스프링 컨테이너를 통째로 다시 띄우는 방법(@DirtiesContext 등)도 있긴 하지만, 테스트 한 번 실행할 때마다 애플리케이션 컨텍스트가 새로 만들어지고, 빈 초기화, DI 주입, 서버 부팅까지 전부 다시 일어나기 때문에 너무 비효율적입니당,, 테스트 속도가 수 초 단위로 느려지고, 대규모 테스트에서는 사실상 불가능에 가깝습니다.

 

따라서 테스트 전후에 항상 DB를 직접 초기화해야 합니다

 

 

이 포스팅에서는 쉽게 가져다 쓸 수 있는 초기화 템플릿을 공유하려고 합니당

🌏 DataCleaner

@Profile("test")
@Component
public class DataCleaner {

    private static final String FOREIGN_KEY_CHECK_FORMAT = "SET FOREIGN_KEY_CHECKS %d";
    private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s";

    private final List<String> tableNames = new ArrayList<>();

    @PersistenceContext
    private EntityManager entityManager;

    @PostConstruct
    public void findDatabaseTableNames() {
        List<Object[]> tableInfos = entityManager.createNativeQuery("SHOW TABLES").getResultList();
        for (Object[] tableInfo : tableInfos) {
            tableNames.add((String) tableInfo[0]);
        }
    }

    @Transactional
    public void clear() {
        entityManager.clear();
        truncate();
    }

    private void truncate() {
        entityManager.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, 0)).executeUpdate();
        for (String tableName : tableNames) {
            entityManager.createNativeQuery(String.format(TRUNCATE_FORMAT, tableName)).executeUpdate();
        }
        entityManager.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, 1)).executeUpdate();
    }
}

 

  1. 테이블 이름 로드
    SHOW TABLES로 현재 DB 내의 모든 테이블 이름을 읽어옵니다.
  2. 외래 키 제약 비활성화각 테이블을 TRUNCATE로 비웁니다.
    SET FOREIGN_KEY_CHECKS = 0으로 FK 제약을 끄고,
  3. 다시 활성화
    SET FOREIGN_KEY_CHECKS = 1으로 복구합니다.

이렇게 하면 모든 데이터가 완전히 초기화된 상태로 다음 테스트가 시작됩니다.

 

🌏 DataClearExtension

하지만 매 테스트마다 dataCleaner.clear()를 수동으로 호출하면 불편하니 자동으로 실행해주는 JUnit 5 Extension을 추가합니다.

public class DataClearExtension implements BeforeEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) {
        DataCleaner dataCleaner = SpringExtension.getApplicationContext(context)
                .getBean(DataCleaner.class);
        dataCleaner.clear();
    }
}

 

 

이 클래스를 @ExtendWith로 등록하면 테스트 시작 전에 항상 DataCleaner.clear()가 자동으로 호출됩니다.

🌏 테스트 템플릿

최종적으로 모든 E2E 테스트는 다음과 같은 템플릿을 상속받습니다.

@ExtendWith(DataClearExtension.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
public class AcceptanceTest {

    @LocalServerPort
    private int port;

    @BeforeEach
    void setup() {
        RestAssured.port = port;
    }

    @AfterEach
    void tearDown() {
        cleaner.clear();
    }
}

 

 

여기서 @ExtendWith(DataClearExtension.class) 덕분에 각 테스트마다 DB가 자동으로 정리됩니다.

🌏 사용 예시

이제 단순히 AcceptanceTest를 상속받기만 하면 됩니다.

class VisitorRewardFindAcceptanceTest extends AcceptanceTest {

    @Test
    void 고객이_자신의_리워드를_조회한다() {
        // 실제 API 호출 및 검증
    }

    @Test
    void 리워드가_없으면_빈_배열을_반환한다() {
        // 또 다른 시나리오
    }
}

 

 

각 테스트가 끝날 때마다 자동으로 데이터가 비워지므로 서로 영향을 주지 않고, 테스트 실행 순서에도 의존하지 않습니다.

 

 

 

 

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!

 

반응형