Spring Boot 테스트 완벽 가이드: JUnit5 단위 테스트부터 TestContainers 통합 테스트까지

소프트웨어 개발 수명 주기(SDLC)에서 ‘테스트’가 차지하는 비중은 날이 갈수록 커지고 있습니다. 과거에는 일정에 쫓겨 “기능 구현만 되면 배포”하는 문화가 만연했지만, 이제는 서비스의 복잡도가 증가하고 배포 주기가 짧아지면서 테스트 코드 없는 배포는 곧 재앙을 의미하게 되었습니다. 버그를 발견하는 시점이 늦어질수록, 즉 개발 단계에서 발견하는 것보다 운영 단계에서 발견했을 때 수정 비용이 수십 배에서 수백 배까지 증가한다는 ‘비용 증가의 법칙’은 이미 널리 알려진 사실입니다.

하지만 많은 자바 스프링(Spring Boot) 개발자들이 여전히 테스트 코드 작성을 어려워하거나, 단순히 라인 커버리지(Line Coverage)를 채우기 위한 형식적인 테스트에 그치는 경우가 많습니다. “단위 테스트(Unit Test)만 짜면 되는 것 아닌가?”, “DB 테스트는 로컬 H2로 충분하지 않나?”라는 의문들이 해소되지 않은 채 불안한 코드가 프로덕션으로 나아갑니다.

오늘은 견고한 애플리케이션을 만들기 위한 스프링 부트의 테스트 전략을 밑바닥부터 훑어보려 합니다. 가장 기초가 되는 JUnit 5와 Mockito를 활용한 단위 테스트 작성법부터, 실제 데이터베이스 환경을 완벽하게 모사하여 신뢰도를 극대화하는 TestContainers 기반의 통합 테스트까지, 실무에서 반드시 알아야 할 모범 사례(Best Practices)를 깊이 있게 다뤄보겠습니다. 이 글이 여러분의 코드를 ‘불안한 코드’에서 ‘믿을 수 있는 코드’로 바꿔주는 이정표가 되기를 바랍니다.


테스트의 견고한 기반, JUnit 5와 단위 테스트 (Unit Testing)

단위 테스트는 소프트웨어의 가장 작은 단위(주로 메서드나 클래스)가 의도한 대로 동작하는지 검증하는 과정입니다. 외부 시스템(데이터베이스, 네트워크, 파일 시스템 등)에 의존하지 않고 격리된 상태에서 로직 자체를 검증하기 때문에 실행 속도가 매우 빠르고, 문제 발생 시 원인을 즉시 파악할 수 있다는 강력한 장점이 있습니다.

자바 진영의 표준 테스트 프레임워크인 JUnit 5는 이전 버전인 JUnit 4와 달리 모듈화 된 아키텍처(Jupiter, Platform, Vintage)를 가지고 있어 훨씬 유연하고 강력한 기능을 제공합니다.

  1. 가독성을 높이는 @DisplayName:과거에는 테스트 메서드 이름을 testCreateUserSuccess()와 같이 영어로 길게 작성해야 했지만, JUnit 5에서는 @DisplayName(“회원가입 성공 시 DB에 데이터가 저장된다”)와 같이 한글로 명확한 의도를 기술할 수 있게 되었습니다. 이는 테스트 결과 리포트를 읽는 동료 개발자나 QA 엔지니어에게 큰 도움을 줍니다.
  2. 생명주기 관리 (@BeforeEach, @AfterAll):테스트의 독립성을 보장하기 위해 각 테스트 실행 전후에 데이터를 초기화하거나 자원을 해제하는 작업이 필수적입니다. @BeforeEach는 각각의 테스트 메서드가 실행되기 직전에 매번 호출되어 깨끗한 상태(Clean State)를 만들어주고, @AfterAll은 테스트 클래스 전체가 종료된 후 무거운 리소스를 정리할 때 유용하게 사용됩니다.

Mockito를 활용한 의존성 격리와 Stubbing

스프링 프레임워크의 핵심은 DI(의존성 주입)를 통한 객체 간의 협력입니다. 하지만 단위 테스트 입장에서는 이것이 걸림돌이 됩니다. 예를 들어 UserService를 테스트하고 싶은데, 이 서비스가 UserRepository를 통해 DB에 접근하고 EmailSender를 통해 메일을 발송한다면, 순수한 UserService의 로직만 검증하기가 어려워집니다.

이때 등장하는 것이 바로 Mockito와 같은 Mocking 프레임워크입니다. Mockito는 실제 객체 대신 가짜 객체(Mock Object)를 만들어 주입함으로써, 테스트 대상을 외부 의존성으로부터 완벽하게 격리시킵니다.

  • @Mock vs @InjectMocks:가장 많이 헷갈리는 개념입니다. @Mock은 가짜 객체를 생성하는 어노테이션이고, @InjectMocks는 테스트 대상 객체(여기서는 UserService)를 생성하면서 내부에 선언된 의존성(UserRepository, EmailSender) 자리에 앞서 만든 @Mock 객체들을 자동으로 주입해 주는 역할을 합니다.
  • BDD 스타일의 Stubbing (Given-When-Then):”어떤 상황이 주어졌을 때(Given), 행동을 하면(When), 결과가 나온다(Then)”라는 BDD(Behavior Driven Development) 패턴은 테스트의 가독성을 비약적으로 높여줍니다. Mockito의 given(userRepository.findById(1L)).willReturn(Optional.of(user));와 같은 구문은 “DB에서 ID가 1인 유저를 찾으면 무조건 준비된 유저 객체를 반환하라”고 가짜 객체를 학습(Stubbing)시키는 것입니다. 이를 통해 DB가 셧다운 되든 말든 우리는 비즈니스 로직 검증에만 집중할 수 있습니다.

통합 테스트(Integration Test)와 인메모리 DB의 함정

단위 테스트가 숲이 아닌 나무를 보는 것이라면, 통합 테스트는 나무들이 모여 이루는 숲을 보는 것입니다. 컨트롤러로 요청이 들어와서 서비스의 비즈니스 로직을 거쳐 리포지토리를 통해 실제 DB에 저장되고, 다시 응답이 나가는 전체 흐름(Flow)을 검증해야 합니다.

많은 프로젝트에서 통합 테스트를 위해 H2와 같은 인메모리(In-memory) DB를 사용합니다. 설정이 간편하고 속도가 빠르기 때문입니다. 하지만 여기에는 치명적인 함정이 숨어 있습니다. 바로 ‘운영 환경과의 불일치‘입니다.

운영 서버는 MySQL이나 PostgreSQL을 사용하는데 테스트는 H2로 돌린다면, 다음과 같은 문제 상황에 직면할 수 있습니다.

  1. SQL 문법 차이: MySQL 전용 함수(예: DATE_FORMATJSON_TYPE)나 방언(Dialect)을 사용한 쿼리가 H2에서는 문법 에러를 발생시킬 수 있습니다.
  2. 격리 수준 및 락(Lock) 동작의 차이: 동시성 이슈를 해결하기 위해 비관적 락(Pessimistic Lock)을 걸었을 때, H2와 실제 DB의 동작 방식이 달라 테스트에서는 통과했으나 운영에서는 데드락(Deadlock)이 발생하는 경우가 비일비재합니다.
  3. 제약 조건 처리: 데이터 타입의 범위나 Unique 제약 조건 처리 방식이 미묘하게 달라 버그를 놓칠 수 있습니다.

결국 “내 로컬에서는 되는데요?”라는 변명이 통하지 않게 하려면, 테스트 환경도 운영 환경과 최대한 동일하게(Production-like) 맞춰야 합니다.

TestContainers, 도커(Docker)를 품은 테스트 혁명

인메모리 DB의 한계를 극복하고, 로컬에서도 실제 운영 DB와 똑같은 환경을 구축해 주는 구세주가 바로 TestContainers입니다.

TestContainers는 자바 코드로 도커(Docker) 컨테이너를 제어할 수 있게 해주는 라이브러리입니다. 테스트 코드가 실행될 때 도커 데몬에 명령을 내려 실제 MySQL, Redis, Kafka 등의 이미지를 다운로드하고 컨테이너를 실행시킵니다. 그리고 테스트가 끝나면 컨테이너를 자동으로 파괴하여 깔끔하게 정리해 줍니다.

  1. Wait Strategy의 중요성:컨테이너가 Started 상태가 되었다고 해서 내부의 DB 프로세스가 즉시 연결 가능한 것은 아닙니다. OS가 부팅되고 MySQL 서비스가 초기화되는 시간이 필요하기 때문입니다. TestContainers는 로그 메시지 감지, 포트 리스닝 확인, 헬스 체크(Health Check) 쿼리 실행 등 다양한 대기 전략(Wait Strategy)을 제공하여 “DB가 진짜로 준비될 때까지” 테스트 실행을 지연시켜 줍니다.
  2. Singleton Container 패턴으로 속도 최적화:TestContainers의 유일한 단점은 매 테스트 클래스마다 컨테이너를 띄우고 내리는 데 시간이 오래 걸린다는 점입니다. 이를 해결하기 위해 abstract 클래스에 static 필드로 컨테이너를 정의하고, 모든 통합 테스트 클래스가 이를 상속받게 하는 ‘싱글톤 컨테이너 패턴’을 사용합니다. 이렇게 하면 전체 테스트 슈트(Test Suite)가 실행되는 동안 컨테이너를 딱 한 번만 띄우고 재사용하므로 테스트 속도를 획기적으로 개선할 수 있습니다.
  3. @DynamicPropertySource를 통한 동적 설정:컨테이너가 뜰 때마다 호스트와 매핑되는 포트 번호가 무작위로 변경됩니다. 스프링 부트가 이 변경된 포트 번호를 알고 DB에 접속할 수 있도록, @DynamicPropertySource를 사용하여 application.yml의 spring.datasource.url 값을 동적으로 덮어씌워 주는 작업이 필요합니다.

코드로 보는 네 가지 테스트

여기서부터는 실제로 돌아가는 코드입니다. 아래 예제는 모두 Spring Boot 3.5.6 / JDK 21 환경에서 작성해 실행하고 통과를 확인한 것입니다. 테스트 대상은 회원 가입 로직 하나로 통일했습니다 — 이메일 중복을 검사하고, 저장한 뒤 환영 메일을 보냅니다.

@Service
public class UserService {
    private final UserRepository userRepository;
    private final EmailSender emailSender;

    public UserService(UserRepository userRepository, EmailSender emailSender) {
        this.userRepository = userRepository;
        this.emailSender = emailSender;
    }

    @Transactional
    public User register(String email, String name) {
        if (userRepository.existsByEmail(email)) {
            throw new DuplicateEmailException(email);
        }
        User saved = userRepository.save(new User(email, name));
        emailSender.sendWelcome(email);
        return saved;
    }
}

① 단위 테스트 — JUnit 5 + Mockito

DB도 메일 서버도 띄우지 않습니다. @Mock으로 가짜를 만들고 @InjectMocks로 주입합니다. MockitoExtension이 이 둘을 연결합니다.

@ExtendWith(MockitoExtension.class)
@DisplayName("UserService 단위 테스트")
class UserServiceTest {

    @Mock UserRepository userRepository;
    @Mock EmailSender emailSender;

    @InjectMocks UserService userService;

    @Test
    @DisplayName("새 이메일이면 저장하고 환영 메일을 보낸다")
    void register_success() {
        // given
        given(userRepository.existsByEmail("a@b.com")).willReturn(false);
        given(userRepository.save(any(User.class)))
                .willAnswer(inv -> inv.getArgument(0));

        // when
        User result = userService.register("a@b.com", "홍길동");

        // then
        assertThat(result.getEmail()).isEqualTo("a@b.com");
        then(emailSender).should().sendWelcome("a@b.com");
    }

    @Test
    @DisplayName("이미 있는 이메일이면 예외를 던지고 메일을 보내지 않는다")
    void register_duplicate() {
        given(userRepository.existsByEmail("a@b.com")).willReturn(true);

        assertThatThrownBy(() -> userService.register("a@b.com", "홍길동"))
                .isInstanceOf(DuplicateEmailException.class)
                .hasMessageContaining("a@b.com");

        then(userRepository).should(never()).save(any());
        then(emailSender).shouldHaveNoInteractions();
    }
}

두 번째 테스트가 이 예제의 핵심입니다. “예외가 났다”만 확인하고 끝내면 절반만 검증한 것입니다. 중복이면 저장도 안 되고 메일도 안 나가야 합니다. should(never())shouldHaveNoInteractions()일어나지 않아야 할 일을 명시적으로 검증합니다. 실무에서 사고는 대개 여기서 납니다 — 예외를 던지기 전에 메일을 먼저 보내버리는 순서 실수 같은 것들입니다.

import static org.mockito.BDDMockito.*; 를 쓰면 given·willReturn·then을 그대로 씁니다. Mockito.when/verify와 기능은 같지만 Given-When-Then 주석과 단어가 맞아 읽기 편합니다.

② 웹 계층 슬라이스 테스트 — @WebMvcTest

컨트롤러의 요청 매핑·직렬화·상태 코드만 확인합니다. 서비스 계층은 대체하므로 DB가 없어도 됩니다.

@WebMvcTest(UserController.class)
@DisplayName("UserController 슬라이스 테스트")
class UserControllerTest {

    @Autowired MockMvc mockMvc;

    @MockitoBean UserService userService;

    @Test
    @DisplayName("가입 성공 시 201과 본문을 반환한다")
    void register_created() throws Exception {
        given(userService.register("a@b.com", "홍길동"))
                .willReturn(new User("a@b.com", "홍길동"));

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                 {"email":"a@b.com","name":"홍길동"}
                                 """))
               .andExpect(status().isCreated())
               .andExpect(jsonPath("$.email").value("a@b.com"));
    }

    @Test
    @DisplayName("중복 이메일이면 409를 반환한다")
    void register_conflict() throws Exception {
        given(userService.register("a@b.com", "홍길동"))
                .willThrow(new DuplicateEmailException("a@b.com"));

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                 {"email":"a@b.com","name":"홍길동"}
                                 """))
               .andExpect(status().isConflict());
    }
}

@MockitoBean에 주의하세요. 예전 자료의 @MockBean은 Spring Boot 3.4에서 deprecated 됐고 4.0에서 제거됐습니다. 새로 쓰는 코드는 @MockitoBean(패키지 org.springframework.test.context.bean.override.mockito)을 씁니다.

③ 영속성 계층 슬라이스 테스트 — @DataJpaTest

리포지토리 쿼리 메서드만 확인합니다. @DataJpaTest는 각 테스트를 트랜잭션으로 감싸고 끝나면 롤백하므로 테스트끼리 데이터가 섞이지 않습니다.

@DataJpaTest
@DisplayName("UserRepository 슬라이스 테스트")
class UserRepositoryTest {

    @Autowired UserRepository userRepository;

    @Test
    @DisplayName("이메일로 조회한다")
    void findByEmail() {
        userRepository.save(new User("a@b.com", "홍길동"));

        assertThat(userRepository.findByEmail("a@b.com"))
                .isPresent()
                .get()
                .extracting(User::getName)
                .isEqualTo("홍길동");
    }

    @Test
    @DisplayName("각 테스트는 롤백되어 서로 영향을 주지 않는다")
    void isolated() {
        assertThat(userRepository.count()).isZero();
    }
}

④ 통합 테스트 — Testcontainers + 실제 PostgreSQL

Spring Boot 3.1부터 @ServiceConnection이 생겨서 접속 정보를 직접 매핑할 필요가 없어졌습니다. 예전에는 @DynamicPropertySource로 URL·계정을 하나씩 넘겨야 했는데, 이제 애너테이션 하나면 컨테이너의 접속 정보가 자동으로 연결됩니다.

@SpringBootTest
@Testcontainers
@DisplayName("실제 PostgreSQL 로 도는 통합 테스트")
class UserIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired UserService userService;
    @Autowired UserRepository userRepository;

    @MockitoBean EmailSender emailSender;   // 외부 발송은 대체한다

    @Test
    @DisplayName("중복 가입이 실제 DB 에서도 막힌다")
    void unique_constraint_is_real() {
        userService.register("a@b.com", "홍길동");

        assertThatThrownBy(() -> userService.register("a@b.com", "임꺽정"))
                .isInstanceOf(DuplicateEmailException.class);

        assertThat(userRepository.count()).isEqualTo(1);
    }
}

static이어야 하는 점이 중요합니다. 인스턴스 필드로 선언하면 테스트 메서드마다 컨테이너를 새로 띄워서 몇 분씩 걸립니다. static이면 클래스 단위로 한 번만 띄웁니다.

pom.xml에 필요한 의존성은 셋입니다.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>

직접 돌려보고 알게 된 것

위 네 가지를 한 프로젝트에 넣고 실행한 결과입니다.

[INFO] Tests run: 2 ... UserService 단위 테스트          -- 0.156 s
[INFO] Tests run: 2 ... UserRepository 슬라이스 테스트     -- 0.349 s
[INFO] Tests run: 2 ... UserController 슬라이스 테스트     -- 1.664 s
[INFO] Tests run: 2 ... 실제 PostgreSQL 로 도는 통합 테스트  -- 3.018 s
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

단위 테스트가 통합 테스트보다 약 19배 빠릅니다(0.156초 대 3.018초). 이 차이가 테스트 피라미드를 지켜야 하는 실질적인 이유입니다. 통합 테스트를 수백 개 쌓으면 CI가 감당하지 못합니다.

H2의 함정은 SQL 문법 차이가 아니었다

이 글 앞부분에서 인메모리 DB의 위험으로 SQL 문법 차이를 들었는데, 실제로 확인해 보니 지금은 대부분 재현되지 않습니다. H2 2.x와 PostgreSQL 16으로 같은 테스트를 돌려 비교한 결과입니다.

흔히 인용되는 차이H2 2.xPostgreSQL 16실제로 차이 있나
예약어를 테이블명으로 (user)거부거부없음 — 양쪽 다 실패
boolean 컬럼을 = 1 로 비교거부거부없음 — 양쪽 다 실패
나노초 타임스탬프 왕복123457000ns 로 반올림123457000ns 로 반올림없음 — 동일

H2 2.x가 예전 1.4.x보다 훨씬 엄격해졌고, Hibernate가 방언 차이를 상당 부분 흡수합니다. 그래서 문법 차이를 근거로 H2를 겁내는 건 지금은 과장입니다.

그런데 진짜 차이는 다른 곳에서 나왔습니다. Testcontainers 테스트를 처음 돌렸을 때 이 오류로 죽었습니다.

org.postgresql.util.PSQLException:
    ERROR: relation "users" does not exist

같은 엔티티로 @DataJpaTest(H2)는 통과했는데 실제 PostgreSQL에서는 테이블이 아예 없었습니다. 이유는 SQL이 아니라 설정입니다. Spring Boot는 H2를 임베디드 DB로 인식해 ddl-autocreate-drop으로 자동 설정합니다. 실제 DB에는 그 자동 판단이 적용되지 않습니다.

# src/test/resources/application.properties
# H2 는 임베디드로 인식되어 자동 생성되지만, 실제 DB 는 명시해야 한다
spring.jpa.hibernate.ddl-auto=create-drop

이게 “H2에서는 되는데 실제 DB에서는 안 된다”의 실제 모습입니다. 문법이 아니라 자동 설정이 다르게 걸리는 것입니다. 그리고 운영과 정말 같게 검증하려면 ddl-auto가 아니라 Flyway나 Liquibase 마이그레이션을 테스트에서도 그대로 실행해야 합니다. 그래야 스키마 정의 자체가 검증됩니다.

Spring Boot 4.0에서 달라지는 것

테스트 관련 변경이 특히 많아서, 4.0으로 올릴 계획이라면 미리 알아둘 만합니다.

3.x4.0
@MockBean / @SpyBean제거@MockitoBean / @MockitoSpyBean (테스트 클래스 필드에서만 사용 가능)
@SpringBootTest가 MockMvc 제공@AutoConfigureMockMvc 를 붙여야 함
@SpringBootTestTestRestTemplate 제공@AutoConfigureTestRestTemplate + spring-boot-resttestclient 필요
spring-security-test 만으로 @WithMockUserspring-boot-starter-security-test 필요

자세한 목록은 Spring Boot 4.0 마이그레이션 정리에 표로 모아 두었습니다.

정리

지금까지 스프링 부트 애플리케이션의 품질을 책임지는 테스트 전략에 대해 살펴보았습니다.

JUnit 5와 Mockito를 이용한 단위 테스트는 개발자에게 빠른 피드백을 제공하여 로직의 결함을 조기에 발견하게 해 줍니다. 이는 전체 테스트의 약 70% 이상을 차지해야 하는 근간입니다. 그리고 TestContainers를 활용한 통합 테스트는 인프라와의 상호작용 과정에서 발생할 수 있는 문제를 사전에 차단하여 배포의 두려움을 없애줍니다.

테스트 코드는 단순히 ‘작동 여부’를 확인하는 절차가 아닙니다. 미래의 나, 그리고 함께 일하는 동료들에게 “이 코드는 이런 의도로 작성되었으며, 이렇게 동작하는 것이 보장됩니다”라고 말하는 살아있는 문서(Living Documentation)입니다. 오늘 소개한 기술들을 여러분의 프로젝트에 하나씩 적용해 보십시오. 처음에는 설정하고 익숙해지는 데 시간이 걸리겠지만, 어느 순간 버그 수정에 들이는 야근 시간이 줄어들고, 리팩토링을 과감하게 시도할 수 있는 자신감을 얻게 될 것입니다. 테스트는 비용이 아니라, 가장 수익률 높은 투자입니다.

자주 묻는 질문

단위 테스트와 슬라이스 테스트, 통합 테스트를 어떤 비율로 써야 하나요?

정해진 숫자는 없지만 속도가 기준을 줍니다. 위 실측에서 단위 테스트가 통합 테스트보다 약 19배 빨랐습니다. 로직 분기는 단위 테스트로 촘촘히 덮고, 통합 테스트는 계층이 실제로 연결되는지 확인하는 대표 경로 몇 개로 제한하는 것이 CI 시간을 지키는 방법입니다.

Testcontainers가 느려서 부담스럽습니다.

컨테이너 필드를 static으로 선언했는지 먼저 확인하세요. 인스턴스 필드면 메서드마다 새로 띄웁니다. 그다음은 재사용입니다. ~/.testcontainers.propertiestestcontainers.reuse.enable=true를 넣고 컨테이너에 withReuse(true)를 주면 테스트 실행 사이에도 살아 있습니다. CI에서는 보통 끄고 로컬에서만 씁니다.

H2를 아예 쓰지 말아야 하나요?

아닙니다. @DataJpaTest로 쿼리 메서드가 의도대로 만들어지는지 확인하는 데는 H2가 충분히 빠르고 유용합니다. 위 실측에서도 0.349초였습니다. 다만 스키마 정의, 제약 조건, 트랜잭션 동작을 검증하는 테스트는 실제 DB로 돌려야 합니다. 둘을 나눠서 쓰는 것이 답입니다.

@MockBean을 쓰던 코드는 언제 바꿔야 하나요?

Spring Boot 3.4 이상이면 지금 바꿔도 됩니다. @MockitoBean이 이미 있습니다. 다만 @Configuration 클래스 안에서 @MockBean을 모아 쓰던 구조라면 단순 치환이 안 됩니다. 새 애너테이션은 테스트 클래스 필드에서만 동작하므로 설계를 함께 바꿔야 합니다.

이 글의 코드는 Spring Boot 3.5.6 / JDK 21 / PostgreSQL 16 환경에서 실행하고 통과를 확인한 것입니다(2026년 9월 기준). Spring Boot 4.0에서는 위 「Spring Boot 4.0에서 달라지는 것」 항목이 함께 적용됩니다.

함께 읽으면 좋은 글

“Spring Boot 테스트 완벽 가이드: JUnit5 단위 테스트부터 TestContainers 통합 테스트까지”에 대한 1개의 생각

댓글 남기기