AI 질의응답-unit test
서론
그동안 ai를 사용하며 정말 유익하고 가치있는 정보가 많았는데, 프로젝트를 준비하고 기획하는데 바빠 다 올리지 못했다. 블로그를 너무 방치한 것 같아 조금이라도 틈을 내서 중요한 정보들은 정리해서 올려보기로 했다.
프로젝트 도중 테스트 코드를 작성해야 하는데 실무에서는 어떤 식으로 하는지, 어떤 방식이 구현하는지 궁금해서 물어보게 됬다.
질의응답
나의 말:
보통 유닛테스트용 코드는 어떻게 작성해?
답변:
백엔드 개발자로서 유닛 테스트 작성은 매우 중요한 기술입니다. Spring에서의 유닛 테스트 작성 방법을 설명해 드리겠습니다.
Spring 유닛 테스트 기본 구조
@SpringBootTest // 전체 애플리케이션 컨텍스트를 로드할 때 사용
// 또는 @WebMvcTest, @DataJpaTest 등 필요한 부분만 테스트
public class UserServiceTest {
// 테스트할 대상 주입
@Autowired // 또는 @MockBean, @InjectMocks 사용
private UserService userService;
// 의존성 모킹
@MockBean
private UserRepository userRepository;
// 테스트 데이터 준비
private User testUser;
@BeforeEach
void setup() {
testUser = new User();
testUser.setId(1L);
testUser.setName("테스트유저");
testUser.setEmail("test@example.com");
// 모의 동작 설정
when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
}
@Test
@DisplayName("사용자를 ID로 조회하면 올바른 사용자가 반환되어야 함")
void testGetUserById() {
// given은 setup에서 설정함
// when - 테스트할 기능 실행
User foundUser = userService.getUserById(1L);
// then - 결과 검증
assertNotNull(foundUser);
assertEquals(1L, foundUser.getId());
assertEquals("테스트유저", foundUser.getName());
// 모의 객체 호출 검증
verify(userRepository, times(1)).findById(1L);
}
@Test
@DisplayName("존재하지 않는 ID로 조회하면 예외가 발생해야 함")
void testGetUserByIdNotFound() {
// given
when(userRepository.findById(99L)).thenReturn(Optional.empty());
// when & then - 예외 검증
assertThrows(UserNotFoundException.class, () -> {
userService.getUserById(99L);
});
}
}
테스트 종류별 어노테이션
- 컨트롤러 테스트
@WebMvcTest(UserController.class) // 웹 레이어만 테스트 public class UserControllerTest { @Autowired private MockMvc mockMvc; // HTTP 요청 모의 @MockBean private UserService userService; @Test void testGetUser() throws Exception { // given User user = new User(1L, "사용자1", "user@example.com"); when(userService.getUserById(1L)).thenReturn(user); // when & then mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.name").value("사용자1")) .andDo(print()); // 응답 내용 출력 } }
- 리포지토리 테스트
@DataJpaTest // JPA 관련 컴포넌트만 테스트, 인메모리 DB 자동 설정 public class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void testFindByEmail() { // given User user = new User(); user.setName("홍길동"); user.setEmail("hong@example.com"); userRepository.save(user); // when User foundUser = userRepository.findByEmail("hong@example.com").orElse(null); // then assertNotNull(foundUser); assertEquals("홍길동", foundUser.getName()); } }
- 서비스 테스트 (Mockito 사용)
@ExtendWith(MockitoExtension.class) // Spring 컨텍스트 없이 Mockito만 사용 public class UserServiceMockTest { @Mock private UserRepository userRepository; @InjectMocks // Mock들을 주입받음 private UserServiceImpl userService; @Test void testCreateUser() { // given User inputUser = new User(null, "신규사용자", "new@example.com"); User savedUser = new User(1L, "신규사용자", "new@example.com"); when(userRepository.save(any(User.class))).thenReturn(savedUser); // when User createdUser = userService.createUser(inputUser); // then assertNotNull(createdUser.getId()); assertEquals(inputUser.getName(), createdUser.getName()); // 인자 캡처를 통한 상세 검증 ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class); verify(userRepository).save(userCaptor.capture()); assertEquals("신규사용자", userCaptor.getValue().getName()); } }
테스트 작성 모범 사례
- 테스트 격리: 각 테스트는 독립적으로 실행되어야 합니다.
- 명확한 테스트 이름:
@DisplayName
을 사용해 테스트 목적을 명확히 표현합니다. - Given-When-Then 패턴: 테스트를 준비-실행-검증 단계로 명확히 구분합니다.
- 경계값 테스트: 정상 케이스뿐만 아니라 예외 상황도 테스트합니다.
- 필요한 부분만 테스트: 전체 애플리케이션 컨텍스트 로드는 피하고 필요한 부분만 테스트합니다.
- 테스트 데이터 관리:
@BeforeEach
,@AfterEach
를 활용해 테스트 데이터를 관리합니다. - Mocking 적절히 사용: 외부 의존성은 mock을 활용해 테스트 속도와 안정성을 높입니다.
Spring Boot에서는 이러한 테스트 작성을 위한 도구들을 기본 제공하므로, 이를 적극 활용하시면 효과적인 유닛 테스트를 작성할 수 있습니다.
나의 말:
@mockbean은 무슨 어노테이션이고 왜 사용하는거야?
답변:
@MockBean
은 Spring Boot 테스트 프레임워크에서 제공하는 중요한 어노테이션입니다. 이 어노테이션의 역할과 사용 이유를 상세히 설명해 드리겠습니다.
@MockBean 개념
@MockBean
은 Spring의 ApplicationContext에 Mockito mock 객체를 등록하는 어노테이션입니다. 이는 Spring Boot Test 모듈의 일부로 제공됩니다.
@SpringBootTest
public class UserServiceTest {
@MockBean
private UserRepository userRepository;
// 테스트 코드
}
@MockBean 사용 이유
- 의존성 격리(Dependency Isolation)
- 테스트 대상 컴포넌트가 의존하는 다른 컴포넌트를 실제 구현체 대신 가짜 객체로 대체합니다.
- 이로써 테스트 대상을 다른 컴포넌트로부터 격리시켜 순수하게 테스트할 수 있습니다.
- 외부 시스템 의존성 제거
- 데이터베이스, 외부 API 등 외부 시스템에 의존하는 컴포넌트를 테스트할 때 실제 연결 없이 테스트 가능합니다.
- 예: 실제 DB 연결 없이 Repository 계층을 모킹하여 Service 계층 테스트 가능
- 테스트 속도 향상
- 실제 구현체보다 모의 객체가 더 빠르게 동작합니다.
- 외부 시스템 접근이나 복잡한 연산을 건너뛸 수 있습니다.
- 테스트 제어력 향상
- 모의 객체의 응답을 프로그래밍적으로 정의하여 다양한 시나리오 테스트가 가능합니다.
- 특정 예외 상황이나 에지 케이스를 쉽게 재현할 수 있습니다.
- Spring 컨텍스트와의 통합
- 일반 Mockito
@Mock
과 달리,@MockBean
은 Spring의 ApplicationContext에 등록됩니다. - 따라서 Spring의 의존성 주입 기능을 그대로 활용할 수 있습니다.
- 일반 Mockito
@Mock vs @MockBean 비교
// Mockito만 사용한 테스트
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserServiceImpl userService;
// 테스트 코드
}
// Spring Boot Test와 통합한 테스트
@SpringBootTest
public class UserServiceIntegrationTest {
@MockBean
private UserRepository userRepository;
@Autowired
private UserService userService;
// 테스트 코드
}
@MockBean 사용 예시
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService; // 컨트롤러가 의존하는 서비스 계층을 모킹
@Test
void testGetUser() throws Exception {
// given - 모의 동작 정의
User mockUser = new User(1L, "테스트", "test@example.com");
when(userService.getUserById(1L)).thenReturn(mockUser);
// when & then - 컨트롤러 테스트
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("테스트"));
// 모의 객체가 예상대로 호출되었는지 검증
verify(userService).getUserById(1L);
}
}
@MockBean 사용 시 주의사항
- Spring 컨텍스트 캐싱 영향
@MockBean
은 Spring 테스트 컨텍스트 캐싱에 영향을 줍니다.- 서로 다른 Mock 설정이 필요한 테스트 클래스들은 별도의 컨텍스트를 생성합니다.
- 성능 영향
@Mock
보다@MockBean
이 Spring 컨텍스트를 활용하므로 더 무겁습니다.- 단순 유닛 테스트에는
@Mock
+@InjectMocks
조합이 더 효율적일 수 있습니다.
- 모의 동작 설정
- 각 테스트 메서드마다
when(...).thenReturn(...)
등으로 모의 동작을 재설정해야 합니다. @BeforeEach
에서 공통 모의 동작을 설정하는 것이 좋습니다.
- 각 테스트 메서드마다
백엔드 개발자로서 @MockBean
을 잘 활용하면 견고하고 유지보수가 용이한 테스트 코드를 작성할 수 있습니다. 특히 계층 간의 의존성을 효과적으로 격리시켜 단위 테스트의 질을 높일 수 있습니다.
나의 말:
원래 테스트는 테스트 하는 객체를 제외하곤 mock 객체로 대신해서 하는거구나. 원래 테스트를 할때 assertthat 으로 하지 않고 verify를 사용해?
답변:
맞습니다. 테스트를 수행할 때 일반적인 원칙은 테스트 대상 객체(SUT: System Under Test)를 제외한 의존성들을 mock 객체로 대체하는 것입니다. 이를 통해 테스트 대상을 격리시켜 순수하게 그 기능만 검증할 수 있습니다.
assert vs verify의 차이점
두 가지 방식은 테스트에서 서로 다른 관점을 검증합니다:
1. assert
메서드 (상태 기반 테스트)
@Test
void getUserName_ShouldReturnCorrectName() {
// given
when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "홍길동", "email")));
// when
String name = userService.getUserName(1L);
// then - 결과값 검증(상태 검증)
assertEquals("홍길동", name);
// 또는
assertThat(name).isEqualTo("홍길동");
}
- 검증 대상: 테스트 대상 메서드의 반환값이나 상태 변화
- 목적: “무엇이 반환되었는가?” 또는 “상태가 어떻게 변했는가?”
- 주로 사용: 기능의 결과값, 객체의 상태 변화 검증에 사용
2. verify
메서드 (행위 기반 테스트)
@Test
void deleteUser_ShouldCallRepositoryMethod() {
// given
doNothing().when(userRepository).deleteById(1L);
// when
userService.deleteUser(1L);
// then - 메서드 호출 여부 검증(행위 검증)
verify(userRepository).deleteById(1L);
}
- 검증 대상: 의존성 객체(mock)와의 상호작용
- 목적: “어떤 메서드가 호출되었는가?” 또는 “어떤 인자로 호출되었는가?”
- 주로 사용: 의존성 객체와의 상호작용, 메서드 호출 패턴 검증에 사용
실제 테스트 시 상황별 검증 방법 선택
1. 두 방식을 함께 사용하는 경우
@Test
void createUser_ShouldSaveAndReturnUser() {
// given
User inputUser = new User(null, "신규사용자", "new@example.com");
User savedUser = new User(1L, "신규사용자", "new@example.com");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// when
User result = userService.createUser(inputUser);
// then
// 1. 상태 검증 - 반환값 확인
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getName()).isEqualTo("신규사용자");
// 2. 행위 검증 - save 메서드 호출 확인
verify(userRepository).save(any(User.class));
}
2. 주로 assert
를 사용하는 경우
- 함수형 메서드 (입력 → 출력 변환)
- 순수 계산 로직
- 상태 변경이 중요한 경우
@Test
void calculateTotalPrice_ShouldReturnCorrectSum() {
// given
List<Product> products = Arrays.asList(
new Product("A", 1000),
new Product("B", 2000)
);
// when
int total = priceCalculator.calculateTotal(products);
// then - 계산 결과만 검증
assertThat(total).isEqualTo(3000);
}
3. 주로 verify
를 사용하는 경우
- void 메서드 (반환값이 없는 경우)
- 다른 컴포넌트와의 상호작용이 중요한 경우
- 이벤트 발행, 메시지 전송 등의 검증
@Test
void sendPasswordResetEmail_ShouldCallEmailService() {
// given
User user = new User(1L, "사용자", "user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
// when
userService.sendPasswordResetEmail("user@example.com");
// then - 이메일 서비스 호출 여부 검증
verify(emailService).sendEmail(
eq("user@example.com"),
eq("비밀번호 재설정"),
contains("재설정 링크")
);
}
모범 테스트 관행
- 테스트 목적에 맞는 검증 방식 선택
- 반환값이나 상태 변화 확인 →
assert
- 의존성과의 상호작용 확인 →
verify
- 반환값이나 상태 변화 확인 →
- 과도한 verify 사용 자제
- 모든 메서드 호출을 verify하면 구현 세부사항에 과도하게 결합된 테스트가 됩니다.
- 핵심적인 상호작용만 verify하고, 나머지는 상태 검증으로 대체하는 것이 좋습니다.
-
행위와 상태 검증 적절히 조합
@Test void processOrder_ShouldUpdateInventoryAndNotifyUser() { // given Order order = new Order(1L, Arrays.asList(new OrderItem("상품A", 2))); when(inventoryService.checkAvailability(any())).thenReturn(true); // when OrderResult result = orderService.processOrder(order); // then // 1. 상태 검증 assertThat(result.isSuccess()).isTrue(); // 2. 중요한 상호작용 검증 verify(inventoryService).reduceStock("상품A", 2); verify(notificationService).notifyOrderConfirmation(eq(1L), any()); // 덜 중요한 로깅 등은 verify하지 않음 }
백엔드 개발자로서 단위 테스트를 작성할 때는 테스트 대상의 성격과 목적에 따라 상태 검증(assert
)과 행위 검증(verify
)을 적절히 조합하는 것이 중요합니다. 두 방식을 이해하고 상황에 맞게 사용하면 더 효과적이고 유지보수하기 쉬운 테스트 코드를 작성할 수 있습니다.
정리
테스트를 할때 @MockBean 어노테이션을 사용하여 테스트하는 객체를 제외하곤 mock 객체로 대체하는 방식과, 왜 그렇게 구현하는지를 배울 수 있었다.
또한 지금까지는 assertions의 assertthat() 과 같은 메서드를 주로 사용했고 verify는 처음 봤는데 두 메서드의 차이와 목적, 상황에 따라 어떤 것을 사용하면 좋은 지를 배울 수 있었다.