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);
        });
    }
}

테스트 종류별 어노테이션

  1. 컨트롤러 테스트
    @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()); // 응답 내용 출력
     }
    }
    
  2. 리포지토리 테스트
    @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());
     }
    }
    
  3. 서비스 테스트 (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());
     }
    }
    

테스트 작성 모범 사례

  1. 테스트 격리: 각 테스트는 독립적으로 실행되어야 합니다.
  2. 명확한 테스트 이름: @DisplayName을 사용해 테스트 목적을 명확히 표현합니다.
  3. Given-When-Then 패턴: 테스트를 준비-실행-검증 단계로 명확히 구분합니다.
  4. 경계값 테스트: 정상 케이스뿐만 아니라 예외 상황도 테스트합니다.
  5. 필요한 부분만 테스트: 전체 애플리케이션 컨텍스트 로드는 피하고 필요한 부분만 테스트합니다.
  6. 테스트 데이터 관리: @BeforeEach, @AfterEach를 활용해 테스트 데이터를 관리합니다.
  7. 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 사용 이유

  1. 의존성 격리(Dependency Isolation)
    • 테스트 대상 컴포넌트가 의존하는 다른 컴포넌트를 실제 구현체 대신 가짜 객체로 대체합니다.
    • 이로써 테스트 대상을 다른 컴포넌트로부터 격리시켜 순수하게 테스트할 수 있습니다.
  2. 외부 시스템 의존성 제거
    • 데이터베이스, 외부 API 등 외부 시스템에 의존하는 컴포넌트를 테스트할 때 실제 연결 없이 테스트 가능합니다.
    • 예: 실제 DB 연결 없이 Repository 계층을 모킹하여 Service 계층 테스트 가능
  3. 테스트 속도 향상
    • 실제 구현체보다 모의 객체가 더 빠르게 동작합니다.
    • 외부 시스템 접근이나 복잡한 연산을 건너뛸 수 있습니다.
  4. 테스트 제어력 향상
    • 모의 객체의 응답을 프로그래밍적으로 정의하여 다양한 시나리오 테스트가 가능합니다.
    • 특정 예외 상황이나 에지 케이스를 쉽게 재현할 수 있습니다.
  5. Spring 컨텍스트와의 통합
    • 일반 Mockito @Mock과 달리, @MockBean은 Spring의 ApplicationContext에 등록됩니다.
    • 따라서 Spring의 의존성 주입 기능을 그대로 활용할 수 있습니다.

@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 사용 시 주의사항

  1. Spring 컨텍스트 캐싱 영향
    • @MockBean은 Spring 테스트 컨텍스트 캐싱에 영향을 줍니다.
    • 서로 다른 Mock 설정이 필요한 테스트 클래스들은 별도의 컨텍스트를 생성합니다.
  2. 성능 영향
    • @Mock보다 @MockBean이 Spring 컨텍스트를 활용하므로 더 무겁습니다.
    • 단순 유닛 테스트에는 @Mock+@InjectMocks 조합이 더 효율적일 수 있습니다.
  3. 모의 동작 설정
    • 각 테스트 메서드마다 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("재설정 링크")
    );
}

모범 테스트 관행

  1. 테스트 목적에 맞는 검증 방식 선택
    • 반환값이나 상태 변화 확인 → assert
    • 의존성과의 상호작용 확인 → verify
  2. 과도한 verify 사용 자제
    • 모든 메서드 호출을 verify하면 구현 세부사항에 과도하게 결합된 테스트가 됩니다.
    • 핵심적인 상호작용만 verify하고, 나머지는 상태 검증으로 대체하는 것이 좋습니다.
  3. 행위와 상태 검증 적절히 조합

    @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는 처음 봤는데 두 메서드의 차이와 목적, 상황에 따라 어떤 것을 사용하면 좋은 지를 배울 수 있었다.