개요
본 프로젝트에서는 여러 테스트를 진행하고 있고 그 중에 RestAssured를 활용해서 실제 Servlet 환경에서 E2E 테스트를 하고자 한다
@SpringBootTest → webEnvironment
일반적으로 통합테스트를 위해서 적용하는 @SpringBootTest는 WebEnvironment 기본값이 WebEnvironment.MOCK으로 지정되어 있다
MOCK
- WebApplicationContext를 생성하긴 하지만 실제 Servlet Container가 아닌 Mock Servlet Container를 제공한다
- 테스트 목적인 실제 Servlet 환경에서의 E2E 테스트가 불가능하기 때문에 활용 X
NONE
should not run as a web application + should not start an embedded web server
- E2E 테스트를 위한 환경 자체를 제공하지 않기 때문에 사용할 필요가 없다
RANDOM_PORT & DEFINED_PORT
- WebApplicationContext와 함께 실제 Servet Container를 띄운다
- RANDOM_PORT → 랜덤한 포트를 listen + 주로 @LocalServerPort를 통해서 포트 주입
- DEFINED_PORT → properties or yml에 지정한 포트를 listen (기본 8080)
테스트 목적에 부합
@Tag("Acceptance")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith({
MySqlTestContainersExtension.class,
RedisTestContainersExtension.class
})
@Import(ExternalApiConfiguration.class)
public abstract class AcceptanceTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
}
RANDOM_PORT + @Transactional
문서에 나와있듯이 RANDOM_PORT / DEFINED_PORT를 활용해서 실제 Servlet Container 환경을 띄운다면 @Transactional을 적용한다고 하더라도 서버 자체가 별도의 쓰레드로 Spring Container를 실행하기 때문에 테스트가 격리되지 않을 수 있다
그렇기 때문에 필자는 @Transactional이 아니라 실제 DB를 Truncate하기 위한 컴포넌트를 구현하였다
@Component
public class DatabaseCleaner {
private final EntityManager entityManager;
private final List<String> tableNames;
public DatabaseCleaner(final EntityManager entityManager) {
this.entityManager = entityManager;
this.tableNames = entityManager.getMetamodel()
.getEntities()
.stream()
.map(Type::getJavaType)
.map(javaType -> javaType.getAnnotation(Table.class))
.map(Table::name)
.collect(Collectors.toList());
}
@Transactional
public void cleanUpDatabase() {
entityManager.flush();
entityManager.createNativeQuery("SET foreign_key_checks = 0").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
}
entityManager.createNativeQuery("SET foreign_key_checks = 1").executeUpdate();
}
}
이렇게 구현한 DatabaseCleaner를 Callback을 통해서 테스트 진행간에 적용함으로써 테스트간 격리를 수행할 수 있다
스터디 조회 시나리오 테스트 최적화
일반적인 테스트는 특정 테스트 클래스 내부적으로 각 시나리오들에 대한 격리가 진행되어야 한다
하지만 스터디 조회 테스트의 경우 다음과 같이 진행할 수 있다
- 테스트 클래스 시작 전에 더미 데이터 Insert
- 모든 조회 시나리오 테스트 진행...
- 전부 테스트한 후 DB Clear
- 동일한 더미 데이터에 대한 조건별 조회이기 때문에 Nested Test별로 Clear는 필요하지 않다고 판단
1. AfterEachCallback을 통한 테스트별 데이터 리셋
이 요구사항을 보면 DatabaseCleaner를 각 테스트별로 끝난 후에 실행되도록 적용하면 매 테스트마다 오랜 시간이 걸리는 더미 데이터 Insert를 진행해야 한다
public class DatabaseCleanerEachCallbackExtension implements AfterEachCallback {
@Override
public void afterEach(final ExtensionContext context) {
final DatabaseCleaner databaseCleaner = SpringExtension.getApplicationContext(context).getBean(DatabaseCleaner.class);
databaseCleaner.cleanUpDatabase();
}
}
@ExtendWith(DatabaseCleanerEachCallbackExtension.class)
@DisplayName("[Acceptance Test] 스터디 조회 관련 기능")
public class StudySearchAcceptanceTest extends AcceptanceTest {
private String hostAccessToken;
private Map<StudyFixture, Long> studies;
private List<Long> members;
@BeforeEach
void setUp() {
hostAccessToken = JIWON.회원가입_후_Google_OAuth_로그인을_진행한다().token().accessToken();
studies = Stream.of(RABBITMQ, OOP, REDIS, JSP, TOEIC, LINE_INTERVIEW)
.collect(Collectors.toMap(
study -> study,
study -> study.스터디를_생성한다(hostAccessToken)
));
members = Stream.of(DUMMY1, DUMMY2, DUMMY3, DUMMY4, DUMMY5)
.map(MemberFixture::회원가입을_진행한다)
.toList();
final List<String> participantAccessTokens = Stream.of(DUMMY1, DUMMY2, DUMMY3, DUMMY4, DUMMY5)
.map(MemberFixture::로그인을_진행한다)
.map(AuthMember::token)
.map(AuthToken::accessToken)
.toList();
for (StudyFixture fixture : studies.keySet()) {
for (int i = 0; i < members.size(); i++) {
스터디_참여_신청을_한다(participantAccessTokens.get(i), studies.get(fixture));
스터디_신청자에_대한_참여를_승인한다(hostAccessToken, studies.get(fixture), members.get(i));
}
}
// Favorite
스터디를_찜_등록한다(participantAccessTokens.get(0), studies.get(RABBITMQ)); // studies0 -> duumy1, dummy3, dummy5
스터디를_찜_등록한다(participantAccessTokens.get(2), studies.get(RABBITMQ));
스터디를_찜_등록한다(participantAccessTokens.get(4), studies.get(RABBITMQ));
스터디를_찜_등록한다(participantAccessTokens.get(1), studies.get(REDIS)); // studies2 -> dummy2, dummy4
스터디를_찜_등록한다(participantAccessTokens.get(3), studies.get(REDIS));
스터디를_찜_등록한다(participantAccessTokens.get(1), studies.get(TOEIC)); // studies4 -> dummy2, dummy3, dummy5
스터디를_찜_등록한다(participantAccessTokens.get(2), studies.get(TOEIC));
스터디를_찜_등록한다(participantAccessTokens.get(4), studies.get(TOEIC));
스터디를_찜_등록한다(participantAccessTokens.get(0), studies.get(LINE_INTERVIEW)); // studies5 -> dummy1, dummy2, dummy3, dummy4, dummy5
스터디를_찜_등록한다(participantAccessTokens.get(1), studies.get(LINE_INTERVIEW));
스터디를_찜_등록한다(participantAccessTokens.get(2), studies.get(LINE_INTERVIEW));
스터디를_찜_등록한다(participantAccessTokens.get(3), studies.get(LINE_INTERVIEW));
스터디를_찜_등록한다(participantAccessTokens.get(4), studies.get(LINE_INTERVIEW));
// Graduate
스터디를_졸업한다(participantAccessTokens.get(0), studies.get(RABBITMQ)); // studies0 -> dummy1, dummy2
스터디_리뷰를_작성한다(participantAccessTokens.get(0), studies.get(RABBITMQ));
스터디를_졸업한다(participantAccessTokens.get(1), studies.get(RABBITMQ));
스터디_리뷰를_작성한다(participantAccessTokens.get(1), studies.get(RABBITMQ));
스터디를_졸업한다(participantAccessTokens.get(1), studies.get(OOP)); // studies1 - dummy2
스터디_리뷰를_작성한다(participantAccessTokens.get(1), studies.get(OOP));
스터디를_졸업한다(participantAccessTokens.get(2), studies.get(REDIS)); // studies2 - dummy3
스터디_리뷰를_작성한다(participantAccessTokens.get(2), studies.get(REDIS));
스터디를_졸업한다(participantAccessTokens.get(1), studies.get(TOEIC)); // studies4 - dummy2, dummy4
스터디_리뷰를_작성한다(participantAccessTokens.get(1), studies.get(TOEIC));
스터디를_졸업한다(participantAccessTokens.get(3), studies.get(TOEIC));
스터디_리뷰를_작성한다(participantAccessTokens.get(3), studies.get(TOEIC));
스터디를_졸업한다(participantAccessTokens.get(0), studies.get(LINE_INTERVIEW)); // studies5 - dummy1, dummy5
스터디_리뷰를_작성한다(participantAccessTokens.get(0), studies.get(LINE_INTERVIEW));
스터디를_졸업한다(participantAccessTokens.get(4), studies.get(LINE_INTERVIEW));
스터디_리뷰를_작성한다(participantAccessTokens.get(4), studies.get(LINE_INTERVIEW));
}
// 테스트 시나리오...
}
추가적으로 본 프로젝트는 Flyway + TestContainers를 통해서 테스트 환경을 구축했기 때문에 테스트 속도가 더 느릴 수 밖에 없다
2. @AfterAll + DatabaseCleaner
- 테스트에 주입되는 DatabaseCleaner는 non-static이기 때문에 static으로 선언해야 하는 @AfterAll에 적용할 수 없다
그러면 주입되는 DatabaseCleaner를 static으로?
public class StudySearchAcceptanceTest extends AcceptanceTest {
@Autowired
private static DatabaseCleaner databaseCleaner;
@Test
void injection() {
System.out.println("주입 -> " + databaseCleaner);
}
}
이것은 사실 당연한 결과이다
클래스의 정적 멤버는 JVM 클래스 로더에 의해서 클래스 로딩이 되는 시점에 초기화된다
따라서 @Autowired는 런타임에 동적으로 Spring Bean간에 의존 관계를 주입하는 메커니즘이기 때문에 동작할 수 없는 것이다
물론 우회해서 주입하는 방법은 존재한다
(1) @PostConstruct
public class StudySearchAcceptanceTest extends AcceptanceTest {
private static DatabaseCleaner databaseCleaner;
@Autowired
private DatabaseCleaner helper;
@PostConstruct
private void help() {
databaseCleaner = helper;
}
@Test
void injection() {
System.out.println("주입 -> " + databaseCleaner);
}
}
(2) 생성자 @Autowired
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
public class StudySearchAcceptanceTest extends AcceptanceTest {
private static DatabaseCleaner databaseCleaner;
public StudySearchAcceptanceTest(final DatabaseCleaner databaseCleaner) {
this.databaseCleaner = databaseCleaner;
}
@Test
void injection() {
System.out.println("주입 -> " + databaseCleaner);
}
}
이런 방식으로 static field에 대한 주입은 가능하지만 너무 코드가 깔끔하지 않은 느낌이고 필자는 AfterAllCallback을 활용할 예정이다
3. AfterAllCallback
public class DatabaseCleanerAllCallbackExtension implements AfterAllCallback {
@Override
public void afterAll(final ExtensionContext context) {
if (context.getTestClass().isPresent()) {
final Class<?> currentClass = context.getTestClass().get();
if (isNestedClass(currentClass)) {
return;
}
}
final DatabaseCleaner databaseCleaner = SpringExtension.getApplicationContext(context).getBean(DatabaseCleaner.class);
databaseCleaner.cleanUpDatabase();
}
private boolean isNestedClass(final Class<?> currentClass) {
return !ModifierSupport.isStatic(currentClass) && currentClass.isMemberClass();
}
}
StudySearchAcceptanceTest라는 Root 테스트 클래스로부터 Nested로 각 시나리오 테스트가 존재한다
여기서 우리의 목적은 시나리오가 끝날때마다 초기화하는게 아니라 Root 테스트 클래스의 전체 테스트가 끝난 후 초기화하는 것이다
→ 그렇기 때문에 isNestedClass를 통해서 걸러내고 Root Level에서 cleanUp을 진행한다
@ExtendWith(DatabaseCleanerAllCallbackExtension.class)
@DisplayName("[Acceptance Test] 스터디 조회 관련 기능")
public class StudySearchAcceptanceTest extends AcceptanceTest {
private static String hostAccessToken;
private static Map<StudyFixture, Long> studies;
private static List<Long> members;
@BeforeAll
static void setUp() {
hostAccessToken = JIWON.회원가입_후_Google_OAuth_로그인을_진행한다().token().accessToken();
studies = Stream.of(RABBITMQ, OOP, REDIS, JSP, TOEIC, LINE_INTERVIEW)
.collect(Collectors.toMap(
study -> study,
study -> study.스터디를_생성한다(hostAccessToken)
));
members = Stream.of(DUMMY1, DUMMY2, DUMMY3, DUMMY4, DUMMY5)
.map(MemberFixture::회원가입을_진행한다)
.toList();
final List<String> participantAccessTokens = Stream.of(DUMMY1, DUMMY2, DUMMY3, DUMMY4, DUMMY5)
.map(MemberFixture::로그인을_진행한다)
.map(AuthMember::token)
.map(AuthToken::accessToken)
.toList();
for (StudyFixture fixture : studies.keySet()) {
for (int i = 0; i < members.size(); i++) {
스터디_참여_신청을_한다(participantAccessTokens.get(i), studies.get(fixture));
스터디_신청자에_대한_참여를_승인한다(hostAccessToken, studies.get(fixture), members.get(i));
}
}
// Favorite
스터디를_찜_등록한다(participantAccessTokens.get(0), studies.get(RABBITMQ)); // studies0 -> duumy1, dummy3, dummy5
스터디를_찜_등록한다(participantAccessTokens.get(2), studies.get(RABBITMQ));
스터디를_찜_등록한다(participantAccessTokens.get(4), studies.get(RABBITMQ));
스터디를_찜_등록한다(participantAccessTokens.get(1), studies.get(REDIS)); // studies2 -> dummy2, dummy4
스터디를_찜_등록한다(participantAccessTokens.get(3), studies.get(REDIS));
스터디를_찜_등록한다(participantAccessTokens.get(1), studies.get(TOEIC)); // studies4 -> dummy2, dummy3, dummy5
스터디를_찜_등록한다(participantAccessTokens.get(2), studies.get(TOEIC));
스터디를_찜_등록한다(participantAccessTokens.get(4), studies.get(TOEIC));
스터디를_찜_등록한다(participantAccessTokens.get(0), studies.get(LINE_INTERVIEW)); // studies5 -> dummy1, dummy2, dummy3, dummy4, dummy5
스터디를_찜_등록한다(participantAccessTokens.get(1), studies.get(LINE_INTERVIEW));
스터디를_찜_등록한다(participantAccessTokens.get(2), studies.get(LINE_INTERVIEW));
스터디를_찜_등록한다(participantAccessTokens.get(3), studies.get(LINE_INTERVIEW));
스터디를_찜_등록한다(participantAccessTokens.get(4), studies.get(LINE_INTERVIEW));
// Graduate
스터디를_졸업한다(participantAccessTokens.get(0), studies.get(RABBITMQ)); // studies0 -> dummy1, dummy2
스터디_리뷰를_작성한다(participantAccessTokens.get(0), studies.get(RABBITMQ));
스터디를_졸업한다(participantAccessTokens.get(1), studies.get(RABBITMQ));
스터디_리뷰를_작성한다(participantAccessTokens.get(1), studies.get(RABBITMQ));
스터디를_졸업한다(participantAccessTokens.get(1), studies.get(OOP)); // studies1 - dummy2
스터디_리뷰를_작성한다(participantAccessTokens.get(1), studies.get(OOP));
스터디를_졸업한다(participantAccessTokens.get(2), studies.get(REDIS)); // studies2 - dummy3
스터디_리뷰를_작성한다(participantAccessTokens.get(2), studies.get(REDIS));
스터디를_졸업한다(participantAccessTokens.get(1), studies.get(TOEIC)); // studies4 - dummy2, dummy4
스터디_리뷰를_작성한다(participantAccessTokens.get(1), studies.get(TOEIC));
스터디를_졸업한다(participantAccessTokens.get(3), studies.get(TOEIC));
스터디_리뷰를_작성한다(participantAccessTokens.get(3), studies.get(TOEIC));
스터디를_졸업한다(participantAccessTokens.get(0), studies.get(LINE_INTERVIEW)); // studies5 - dummy1, dummy5
스터디_리뷰를_작성한다(participantAccessTokens.get(0), studies.get(LINE_INTERVIEW));
스터디를_졸업한다(participantAccessTokens.get(4), studies.get(LINE_INTERVIEW));
스터디_리뷰를_작성한다(participantAccessTokens.get(4), studies.get(LINE_INTERVIEW));
}
// 테스트 시나리오...
}
AfterEachCallback | AfterAllCallback |
25s 464ms | 3s 156ms |