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

Spring Boot Test: 효과적인 통합 테스트 전략 🧪
자바 개발자를 위한 테스트 마스터 가이드
📌 안녕? 스프링 부트 테스트의 세계로 초대할게! 🚀
자바 개발자로서 코드를 작성하는 건 재밌지만, 그 코드가 제대로 동작하는지 확인하는 건 더 중요하지 않을까? 오늘은 Spring Boot에서 통합 테스트를 효과적으로 작성하는 방법에 대해 친구처럼 편하게 얘기해볼게! 이 글을 통해 테스트 코드 작성이 더 이상 지루한 작업이 아니라 개발의 즐거운 부분이 될 거야.
요즘 재능넷 같은 플랫폼에서도 테스트 코드 작성 능력을 갖춘 개발자의 수요가 늘고 있어. 실제로 재능넷에서 프로젝트를 의뢰할 때 "테스트 코드 포함"이라는 요구사항이 점점 늘어나고 있다고 해. 그만큼 중요해진 테스트, 함께 파헤쳐보자! 😊
🧩 목차
- Spring Boot 테스트 기초 이해하기
- 통합 테스트 vs 단위 테스트: 언제 무엇을 써야 할까?
- Spring Boot 테스트 환경 설정하기
- 효과적인 통합 테스트 작성 전략
- 데이터베이스 통합 테스트 전략
- REST API 통합 테스트 방법
- 테스트 데이터 관리 전략
- 테스트 성능 최적화 팁
- 실제 프로젝트에서의 테스트 사례
- 마무리 및 추가 학습 자료
1. Spring Boot 테스트 기초 이해하기 🌱
스프링 부트는 테스트를 정말 쉽게 만들어줘! 기본적으로 spring-boot-starter-test 의존성만 추가하면 테스트에 필요한 거의 모든 도구가 함께 제공된다고 보면 돼.
🔍 Spring Boot 테스트에 포함된 주요 라이브러리
- JUnit 5: 테스트 프레임워크의 기본! 테스트 실행을 담당해
- Spring Test: 스프링 애플리케이션 컨텍스트 테스트 지원
- AssertJ: 더 직관적인 assertion 라이브러리
- Hamcrest: 매처 라이브러리
- Mockito: 목 객체 생성 및 검증 라이브러리
- JSONassert: JSON assertion 라이브러리
- JsonPath: JSON 데이터 쿼리 라이브러리
이 모든 걸 따로 설치할 필요 없이 아래 의존성 하나면 끝! 😎
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
이 의존성 하나로 테스트의 모든 준비는 끝! 이제 실제로 테스트 코드를 작성해볼 준비가 됐어.
2. 통합 테스트 vs 단위 테스트: 언제 무엇을 써야 할까? 🤔
테스트에는 크게 두 가지 유형이 있어. 단위 테스트와 통합 테스트! 이 둘의 차이점과 각각 언제 사용하면 좋을지 알아보자.
🔬 단위 테스트
단위 테스트는 코드의 가장 작은 단위(보통 메소드 수준)를 테스트해. 외부 의존성은 모두 목(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() {
// 각 테스트 후에 실행할 코드
}
}
🏷 주요 테스트 애노테이션
스프링 부트 테스트에서 자주 사용하는 애노테이션들을 알아보자:
- @SpringBootTest: 스프링 부트 애플리케이션 컨텍스트를 로드해 통합 테스트 환경을 제공해
- @WebMvcTest: 웹 레이어(컨트롤러)만 테스트할 때 사용해
- @DataJpaTest: JPA 컴포넌트만 테스트할 때 사용해
- @AutoConfigureMockMvc: MockMvc를 자동 구성해줘
- @MockBean: 스프링 컨텍스트에 모의 객체를 추가해
- @TestPropertySource: 테스트용 프로퍼티 파일을 지정해
- @Transactional: 각 테스트 메소드를 트랜잭션으로 감싸고 테스트 후 롤백해
- @ActiveProfiles: 특정 프로필을 활성화해
이런 애노테이션들을 적절히 조합하면 다양한 테스트 시나리오를 효과적으로 구성할 수 있어! 예를 들어, 컨트롤러만 테스트하고 싶다면 @WebMvcTest를, DB 관련 로직만 테스트하고 싶다면 @DataJpaTest를 사용하면 되지.
4. 효과적인 통합 테스트 작성 전략 📝
이제 실제로 통합 테스트를 작성하는 전략에 대해 알아보자! 좋은 테스트는 단순히 코드를 검증하는 것을 넘어서 코드의 문서 역할도 한다는 걸 기억해.
🎯 테스트 작성의 기본 원칙
- Given-When-Then 패턴 사용하기: 테스트 구조를 명확하게 해
- 테스트 메소드 이름을 명확하게 짓기: 무엇을 테스트하는지 이름만 봐도 알 수 있게
- 하나의 테스트는 하나의 동작만 검증하기: 테스트가 실패했을 때 원인을 쉽게 파악할 수 있어
- 테스트 간 의존성 없애기: 각 테스트는 독립적으로 실행할 수 있어야 해
- 실패 케이스도 테스트하기: 예외 상황도 제대로 처리되는지 확인해
🌟 효과적인 통합 테스트 예제
사용자 등록 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("이미 존재하는 사용자명입니다")));
}
}
🔍 테스트 가독성 높이기
테스트 코드도 결국 코드야. 다른 개발자(또는 미래의 나)가 쉽게 이해할 수 있도록 가독성을 높이는 게 중요해!
- 헬퍼 메소드 활용하기: 반복되는 코드는 별도 메소드로 추출해
- 테스트 픽스처 활용하기: 테스트에 필요한 객체를 미리 준비해두기
- 명확한 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. 데이터베이스 통합 테스트 전략 💾
데이터베이스와 연동된 통합 테스트는 실제 애플리케이션의 동작을 가장 잘 검증할 수 있어. 하지만 몇 가지 고려해야 할 점이 있지!
🗃 테스트용 데이터베이스 선택하기
테스트에 사용할 수 있는 데이터베이스 옵션들을 알아보자:
- 인메모리 DB (H2, HSQLDB): 가장 빠르고 설정이 간편해
- Testcontainers: 실제 DB와 동일한 환경을 Docker 컨테이너로 제공해
- 실제 개발 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와 동일한 환경에서 테스트할 수 있어 더 신뢰성 높은 테스트가 가능해! 물론 테스트 실행 속도는 조금 느려질 수 있지만, 실제 환경과의 일관성을 위해 충분히 가치 있는 트레이드오프야.
💡 데이터베이스 테스트 팁
- @Transactional 활용하기: 각 테스트 후 자동으로 롤백되어 테스트 간 격리를 보장해
- 데이터 정리는 tearDown보다 setUp에서: 테스트 실패 시에도 다음 테스트에 영향이 없도록
- SQL 스크립트 활용하기:
@Sql애노테이션으로 테스트 데이터 초기화 - 페이징, 정렬 등 복잡한 쿼리도 테스트하기: 실제 사용 패턴을 반영한 테스트 작성
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를 개발할 때는 이런 테스트를 통해 클라이언트와의 계약을 확실히 지킬 수 있어!
📊 응답 검증 팁
- JsonPath 활용하기: 복잡한 JSON 응답도 쉽게 검증할 수 있어
- 상태 코드 확인하기: 적절한 HTTP 상태 코드를 반환하는지 확인
- 헤더 검증하기: Content-Type, Location 등 중요 헤더 확인
- 에러 응답 테스트하기: 예외 상황에서 적절한 에러 응답을 주는지 확인
7. 테스트 데이터 관리 전략 📊
통합 테스트에서 가장 까다로운 부분 중 하나는 테스트 데이터 관리야. 어떻게 하면 테스트 데이터를 효과적으로 관리할 수 있을까?
🧩 테스트 데이터 생성 패턴
- Test Fixtures: 미리 정의된 테스트 데이터 세트
- Object Mother: 테스트 객체 생성을 담당하는 팩토리 클래스
- Builder 패턴: 유연한 테스트 객체 생성
- 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, '액세서리');
🧼 테스트 데이터 정리 전략
- @Transactional 활용: 테스트 후 자동 롤백
- @BeforeEach에서 데이터 정리: 각 테스트 전에 관련 테이블 비우기
- 테스트 전용 스키마 사용: 테스트마다 새로운 스키마 생성
- 인메모리 DB 재시작: 테스트 클래스마다 새로운 DB 인스턴스 사용
테스트 데이터 관리는 테스트의 신뢰성과 유지보수성에 직결돼. 특히 여러 개발자가 함께 일하는 재능넷 같은 환경에서는 일관된 테스트 데이터 관리 전략이 더욱 중요하지!
8. 테스트 성능 최적화 팁 ⚡
통합 테스트는 단위 테스트보다 실행 시간이 오래 걸리는 경향이 있어. 하지만 몇 가지 최적화 기법을 적용하면 테스트 실행 시간을 크게 단축할 수 있어!
🚀 테스트 실행 속도 향상 전략
- 테스트 컨텍스트 캐싱 활용: 스프링은 동일한 설정의 테스트 컨텍스트를 재사용해
- 불필요한 자동 구성 제외:
@SpringBootTest(excludeAutoConfiguration = ...) - 슬라이스 테스트 활용:
@WebMvcTest,@DataJpaTest등 - 병렬 테스트 실행: JUnit 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"
}
}
}
}
🧠 테스트 실행 메모리 최적화
- 테스트 JVM 메모리 설정:
-Xmx옵션으로 최대 힙 크기 설정 - 불필요한 로깅 비활성화: 테스트 실행 시 로그 레벨 조정
- 리소스 정리 철저히:
@AfterEach에서 무거운 리소스 정리
테스트 실행 시간을 단축하면 개발 생산성이 크게 향상돼! CI/CD 파이프라인에서도 빠른 피드백을 받을 수 있고, 개발자들이 더 자주 테스트를 실행하게 되는 선순환이 생겨.
9. 실제 프로젝트에서의 테스트 사례 💼
이론은 충분히 알아봤으니, 이제 실제 프로젝트에서 어떻게 통합 테스트를 적용하는지 살펴보자! 간단한 온라인 쇼핑몰 API를 예로 들어볼게.
🛍 온라인 쇼핑몰 API 테스트 사례
이 프로젝트는 다음과 같은 기능을 제공해:
- 상품 목록 조회 및 상세 정보 조회
- 장바구니 추가/수정/삭제
- 주문 생성 및 결제
- 사용자 리뷰 작성
🧪 상품 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에서 효과적인 통합 테스트 전략에 대해 알아봤어! 이제 너도 테스트 마스터가 될 준비가 됐지? 😎
🔑 핵심 요약
- 테스트 환경 설정: Spring Boot Starter Test로 쉽게 시작
- 단위 테스트 vs 통합 테스트: 각각의 장단점과 적절한 사용 시점 이해
- 데이터베이스 테스트: 인메모리 DB 또는 Testcontainers 활용
- REST API 테스트: MockMvc로 엔드포인트 검증
- 테스트 데이터 관리: 빌더 패턴, SQL 스크립트 등 활용
- 테스트 성능 최적화: 컨텍스트 캐싱, 병렬 실행 등
- 실제 프로젝트 적용: 비즈니스 로직에 맞는 테스트 작성
📖 추가 학습 자료
- 공식 문서: Spring Boot Testing
- 책: "Spring Boot in Action" by Craig Walls
- 책: "Practical Test-Driven Development using C# 7" by John Callaway, Clayton Hunt
- 온라인 강의: Udemy의 "Testing Spring Boot: Beginner to Guru"
- 블로그: Baeldung의 Spring Testing 시리즈
- GitHub: Spring Boot 테스트 예제
💡 마지막 팁
테스트는 개발 과정의 일부일 뿐, 목적 자체가 아니야. 테스트가 너무 복잡하거나 유지보수하기 어렵다면, 그건 좋은 테스트가 아닐 수 있어. 항상 테스트의 가치와 비용을 균형 있게 고려하는 것이 중요해!
테스트 코드 작성은 처음에는 시간이 좀 더 들지만, 장기적으로는 버그 수정과 기능 추가 시간을 크게 줄여줘. 특히 재능넷처럼 다양한 개발자들이 협업하는 환경에서는 테스트의 가치가 더욱 빛나지!
이제 너도 Spring Boot 테스트 전문가로 거듭나서 더 안정적이고 유지보수하기 쉬운 코드를 작성해보자! 질문이 있다면 언제든지 물어봐. 함께 성장하는 개발자가 되자고! 🚀
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.