본문으로 바로가기
TaeyoungKim.dev

JUnit 테스트는 각각 통과하는데 전체 실행에서 실패하는 이유: static 저장소와 @AfterEach

자바/스프링작성 약 3분 읽기TaeyoungKim
LinkedInX

회원 저장 테스트를 하나씩 돌리면 둘 다 통과한다. 함께 돌리면 두 번째에서 duplicate: kim이 난다. 테스트가 서로 눈치를 보는 걸까? 두 테스트가 같은 메모리 저장소를 공유하고 있을 가능성부터 살펴보자.

메모리 리포지토리 테스트가 왜 서로 영향을 줄까?

초기 개발에서는 DB 대신 Map에 회원을 저장하는 구현을 만들 수 있다. 아래 예제의 store는 static이다. 따라서 new MemoryRepository()로 객체를 두 개 만들어도 저장 공간은 하나다.

java
import java.util.HashMap;
import java.util.Map;

class MemoryRepository {
    private static final Map<String, Integer> store = new HashMap<>();

    void save(String name) {
        if (store.containsKey(name)) {
            throw new IllegalStateException("duplicate: " + name);
        }
        store.put(name, store.size() + 1);
    }

    int count() { return store.size(); }
    void clear() { store.clear(); }
}

첫 테스트가 kim을 저장한 뒤 비우지 않으면, 다음 테스트는 새 객체로 시작해도 같은 이름을 본다. new는 객체를 새로 만들었지만 static 필드까지 새로 만들지는 않는다. 그림에서 두 테스트가 같은 상자를 가리키는 이유다.

새 객체를 만들었는데도 중복 오류가 날까?

같은 예제를 순서대로 실행하면 상태가 남는 지점을 볼 수 있다.

java
class Demo {
    public static void main(String[] args) {
        MemoryRepository first = new MemoryRepository();
        first.save("kim");
        System.out.println(first.count());

        MemoryRepository second = new MemoryRepository();
        try {
            second.save("kim");
        } catch (IllegalStateException error) {
            System.out.println(error.getMessage());
        }

        first.clear();
        second.save("kim");
        System.out.println(second.count());
    }
}
text
1
duplicate: kim
1

두 번째 save는 중복 예외를 던지고, 저장소를 비운 뒤에는 같은 이름을 다시 저장할 수 있다. JUnit Jupiter는 기본적으로 테스트 메서드마다 테스트 클래스 객체를 새로 만들지만, static 상태까지 초기화하지는 않는다. 그래서 '테스트 객체가 새것'과 '테스트 데이터가 새것'은 다른 말이다. JUnit의 테스트 인스턴스 수명 설명도 이 기본 동작을 명시한다.

@AfterEach로 무엇을 초기화해야 할까?

이처럼 공유 저장소를 유지해야 하는 테스트라면 각 테스트가 끝날 때 저장소를 비워, 다음 테스트가 앞선 데이터에 기대지 않게 할 수 있다.

java
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

class MemoryRepositoryTest {
    private final MemoryRepository repository = new MemoryRepository();

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

    @Test
    void savesMember() {
        repository.save("kim");
        assertEquals(1, repository.count());
    }

    @Test
    void alsoStartsEmpty() {
        repository.save("kim");
        assertEquals(1, repository.count());
    }
}

@AfterEach는 각 테스트 뒤에 실행되는 JUnit Jupiter 훅이다. 두 테스트 모두 kim을 저장하지만, 각 테스트가 끝난 뒤 clear()가 호출되면 다음 테스트는 빈 저장소에서 시작한다.

테스트 격리는 clear만으로 충분할까?

가능하다면 더 단순한 방법은 테스트마다 독립된 저장소 인스턴스를 만드는 것이다. store에서 static을 빼고 각 테스트가 새 MemoryRepository를 사용하면 clear()를 잊어서 생기는 간섭을 줄일 수 있다. 반대로 Spring 컨텍스트가 공유하는 빈이나 실제 DB를 사용하는 테스트라면, 무엇을 새로 만들고 무엇을 함께 쓰는지 확인해야 한다.

공유된 static Map을 여러 테스트가 동시에 읽고 지운다면 @AfterEach만으로 격리를 보장할 수 없다. 한 테스트의 정리가 다른 테스트의 데이터를 지울 수 있기 때문이다. 병렬 실행이나 실제 DB 테스트에는 저장소 분리·테스트 데이터 범위·트랜잭션 경계를 별도로 설계하자. 메모리 예제의 clear()를 운영 데이터 저장소에 그대로 적용해서는 안 된다.

핵심 요약

단독 테스트는 통과하고 전체 실행에서만 실패한다면, 먼저 테스트 사이에 남는 상태를 찾자. static 저장소는 새 객체를 만들어도 공유된다. @AfterEach는 공유된 테스트 데이터를 매번 정리하는 방법이지만, 가장 깔끔한 출발점은 테스트마다 독립된 저장소를 쓰는 것이다.

작성자

TaeyoungKim

기초 개념을 구현과 검증, 실제 운영 판단까지 연결해 기록합니다.

#Java#Spring#JUnit#@AfterEach#테스트 격리

함께 읽으면 좋은 글