S-HOOK 팀 베로입니다 👾
테스트 격리가 되지 않아 발생한 문제를 공유하고, 인수 테스트를 격리시키는 과정을 정리해보았습니다.
인수 테스트 메서드를 추가했는데, 이전에는 잘 동작하던 테스트가 깨지는 상황이 발생했습니다.
문제는 테스트 격리가 되어 있지 않기 때문입니다.
저장한 값을 조회하기 위해 repository 로 값을 저장해 둔 상태였는데, 다른 테스트 메서드에서 저장해둔 값이 함께 조회된 것입니다.
@DisplayName("특정 노래를 조회할 때, 이전 노래와 다음 노래의 정보를 담은 응답을 반환한다.")
@Test
void findById() {
// given
final VotingSong beforeSong = votingSongRepository.save(
new VotingSong("제목1", "비디오URL", "이미지URL", "가수", 20));
final VotingSong standardSong = votingSongRepository.save(
new VotingSong("제목2", "비디오URL", "이미지URL", "가수", 20));
// when
final VotingSongSwipeResponse response = RestAssured.given().log().all()
.when().log().all()
.get("/voting-songs/{voting_song_id}", standardSong.getId())
.then().log().all()
.statusCode(HttpStatus.OK.value())
.extract()
.body().as(VotingSongSwipeResponse.class);
// then
final List<VotingSongResponse> expectedBefore = Stream.of(beforeSong)
.map(VotingSongResponse::from)
.toList();
assertAll(
() -> assertThat(response.getBeforeSongs()).usingRecursiveComparison()
.isEqualTo(expectedBefore),
() -> assertThat(response.getCurrentSong()).usingRecursiveComparison()
.isEqualTo(VotingSongResponse.from(standardSong)),
() -> assertThat(response.getAfterSongs()).isEmpty()
);
}
즉, getBeforeSongs
에 이전에 등록했던 값들이 추가되어 원하는 값을 가져오지 못하게 된 것입니다.
각 테스트들이 다른 테스트에 영향을 줄 수 없도록, 테스트를 격리해봅시다!
import jakarta.annotation.PostConstruct;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.metamodel.EntityType;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("test")
public class DataCleaner {
private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s";
private static final String ALTER_TABLE_FORMAT = "ALTER TABLE %s ALTER COLUMN ID RESTART WITH 1";
private static final String CAMEL_CASE_REGEX = "([a-z])([A-Z]+)";
private static final String SNAKE_CASE_REGEX = "$1_$2";
private List<String> tableNames;
@PersistenceContext
private EntityManager entityManager;
@PostConstruct
public void findDatabaseTableNames() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(DataCleaner::isEntityClass)
.map(DataCleaner::convertCamelCaseToSnakeCase)
.toList();
}
private static boolean isEntityClass(final EntityType<?> e) {
return e.getJavaType().getAnnotation(Entity.class) != null;
}
private static String convertCamelCaseToSnakeCase(final EntityType<?> e) {
return e.getName().replaceAll(CAMEL_CASE_REGEX, SNAKE_CASE_REGEX).toLowerCase();
}
@Transactional
public void clear() {
entityManager.flush();
entityManager.clear();
truncate();
}
private void truncate() {
for (String tableName : tableNames) {
entityManager.createNativeQuery(String.format(TRUNCATE_FORMAT, tableName))
.executeUpdate();
entityManager.createNativeQuery(String.format(ALTER_TABLE_FORMAT, tableName))
.executeUpdate();
}
}}
test 프로필에서만 bean 이 주입되도록 @Profile("test")
를 달아주었습니다.
findDatabaseTableNames()
해당 bean이 생성된 이후에 entity 클래스들을 가져와서 클래스 이름을 Camelcase 에서 snake_case 로 변경해주었습니다.
DB 테이블 이름은 snake_case 로 되어 있기 때문에, 변경 과정이 반드시 필요합니다.
clear()
쓰기 지연 저장소에 남아있는 쿼리들을 모두 수행합니다.
영속성 컨텍스트에 남아있는 데이터들을 모두 삭제한 후, 테이블을 TRUNCATE
합니다.
truncate()
테이블을 TRUNCATE
하고, auto-increment 된 PK 값을 1로 돌려 놓는 과정입니다.
AcceptanceTest
클래스 생성S-HOOK 에서 인수테스트는 공통적으로 @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
를 사용합니다.
따라서 모든 인수테스트에서 동일하게 테스트 격리를 위해 + DataCleaner
를 언제나 @Autowired
하는 귀찮음을 덜기 위해 공통 부분을 AcceptanceTest
클래스로 분리했습니다.
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
@Autowired
private DataCleaner dataCleaner;
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
dataCleaner.clear();
}
}
이제 인수테스트를 작성할 때 AcceptanceTest
를 상속 받아 사용하면 되면 완성입니다.
class VotingSongControllerTest extends AcceptanceTest {
// ...
}
테스트 격리가 완료되어 모든 테스트가 통과하게 되었습니다!