콘텐츠 대표 이미지 - Spring Boot Test: 효과적인 통합 테스트 전략 🧪

Spring Boot Test: 효과적인 통합 테스트 전략 🧪

자바 개발자를 위한 테스트 마스터 가이드

Spring Boot TEST 통합 테스트

📌 안녕? 스프링 부트 테스트의 세계로 초대할게! 🚀

자바 개발자로서 코드를 작성하는 건 재밌지만, 그 코드가 제대로 동작하는지 확인하는 건 더 중요하지 않을까? 오늘은 Spring Boot에서 통합 테스트를 효과적으로 작성하는 방법에 대해 친구처럼 편하게 얘기해볼게! 이 글을 통해 테스트 코드 작성이 더 이상 지루한 작업이 아니라 개발의 즐거운 부분이 될 거야.

요즘 재능넷 같은 플랫폼에서도 테스트 코드 작성 능력을 갖춘 개발자의 수요가 늘고 있어. 실제로 재능넷에서 프로젝트를 의뢰할 때 "테스트 코드 포함"이라는 요구사항이 점점 늘어나고 있다고 해. 그만큼 중요해진 테스트, 함께 파헤쳐보자! 😊

🧩 목차

  1. Spring Boot 테스트 기초 이해하기
  2. 통합 테스트 vs 단위 테스트: 언제 무엇을 써야 할까?
  3. Spring Boot 테스트 환경 설정하기
  4. 효과적인 통합 테스트 작성 전략
  5. 데이터베이스 통합 테스트 전략
  6. REST API 통합 테스트 방법
  7. 테스트 데이터 관리 전략
  8. 테스트 성능 최적화 팁
  9. 실제 프로젝트에서의 테스트 사례
  10. 마무리 및 추가 학습 자료

1. Spring Boot 테스트 기초 이해하기 🌱

스프링 부트는 테스트를 정말 쉽게 만들어줘! 기본적으로 spring-boot-starter-test 의존성만 추가하면 테스트에 필요한 거의 모든 도구가 함께 제공된다고 보면 돼.

🔍 Spring Boot 테스트에 포함된 주요 라이브러리

  1. JUnit 5: 테스트 프레임워크의 기본! 테스트 실행을 담당해
  2. Spring Test: 스프링 애플리케이션 컨텍스트 테스트 지원
  3. AssertJ: 더 직관적인 assertion 라이브러리
  4. Hamcrest: 매처 라이브러리
  5. Mockito: 목 객체 생성 및 검증 라이브러리
  6. JSONassert: JSON assertion 라이브러리
  7. JsonPath: JSON 데이터 쿼리 라이브러리

이 모든 걸 따로 설치할 필요 없이 아래 의존성 하나면 끝! 😎


dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
        

이 의존성 하나로 테스트의 모든 준비는 끝! 이제 실제로 테스트 코드를 작성해볼 준비가 됐어.

2. 통합 테스트 vs 단위 테스트: 언제 무엇을 써야 할까? 🤔

테스트에는 크게 두 가지 유형이 있어. 단위 테스트와 통합 테스트! 이 둘의 차이점과 각각 언제 사용하면 좋을지 알아보자.

단위 테스트 vs 통합 테스트 단위 테스트 작은 코드 단위 테스트 빠른 실행 속도 외부 의존성 격리(Mock) 특정 메소드/클래스 검증 TDD에 적합 디버깅 용이 통합 테스트 여러 컴포넌트 상호작용 테스트 실제 환경과 유사한 테스트 DB, 외부 API 등 실제 사용 전체 흐름 검증 실행 속도 느림 배포 전 최종 검증에 적합

🔬 단위 테스트

단위 테스트는 코드의 가장 작은 단위(보통 메소드 수준)를 테스트해. 외부 의존성은 모두 목(Mock)으로 대체하고, 해당 메소드의 로직만 검증하는 거지.


@Test
void calculateDiscountTest() {
    // given
    PriceCalculator calculator = new PriceCalculator();
    Product product = new Product("노트북", 1000000);
    
    // when
    int discountedPrice = calculator.calculateDiscount(product, 10);
    
    // then
    assertEquals(900000, discountedPrice);
}
        

🔗 통합 테스트

통합 테스트는 여러 컴포넌트가 함께 잘 동작하는지 검증해. 실제 DB에 연결하거나, 실제 API를 호출하는 등 실제 환경과 유사한 조건에서 테스트를 진행하지.


@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void createOrderTest() throws Exception {
        // given
        String orderRequest = "{\"productId\":1,\"quantity\":2}";
        
        // when & then
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(orderRequest))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.orderId").exists());
    }
}
        

🤹‍♂️ 언제 무엇을 사용해야 할까?

단위 테스트는 빠른 피드백이 필요하거나 특정 로직을 검증할 때 사용해. 반면 통합 테스트는 컴포넌트 간 상호작용이나 전체 흐름을 검증할 때 사용하는 게 좋아.

실무에서는 두 가지를 적절히 섞어 사용하는 게 가장 효과적이야. 테스트 피라미드 원칙에 따라 단위 테스트를 많이, 통합 테스트를 적당히, E2E 테스트를 소량 작성하는 게 좋은 전략이지! 🏆

3. Spring Boot 테스트 환경 설정하기 ⚙️

스프링 부트에서 통합 테스트를 위한 환경을 설정해보자! 이 부분은 처음에만 조금 복잡해 보일 수 있지만, 한 번 설정해두면 계속 재사용할 수 있어.

🛠 기본 설정

먼저 build.gradle 또는 pom.xml에 필요한 의존성을 추가해야 해:


// Gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'com.h2database:h2'  // 테스트용 인메모리 DB
}
        

🧪 테스트 속성 파일 설정

테스트 환경에서만 사용할 설정을 src/test/resources/application.properties (또는 application.yml)에 추가해보자:


# 테스트용 DB 설정
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA 설정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# 로깅 레벨 설정
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
        

🧩 테스트 클래스 기본 구조

이제 기본적인 통합 테스트 클래스 구조를 살펴보자:


@SpringBootTest
@AutoConfigureMockMvc
class UserServiceIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @BeforeEach
    void setup() {
        // 각 테스트 전에 실행할 코드
        userRepository.deleteAll();
    }
    
    @Test
    void createUserTest() throws Exception {
        // 테스트 코드
    }
    
    @AfterEach
    void cleanup() {
        // 각 테스트 후에 실행할 코드
    }
}
        

🏷 주요 테스트 애노테이션

스프링 부트 테스트에서 자주 사용하는 애노테이션들을 알아보자:

  1. @SpringBootTest: 스프링 부트 애플리케이션 컨텍스트를 로드해 통합 테스트 환경을 제공해
  2. @WebMvcTest: 웹 레이어(컨트롤러)만 테스트할 때 사용해
  3. @DataJpaTest: JPA 컴포넌트만 테스트할 때 사용해
  4. @AutoConfigureMockMvc: MockMvc를 자동 구성해줘
  5. @MockBean: 스프링 컨텍스트에 모의 객체를 추가해
  6. @TestPropertySource: 테스트용 프로퍼티 파일을 지정해
  7. @Transactional: 각 테스트 메소드를 트랜잭션으로 감싸고 테스트 후 롤백해
  8. @ActiveProfiles: 특정 프로필을 활성화해

이런 애노테이션들을 적절히 조합하면 다양한 테스트 시나리오를 효과적으로 구성할 수 있어! 예를 들어, 컨트롤러만 테스트하고 싶다면 @WebMvcTest를, DB 관련 로직만 테스트하고 싶다면 @DataJpaTest를 사용하면 되지.

4. 효과적인 통합 테스트 작성 전략 📝

이제 실제로 통합 테스트를 작성하는 전략에 대해 알아보자! 좋은 테스트는 단순히 코드를 검증하는 것을 넘어서 코드의 문서 역할도 한다는 걸 기억해.

🎯 테스트 작성의 기본 원칙

  1. Given-When-Then 패턴 사용하기: 테스트 구조를 명확하게 해
  2. 테스트 메소드 이름을 명확하게 짓기: 무엇을 테스트하는지 이름만 봐도 알 수 있게
  3. 하나의 테스트는 하나의 동작만 검증하기: 테스트가 실패했을 때 원인을 쉽게 파악할 수 있어
  4. 테스트 간 의존성 없애기: 각 테스트는 독립적으로 실행할 수 있어야 해
  5. 실패 케이스도 테스트하기: 예외 상황도 제대로 처리되는지 확인해

🌟 효과적인 통합 테스트 예제

사용자 등록 API에 대한 통합 테스트를 작성해보자:


@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @BeforeEach
    void setup() {
        userRepository.deleteAll();
    }
    
    @Test
    void registerUser_WithValidData_ShouldCreateNewUser() throws Exception {
        // given
        UserRegistrationDto userDto = new UserRegistrationDto(
            "testuser", 
            "password123", 
            "test@example.com"
        );
        String userJson = objectMapper.writeValueAsString(userDto);
        
        // when
        ResultActions result = mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(userJson));
            
        // then
        result.andExpect(status().isCreated())
              .andExpect(jsonPath("$.username").value("testuser"))
              .andExpect(jsonPath("$.email").value("test@example.com"))
              .andExpect(jsonPath("$.id").exists());
              
        // 데이터베이스에도 제대로 저장되었는지 확인
        User savedUser = userRepository.findByUsername("testuser")
                                       .orElseThrow();
        assertEquals("test@example.com", savedUser.getEmail());
    }
    
    @Test
    void registerUser_WithDuplicateUsername_ShouldReturnError() throws Exception {
        // given
        // 먼저 사용자 생성
        User existingUser = new User("testuser", "password123", "existing@example.com");
        userRepository.save(existingUser);
        
        // 같은 사용자명으로 새 사용자 등록 시도
        UserRegistrationDto userDto = new UserRegistrationDto(
            "testuser", 
            "newpassword", 
            "new@example.com"
        );
        String userJson = objectMapper.writeValueAsString(userDto);
        
        // when
        ResultActions result = mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(userJson));
            
        // then
        result.andExpect(status().isConflict())
              .andExpect(jsonPath("$.message").value(containsString("이미 존재하는 사용자명입니다")));
    }
}
        

🔍 테스트 가독성 높이기

테스트 코드도 결국 코드야. 다른 개발자(또는 미래의 나)가 쉽게 이해할 수 있도록 가독성을 높이는 게 중요해!

  1. 헬퍼 메소드 활용하기: 반복되는 코드는 별도 메소드로 추출해
  2. 테스트 픽스처 활용하기: 테스트에 필요한 객체를 미리 준비해두기
  3. 명확한 assertion 메시지 작성하기: 테스트 실패 시 원인을 쉽게 파악할 수 있게

예를 들어, 위 코드를 더 가독성 있게 리팩토링해보자:


// 헬퍼 메소드 활용 예시
private UserRegistrationDto createUserDto(String username, String password, String email) {
    return new UserRegistrationDto(username, password, email);
}

private ResultActions performUserRegistration(UserRegistrationDto userDto) throws Exception {
    String userJson = objectMapper.writeValueAsString(userDto);
    return mockMvc.perform(post("/api/users")
        .contentType(MediaType.APPLICATION_JSON)
        .content(userJson));
}

@Test
void registerUser_WithValidData_ShouldCreateNewUser() throws Exception {
    // given
    UserRegistrationDto userDto = createUserDto("testuser", "password123", "test@example.com");
    
    // when
    ResultActions result = performUserRegistration(userDto);
        
    // then
    result.andExpect(status().isCreated())
          .andExpect(jsonPath("$.username").value("testuser"));
          
    // 데이터베이스 검증
    User savedUser = userRepository.findByUsername("testuser").orElseThrow();
    assertEquals("test@example.com", savedUser.getEmail(), "사용자 이메일이 일치하지 않습니다");
}
        

이렇게 헬퍼 메소드를 활용하면 테스트 코드의 중복을 줄이고 가독성을 높일 수 있어! 재능넷에서 프로젝트를 진행할 때도 이런 방식으로 테스트 코드를 작성하면 협업하는 개발자들이 코드를 더 쉽게 이해할 수 있을 거야. 😊

5. 데이터베이스 통합 테스트 전략 💾

데이터베이스와 연동된 통합 테스트는 실제 애플리케이션의 동작을 가장 잘 검증할 수 있어. 하지만 몇 가지 고려해야 할 점이 있지!

🗃 테스트용 데이터베이스 선택하기

테스트에 사용할 수 있는 데이터베이스 옵션들을 알아보자:

  1. 인메모리 DB (H2, HSQLDB): 가장 빠르고 설정이 간편해
  2. Testcontainers: 실제 DB와 동일한 환경을 Docker 컨테이너로 제공해
  3. 실제 개발 DB: 실제와 가장 유사하지만 테스트 간 격리가 어려워

🧹 테스트 데이터 관리하기

테스트 데이터를 어떻게 관리할지 전략을 세워보자:


@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @BeforeEach
    void setup() {
        // 테스트 데이터 준비
        User user1 = new User("user1", "password1", "user1@example.com");
        User user2 = new User("user2", "password2", "user2@example.com");
        
        entityManager.persist(user1);
        entityManager.persist(user2);
        entityManager.flush();
    }
    
    @Test
    void findByEmail_ShouldReturnUser() {
        // when
        Optional<User> foundUser = userRepository.findByEmail("user1@example.com");
        
        // then
        assertTrue(foundUser.isPresent(), "사용자를 찾을 수 없습니다");
        assertEquals("user1", foundUser.get().getUsername());
    }
    
    @Test
    void findByUsername_WhenUserDoesNotExist_ShouldReturnEmpty() {
        // when
        Optional<User> foundUser = userRepository.findByUsername("nonexistent");
        
        // then
        assertTrue(foundUser.isEmpty(), "존재하지 않는 사용자가 조회되었습니다");
    }
}
        

🔄 Testcontainers 활용하기

실제 DB와 동일한 환경에서 테스트하고 싶다면 Testcontainers를 활용해보자:


// build.gradle에 의존성 추가
testImplementation 'org.testcontainers:postgresql:1.17.3'
testImplementation 'org.testcontainers:junit-jupiter:1.17.3'
        

@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:14")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    @DynamicPropertySource
    static void registerPgProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", postgreSQLContainer::getUsername);
        registry.add("spring.datasource.password", postgreSQLContainer::getPassword);
    }
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    void setup() {
        userRepository.deleteAll();
    }
    
    @Test
    void createUser_ShouldSaveUserToDatabase() {
        // given
        UserDto userDto = new UserDto("testuser", "test@example.com");
        
        // when
        User createdUser = userService.createUser(userDto);
        
        // then
        assertNotNull(createdUser.getId());
        
        // DB에서 직접 조회해서 확인
        User savedUser = userRepository.findById(createdUser.getId()).orElseThrow();
        assertEquals("testuser", savedUser.getUsername());
        assertEquals("test@example.com", savedUser.getEmail());
    }
}
        

Testcontainers를 사용하면 실제 DB와 동일한 환경에서 테스트할 수 있어 더 신뢰성 높은 테스트가 가능해! 물론 테스트 실행 속도는 조금 느려질 수 있지만, 실제 환경과의 일관성을 위해 충분히 가치 있는 트레이드오프야.

💡 데이터베이스 테스트 팁

  1. @Transactional 활용하기: 각 테스트 후 자동으로 롤백되어 테스트 간 격리를 보장해
  2. 데이터 정리는 tearDown보다 setUp에서: 테스트 실패 시에도 다음 테스트에 영향이 없도록
  3. SQL 스크립트 활용하기: @Sql 애노테이션으로 테스트 데이터 초기화
  4. 페이징, 정렬 등 복잡한 쿼리도 테스트하기: 실제 사용 패턴을 반영한 테스트 작성

6. REST API 통합 테스트 방법 🌐

REST API를 테스트하는 것은 스프링 부트 애플리케이션에서 가장 흔한 통합 테스트 중 하나야. MockMvc를 활용해 효과적으로 API를 테스트하는 방법을 알아보자!

🛠 MockMvc 기본 설정


@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @MockBean
    private ProductService productService;
    
    // 테스트 코드
}
        

🔍 GET 요청 테스트


@Test
void getProduct_ShouldReturnProduct() throws Exception {
    // given
    ProductDto productDto = new ProductDto(1L, "노트북", 1000000, "전자제품");
    when(productService.getProductById(1L)).thenReturn(productDto);
    
    // when & then
    mockMvc.perform(get("/api/products/1"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.id").value(1))
           .andExpect(jsonPath("$.name").value("노트북"))
           .andExpect(jsonPath("$.price").value(1000000))
           .andExpect(jsonPath("$.category").value("전자제품"))
           .andDo(print()); // 응답 내용 출력 (디버깅용)
}
        

📝 POST 요청 테스트


@Test
void createProduct_ShouldReturnCreatedProduct() throws Exception {
    // given
    ProductCreateDto createDto = new ProductCreateDto("스마트폰", 800000, "전자제품");
    ProductDto createdDto = new ProductDto(2L, "스마트폰", 800000, "전자제품");
    
    when(productService.createProduct(any(ProductCreateDto.class))).thenReturn(createdDto);
    
    // when & then
    mockMvc.perform(post("/api/products")
           .contentType(MediaType.APPLICATION_JSON)
           .content(objectMapper.writeValueAsString(createDto)))
           .andExpect(status().isCreated())
           .andExpect(jsonPath("$.id").value(2))
           .andExpect(jsonPath("$.name").value("스마트폰"));
}
        

🔄 PUT/PATCH 요청 테스트


@Test
void updateProduct_ShouldReturnUpdatedProduct() throws Exception {
    // given
    ProductUpdateDto updateDto = new ProductUpdateDto("업데이트된 노트북", 1200000, "전자제품");
    ProductDto updatedDto = new ProductDto(1L, "업데이트된 노트북", 1200000, "전자제품");
    
    when(productService.updateProduct(eq(1L), any(ProductUpdateDto.class))).thenReturn(updatedDto);
    
    // when & then
    mockMvc.perform(put("/api/products/1")
           .contentType(MediaType.APPLICATION_JSON)
           .content(objectMapper.writeValueAsString(updateDto)))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.name").value("업데이트된 노트북"))
           .andExpect(jsonPath("$.price").value(1200000));
}
        

❌ DELETE 요청 테스트


@Test
void deleteProduct_ShouldReturnNoContent() throws Exception {
    // given
    doNothing().when(productService).deleteProduct(1L);
    
    // when & then
    mockMvc.perform(delete("/api/products/1"))
           .andExpect(status().isNoContent());
           
    verify(productService, times(1)).deleteProduct(1L);
}
        

🔒 인증이 필요한 API 테스트

JWT나 Spring Security를 사용하는 경우 인증이 필요한 API를 테스트하는 방법도 알아보자:


@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
void getSecuredEndpoint_WithAdminRole_ShouldSucceed() throws Exception {
    // when & then
    mockMvc.perform(get("/api/admin/products"))
           .andExpect(status().isOk());
}

@Test
@WithMockUser(username = "user", roles = {"USER"})
void getSecuredEndpoint_WithUserRole_ShouldFail() throws Exception {
    // when & then
    mockMvc.perform(get("/api/admin/products"))
           .andExpect(status().isForbidden());
}
        

REST API 테스트는 애플리케이션의 외부 인터페이스를 검증하는 중요한 테스트야. 특히 재능넷 같은 플랫폼에서 API를 개발할 때는 이런 테스트를 통해 클라이언트와의 계약을 확실히 지킬 수 있어!

📊 응답 검증 팁

  1. JsonPath 활용하기: 복잡한 JSON 응답도 쉽게 검증할 수 있어
  2. 상태 코드 확인하기: 적절한 HTTP 상태 코드를 반환하는지 확인
  3. 헤더 검증하기: Content-Type, Location 등 중요 헤더 확인
  4. 에러 응답 테스트하기: 예외 상황에서 적절한 에러 응답을 주는지 확인

7. 테스트 데이터 관리 전략 📊

통합 테스트에서 가장 까다로운 부분 중 하나는 테스트 데이터 관리야. 어떻게 하면 테스트 데이터를 효과적으로 관리할 수 있을까?

🧩 테스트 데이터 생성 패턴

  1. Test Fixtures: 미리 정의된 테스트 데이터 세트
  2. Object Mother: 테스트 객체 생성을 담당하는 팩토리 클래스
  3. Builder 패턴: 유연한 테스트 객체 생성
  4. Test Data Builders: 빌더 패턴의 테스트 특화 버전

🏗 테스트 데이터 빌더 예제


// 테스트 데이터 빌더
public class UserTestBuilder {
    private String username = "defaultUser";
    private String password = "password123";
    private String email = "default@example.com";
    private Set<Role> roles = new HashSet<>();
    
    public static UserTestBuilder aUser() {
        return new UserTestBuilder();
    }
    
    public UserTestBuilder withUsername(String username) {
        this.username = username;
        return this;
    }
    
    public UserTestBuilder withEmail(String email) {
        this.email = email;
        return this;
    }
    
    public UserTestBuilder withPassword(String password) {
        this.password = password;
        return this;
    }
    
    public UserTestBuilder withRole(Role role) {
        this.roles.add(role);
        return this;
    }
    
    public User build() {
        User user = new User(username, password, email);
        user.setRoles(roles);
        return user;
    }
}

// 테스트에서 사용
@Test
void findUserByEmail_ShouldReturnUser() {
    // given
    User user = UserTestBuilder.aUser()
                   .withUsername("testuser")
                   .withEmail("test@example.com")
                   .withRole(Role.USER)
                   .build();
    userRepository.save(user);
    
    // when
    User foundUser = userService.findByEmail("test@example.com");
    
    // then
    assertNotNull(foundUser);
    assertEquals("testuser", foundUser.getUsername());
}
        

📜 SQL 스크립트 활용하기

대량의 테스트 데이터가 필요하다면 SQL 스크립트를 활용하는 것도 좋은 방법이야:


@SpringBootTest
@Sql({"/schema.sql", "/test-data.sql"})
class ProductServiceIntegrationTest {
    // 테스트 코드
}
        

src/test/resources/test-data.sql:


INSERT INTO products (id, name, price, category) VALUES (1, '노트북', 1000000, '전자제품');
INSERT INTO products (id, name, price, category) VALUES (2, '스마트폰', 800000, '전자제품');
INSERT INTO products (id, name, price, category) VALUES (3, '헤드폰', 300000, '액세서리');
        

🧼 테스트 데이터 정리 전략

  1. @Transactional 활용: 테스트 후 자동 롤백
  2. @BeforeEach에서 데이터 정리: 각 테스트 전에 관련 테이블 비우기
  3. 테스트 전용 스키마 사용: 테스트마다 새로운 스키마 생성
  4. 인메모리 DB 재시작: 테스트 클래스마다 새로운 DB 인스턴스 사용

테스트 데이터 관리는 테스트의 신뢰성과 유지보수성에 직결돼. 특히 여러 개발자가 함께 일하는 재능넷 같은 환경에서는 일관된 테스트 데이터 관리 전략이 더욱 중요하지!

8. 테스트 성능 최적화 팁 ⚡

통합 테스트는 단위 테스트보다 실행 시간이 오래 걸리는 경향이 있어. 하지만 몇 가지 최적화 기법을 적용하면 테스트 실행 시간을 크게 단축할 수 있어!

🚀 테스트 실행 속도 향상 전략

  1. 테스트 컨텍스트 캐싱 활용: 스프링은 동일한 설정의 테스트 컨텍스트를 재사용해
  2. 불필요한 자동 구성 제외: @SpringBootTest(excludeAutoConfiguration = ...)
  3. 슬라이스 테스트 활용: @WebMvcTest, @DataJpaTest 등
  4. 병렬 테스트 실행: JUnit 5의 병렬 실행 기능 활용
  5. 테스트 격리 최적화: DB 초기화 전략 개선

⏱ 병렬 테스트 설정 예제

JUnit 5에서 병렬 테스트를 활성화하려면 junit-platform.properties 파일을 src/test/resources에 추가해:


junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
        

그리고 테스트 클래스에 다음과 같이 설정할 수 있어:


@SpringBootTest
@Execution(ExecutionMode.CONCURRENT)
class ConcurrentIntegrationTest {
    // 병렬로 실행될 테스트 메소드들
}
        

🔍 테스트 프로파일링

어떤 테스트가 가장 오래 걸리는지 파악하는 것도 중요해:


// build.gradle
test {
    testLogging {
        events "passed", "skipped", "failed"
        showStandardStreams = true
        exceptionFormat = 'full'
        
        // 테스트 실행 시간 출력
        afterSuite { desc, result ->
            if (!desc.parent) {
                println "\nTest result: ${result.resultType}"
                println "Test summary: ${result.testCount} tests, " +
                       "${result.successfulTestCount} succeeded, " +
                       "${result.failedTestCount} failed, " +
                       "${result.skippedTestCount} skipped"
                println "Total time: ${(result.endTime - result.startTime) / 1000.0} seconds"
            }
        }
    }
}
        

🧠 테스트 실행 메모리 최적화

  1. 테스트 JVM 메모리 설정: -Xmx 옵션으로 최대 힙 크기 설정
  2. 불필요한 로깅 비활성화: 테스트 실행 시 로그 레벨 조정
  3. 리소스 정리 철저히: @AfterEach에서 무거운 리소스 정리

테스트 실행 시간을 단축하면 개발 생산성이 크게 향상돼! CI/CD 파이프라인에서도 빠른 피드백을 받을 수 있고, 개발자들이 더 자주 테스트를 실행하게 되는 선순환이 생겨.

9. 실제 프로젝트에서의 테스트 사례 💼

이론은 충분히 알아봤으니, 이제 실제 프로젝트에서 어떻게 통합 테스트를 적용하는지 살펴보자! 간단한 온라인 쇼핑몰 API를 예로 들어볼게.

🛍 온라인 쇼핑몰 API 테스트 사례

이 프로젝트는 다음과 같은 기능을 제공해:

  1. 상품 목록 조회 및 상세 정보 조회
  2. 장바구니 추가/수정/삭제
  3. 주문 생성 및 결제
  4. 사용자 리뷰 작성

🧪 상품 API 통합 테스트


@SpringBootTest
@AutoConfigureMockMvc
class ProductApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private CategoryRepository categoryRepository;
    
    private Category electronicsCategory;
    private Product laptop;
    
    @BeforeEach
    void setup() {
        productRepository.deleteAll();
        categoryRepository.deleteAll();
        
        // 테스트 데이터 준비
        electronicsCategory = categoryRepository.save(new Category("전자제품"));
        
        laptop = new Product();
        laptop.setName("맥북 프로 16인치");
        laptop.setPrice(new BigDecimal("2500000"));
        laptop.setDescription("Apple M1 Pro 칩 탑재 모델");
        laptop.setCategory(electronicsCategory);
        laptop.setStockQuantity(10);
        laptop = productRepository.save(laptop);
    }
    
    @Test
    void getProductList_ShouldReturnProducts() throws Exception {
        // when & then
        mockMvc.perform(get("/api/products"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.content").isArray())
               .andExpect(jsonPath("$.content[0].name").value("맥북 프로 16인치"))
               .andExpect(jsonPath("$.content[0].price").value(2500000));
    }
    
    @Test
    void getProductById_WithValidId_ShouldReturnProduct() throws Exception {
        // when & then
        mockMvc.perform(get("/api/products/{id}", laptop.getId()))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.name").value("맥북 프로 16인치"))
               .andExpect(jsonPath("$.category.name").value("전자제품"));
    }
    
    @Test
    void getProductById_WithInvalidId_ShouldReturnNotFound() throws Exception {
        // when & then
        mockMvc.perform(get("/api/products/999"))
               .andExpect(status().isNotFound());
    }
}
        

🛒 장바구니 API 통합 테스트


@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser(username = "user1")
class CartApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private CartRepository cartRepository;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    private User user;
    private Product product;
    
    @BeforeEach
    void setup() {
        // 테스트 데이터 준비
        userRepository.deleteAll();
        productRepository.deleteAll();
        cartRepository.deleteAll();
        
        user = new User("user1", "password", "user1@example.com");
        user = userRepository.save(user);
        
        product = new Product();
        product.setName("아이폰 13");
        product.setPrice(new BigDecimal("1200000"));
        product.setStockQuantity(5);
        product = productRepository.save(product);
    }
    
    @Test
    void addToCart_ShouldAddProductToCart() throws Exception {
        // given
        AddToCartRequest request = new AddToCartRequest();
        request.setProductId(product.getId());
        request.setQuantity(2);
        
        // when & then
        mockMvc.perform(post("/api/cart/items")
               .contentType(MediaType.APPLICATION_JSON)
               .content(objectMapper.writeValueAsString(request)))
               .andExpect(status().isCreated())
               .andExpect(jsonPath("$.productName").value("아이폰 13"))
               .andExpect(jsonPath("$.quantity").value(2));
        
        // 데이터베이스에도 제대로 저장되었는지 확인
        List<CartItem> cartItems = cartRepository.findByUser(user);
        assertEquals(1, cartItems.size());
        assertEquals(product.getId(), cartItems.get(0).getProduct().getId());
        assertEquals(2, cartItems.get(0).getQuantity());
    }
}
        

💳 주문 API 통합 테스트


@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser(username = "user1")
class OrderApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    private User user;
    private Product product1;
    private Product product2;
    
    @BeforeEach
    void setup() {
        // 테스트 데이터 준비
        orderRepository.deleteAll();
        userRepository.deleteAll();
        productRepository.deleteAll();
        
        user = new User("user1", "password", "user1@example.com");
        user = userRepository.save(user);
        
        product1 = new Product();
        product1.setName("노트북");
        product1.setPrice(new BigDecimal("1500000"));
        product1.setStockQuantity(10);
        product1 = productRepository.save(product1);
        
        product2 = new Product();
        product2.setName("키보드");
        product2.setPrice(new BigDecimal("150000"));
        product2.setStockQuantity(20);
        product2 = productRepository.save(product2);
    }
    
    @Test
    void createOrder_ShouldCreateNewOrder() throws Exception {
        // given
        CreateOrderRequest request = new CreateOrderRequest();
        List<OrderItemRequest> items = new ArrayList<>();
        
        OrderItemRequest item1 = new OrderItemRequest();
        item1.setProductId(product1.getId());
        item1.setQuantity(1);
        
        OrderItemRequest item2 = new OrderItemRequest();
        item2.setProductId(product2.getId());
        item2.setQuantity(2);
        
        items.add(item1);
        items.add(item2);
        request.setItems(items);
        
        // when & then
        mockMvc.perform(post("/api/orders")
               .contentType(MediaType.APPLICATION_JSON)
               .content(objectMapper.writeValueAsString(request)))
               .andExpect(status().isCreated())
               .andExpect(jsonPath("$.orderNumber").exists())
               .andExpect(jsonPath("$.totalAmount").value(1800000))
               .andExpect(jsonPath("$.orderItems").isArray())
               .andExpect(jsonPath("$.orderItems.length()").value(2));
        
        // 상품 재고가 감소했는지 확인
        Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
        assertEquals(9, updatedProduct1.getStockQuantity());
        
        Product updatedProduct2 = productRepository.findById(product2.getId()).orElseThrow();
        assertEquals(18, updatedProduct2.getStockQuantity());
    }
}
        

이런 식으로 실제 비즈니스 로직을 테스트하면 애플리케이션의 안정성을 크게 높일 수 있어! 재능넷에서 프로젝트를 진행할 때도 이런 방식으로 통합 테스트를 작성하면 클라이언트의 신뢰를 얻는 데 큰 도움이 될 거야. 😊

10. 마무리 및 추가 학습 자료 📚

지금까지 Spring Boot에서 효과적인 통합 테스트 전략에 대해 알아봤어! 이제 너도 테스트 마스터가 될 준비가 됐지? 😎

🔑 핵심 요약

  1. 테스트 환경 설정: Spring Boot Starter Test로 쉽게 시작
  2. 단위 테스트 vs 통합 테스트: 각각의 장단점과 적절한 사용 시점 이해
  3. 데이터베이스 테스트: 인메모리 DB 또는 Testcontainers 활용
  4. REST API 테스트: MockMvc로 엔드포인트 검증
  5. 테스트 데이터 관리: 빌더 패턴, SQL 스크립트 등 활용
  6. 테스트 성능 최적화: 컨텍스트 캐싱, 병렬 실행 등
  7. 실제 프로젝트 적용: 비즈니스 로직에 맞는 테스트 작성

📖 추가 학습 자료

  1. 공식 문서: Spring Boot Testing
  2. 책: "Spring Boot in Action" by Craig Walls
  3. 책: "Practical Test-Driven Development using C# 7" by John Callaway, Clayton Hunt
  4. 온라인 강의: Udemy의 "Testing Spring Boot: Beginner to Guru"
  5. 블로그: Baeldung의 Spring Testing 시리즈
  6. GitHub: Spring Boot 테스트 예제

💡 마지막 팁

테스트는 개발 과정의 일부일 뿐, 목적 자체가 아니야. 테스트가 너무 복잡하거나 유지보수하기 어렵다면, 그건 좋은 테스트가 아닐 수 있어. 항상 테스트의 가치와 비용을 균형 있게 고려하는 것이 중요해!

테스트 코드 작성은 처음에는 시간이 좀 더 들지만, 장기적으로는 버그 수정과 기능 추가 시간을 크게 줄여줘. 특히 재능넷처럼 다양한 개발자들이 협업하는 환경에서는 테스트의 가치가 더욱 빛나지!

이제 너도 Spring Boot 테스트 전문가로 거듭나서 더 안정적이고 유지보수하기 쉬운 코드를 작성해보자! 질문이 있다면 언제든지 물어봐. 함께 성장하는 개발자가 되자고! 🚀

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.