Programming/Study

@BeforeEach vs @BeforeAll - 한 테스트 클래스에 여러 개의 테스트가 있으면 반드시 동시에 돌려봐야 하는 이유 / @TestInstace

JKROH 2023. 12. 8. 15:21
반응형

 테스트를 하기 전에, 뭔가 밑작업을 해놓고 싶을 수 있다. 그 때 사용하는 대표적인 애너테이션 두 개가 바로 @BeforeEach와 @BeforeAll이다.

 

이름만 봐도 알 수 있듯이, @BeforeEach는 각 테스트 메서드가 수행되기 전에 매 번 수행되고, @BeforeAll은 전체 메서드가 수행되기 전에 한 번 수행된다. 

 

 이번에 Spring Security 통합 테스트를 진행하면서, @BeforeAll을 사용해 볼 기회가 생겨 기록으로 남긴다.

 

 기존에 사용하던 테스트 set up 코드는 아래와 같았다.

@BeforeEach
void setUp(){
    String email = "jk@gmail.com";
    String password = PasswordEncoderFactories.createDelegatingPasswordEncoder().encode( "123");
    String name = "JKROH";
    String phoneNumber = "010-1111-2222";

    Member member = Member.builder()
            .email(email)
            .password(password)
            .name(name)
            .phoneNumber(phoneNumber)
            .roles(List.of("BARBER", "CUSTOMER"))
            .build();

    Member createdMember = memberCommandService.createMember(member);

    FreeBoard freeBoard = FreeBoard.builder()
            .title("title")
            .content("content")
            .build();

    createdMember.writeFreeBoard(freeBoard);
    freeBoardCommandService.create(freeBoard);
}

 

 @BeforeEach를 이용해 테스트 수행 전에 게시글과 회원을 등록시켜놓는다. 테스트를 하나씩 눌러 수행할 때는 문제가 없다. 그런데 테스트 클래스의 모든 테스트를 동시에 수행하면 다음과 같은 문제가 발생한다.

NonUniqueResultException 발생

 

 예외가 발생하는 테스트 코드는 다음과 같다.

@Test
@DisplayName("인증된 사용자가 등록 요청을 보내면 성공한다.")
@WithUserDetails(value = "jk@gmail.com", setupBefore = TestExecutionEvent.TEST_EXECUTION)
void testAuthorizedUserRequestAuthorizedMethod() throws Exception {
    String testTitle = "title";
    String testContent = "content";

    FreeBoardDto.Post testPost = new FreeBoardDto.Post();
    testPost.title = testTitle;
    testPost.content = testContent;

    String content = gson.toJson(testPost);
    mockMvc.perform(
            post("/free-board")
                    .accept(MediaType.APPLICATION_JSON)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(content)
    ).andExpect(status().isCreated());
}

 

 처음에는 테스트가 실패해서 당황스러웠는데, 예외 발생 원인을 보고 바로 납득해버렸다. 나는 Security의 username으로 이메일을 사용하는데, @BeforeEach가 붙은 메서드에서 같은 이메일의 계정 3개를 등록시켜버리니, 당연히 발생하는 에러였다. @WithUserName은 직접 Credential 저장소를 뒤져서 계정을 가져오는데, 아니 같은 이메일이 세 갠데 누가 누군지 어떻게 아냐고요.

 

 테스트 전체를 수행하기 전에 딱 한 번만 수행되는 메서드를 작성하는 방법을 알아내야했다. @BeforeAll을 찾는건 어렵지 않았다. IDE에서 그냥 보여주니까. 문제는, 이걸 써 본 적이 없다는 거였다. @BeforeAll 사용에는 한 가지 아주 큰 제약이 있다.

  • @BeforeAll이 적용된 메서드는 static하게 선언해야한다.

 생각해보면 이유는 간단하다. '일단 모든 테스트가 수행되기 전에 해당 메서드를 먼저 수행해야 하니까'. 테스트 메서드가 수행된다는 건 해당 클래스의 인스턴스가 생성되고, 인스턴스를 통해 테스트 메서드를 호출해 수행시킨다는 것이다. 그런데, 만약 인스턴스가 제대로 생성되기 어려운 상황이라면 어떨까? @BeforeAll을 수행하기도 어려울 것이다.

 

 기본적으로 자바의 정적 멤버들은 JVM의 Method Area에 올라가고, 인스턴스는 Heap영역에 올라간다. Method Area에 정적 요소들 은 '동적으로 생성되는 인스턴스 생성과 무관하게 정적으로 존재해야 하기 때문에' 먼저 올리는 것이다. @BeforeAll과 마찬가지로 보이지 않는가? '동적으로 실행되는 테스트 메서드와 무관하게 일단 수행되어야 한다' 따라서 @BeforeAll은 static하게 선언되어야 한다.

 

 그런데, static한 메서드에서 사용하기 위한 변수나 객체는 모두 static해야 한다. 그런데 static한 멤버들은 @Autowired를 수행할 수 없다. 위의 이유와 마찬가지다. static 멤버들은 미리미리 생성되어서 Runtime Data Area에 올라가야한다. 그럼 static한 @BeforeAll을 사용하려면 거기 사용되는 애들을 다 만들어줘야 하고... 그럼 또 구조를 엄청 바꿔가면서 해야 하고... 실제로 1시간 정도 이렇게 고생했다.

 

 사실 해결책은 간단하다. @TestInstace(TestInstace.LifeCycle.PER_CLASS) 애너테이션을 테스트 클래스에 붙여주면 된다. 얘가 뭔데 이 문제를 해결해줄까?

 

 기본적으로 테스트 클래스의 인스턴스는 각 테스트 메서드가 수행되기 전에 매 번 생성된다. 예를 들어, 다음과 같은 테스트 코드가 있다고 해보자.

class AdditionTest {

    private int sum = 1;

    @Test
    void addingTwoReturnsThree() {
        sum += 2;
        assertEquals(3, sum);
    }

    @Test
    void addingThreeReturnsFour() {
        sum += 3;
        assertEquals(4, sum);
    }
}

 

 이 테스트 클래스를 통째로 수행하면 두 번째 테스트에서 sum은 6이 되고, 테스트는 실패해야 할 것 같다. 그러나 실제로는 둘 다 통과한다. 이유는 테스트 클래스의 인스턴스가 각 테스트가 수행될 때마다 새롭게 생성되고 따라서 sum은 매번 1로 초기화되기 때문이다. 이 설정이 기본적인 테스트 인스턴스의 설정이며, 이는 @TestInstance(TestInstance.LifeCycle.PER_METHOD)로 해당 설정이 default로 설정되어있다.

 

 여기까지만 봐도 @TestInstace(TestInstace.LifeCycle.PER_CLASS)를 붙이면 @BeforeAll이 static이 아니어도 되는 이유를 유추할 수 있다. @BeforeAll은 딱 한 번만 수행되어야 한다. 그런데 기본 설정이 매 번 인스턴스를 만드는 것이라면 공유 자원이 매 번 생성되는 환경(PER_METHOD)에서는 적절한 테스트가 수행되지 않을 것이다. 미리 공유 자원에 대한 설정은 마치고, 공유되지 않는 자원에 대해서 인스턴스를 만들어서 수행해야 할 것이다. 따라서 전체 메서드에서 공유하는 @BeforeAll 설정은 static하게 설정되어야 한다.

 

 그런데 TestInstace의 생명 주기가 Class 단위라면 이야기가 다르지 않을까? 인스턴스가 클래스 전체의 메서드를 수행하기까지 딱 한 번만 생성된다면, 굳이 static하게 먼저 올려줄 필요가 있을까? 아니다. 그냥 인스턴스가 만들어지고 해당 메서드를 딱 한 번 수행하고 나머지 메서드들을 순서대로 수행하면 된다. 왜? 테스트 인스턴스가 전체 메서드를 수행할 때까지 살아있으니까.

 

 아니 그럼 애초에 당신이 말 한 '테스트 인스턴스와 무관하게 실행되어야 하니까 static해야 된다는 건 뭔데요?' 라고 생각할 수도 있다. 한 번 이렇게 생각해보자. 모든 테스트 메서드가 통과하지 않으면 그 테스트는 실패한다고 말이다. 이 경우가 바로 생명 주기가 Class단위인 경우 아닌가? 하나의 테스트만 실패해도 실패한 테스트가 되는데 굳이 설정을 정적으로 올려야할까? 정적으로 올리는 이유는 '테스트 수행 시마다 인스턴스가 생기고, 어떤 인스턴스가 생성되지 않았을 때도 일단 @BeforeAll은 수행되어야 하기 때문'인데, '애초에 하나의 인스턴스만 만들어서 순차적으로 모든 테스트를 수행'하면 굳이 정적으로 올릴 필요가 있겠냐는 말이다. 

 

 @BeforeAll을 사용하는 이유를 생각하면 같은 결론이 나온다. @BeforeAll을 사용하는 이유는 서로 다른 테스트에 같은 설정을 적용한다는 것인데, 같은 설정을 적용한다는 것은 서로 다른 테스트가 공유하는 자원에 어떤 설정을 가한다는 것이다. 굳이 얘를 정적으로 올릴 이유가 없다. 그냥 한 번 딱 수행하고 다시 수행하지 않으면 그만이다. 결국 테스트 인스턴스의 생명 주기를 클래스 단위로 설정하면 static하게 올릴 필요가 없다.

 

 같은 이유로 @AfterAll을 사용할 때도 테스트 인스턴스의 생명주기를 클래스 단위로 설정해주면 굳이 static하게 설정하지 않아도 된다.

 

참고 자료

- 밸덩 : @TestInstance Annotation in JUnit 5

https://www.baeldung.com/junit-testinstance-annotation

- 밸덩 : @BeforeAll and @AfterAll in Non-Static Methods

https://www.baeldung.com/java-beforeall-afterall-non-static

 

반응형