[Spring] JPA 환경에서 RestAssured 시나리오 테스트 최적화 (with AfterAllCallback)

개요

 

GitHub - sjiwon/study-with-me-be: 여기서 구해볼래? Backend Repository (Refactoring)

여기서 구해볼래? Backend Repository (Refactoring). Contribute to sjiwon/study-with-me-be development by creating an account on GitHub.

github.com

본 프로젝트에서는 여러 테스트를 진행하고 있고 그 중에 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

 

41. Testing

A few test utility classes are packaged as part of spring-boot that are generally useful when testing your application. TestRestTemplate is a convenience alternative to Spring’s RestTemplate that is useful in integration tests. You can get a vanilla temp

docs.spring.io

문서에 나와있듯이 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을 통해서 테스트 진행간에 적용함으로써 테스트간 격리를 수행할 수 있다

 

 

스터디 조회 시나리오 테스트 최적화

일반적인 테스트는 특정 테스트 클래스 내부적으로 각 시나리오들에 대한 격리가 진행되어야 한다

하지만 스터디 조회 테스트의 경우 다음과 같이 진행할 수 있다

  1. 테스트 클래스 시작 전에 더미 데이터 Insert
  2. 모든 조회 시나리오 테스트 진행...
  3. 전부 테스트한 후 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을 진행한다
 

BeforeAll / AfterAll callbacks of junit5 extension are executed for each nested test class. Is this expected?

I have a test class with multiple nested test classes inside. The outer test class uses an extension that implements BeforeAllCallback and AfterAllCallback. The methods of these interfaces are call...

stackoverflow.com

 

@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