Test Code
2025-06-13
테스트는 요구사항에 따라 정확하게 동작하는 지 확인하는 과정이다.
테스트의 필요성
-
Quality Assurance: 예상치 못한 오류나 버그를 사전에 발견 후 품질 보증
-
Regression Prevention: 회귀 버그 방지
-
회귀 버그
새로운 기능을 추가하거나 기존 코드를 수정할 때, 이로 인해 기존에 잘 동작하던 기능에 문제가 발생하는 것
-
-
Design Improvement:
- 보통 ‘테스트하기 어렵다?’ → 설계가 이상하다.
- ‘테스트하기 쉬운 코드’ → 보통 좋은 설계로 이어짐.
-
Documentation: 테스트 코드 그 자체로 문서의 역할을 할 수 있어야함.
- 문서 역할을 하지 못하면, 잘 작성하지 못한 테스트 코드임.
-
Increased Development Speed: 초기 버그를 빠르게 파악하기 떄문에 유지보수 비용 절감 가능.
좋은 테스트 코드란
F.I.R.S.T 원칙
Fast: 테스트는 자주 실행되므로 빠르게 동작해야 합니다.
Independent: 각 테스트는 다른 테스트의 실행 결과에 영향을 받지 않아야 합니다.
Repeatable: 어떤 환경에서든, 몇 번을 실행하든 항상 동일한 결과를 내야 합니다.
Self-Validating: 테스트는 성공 또는 실패를 명확하게 알려주어야 합니다.
Timely: 테스트할 코드를 작성하기 직전 또는 작성하면서 함께 작성되어야 합니다.
특성
- Readability: 테스트 코드는 다른 개발자가 쉽게 이해할 수 있도록 명확하고 간결해야 합니다.
- Maintainability: 기능 코드가 변경될 때, 테스트 코드도 너무 많은 수정이 필요하지 않도록 작성되어야 합니다.
- Reliability: 테스트 코드는 항상 정확한 결과를 보고해야 합니다. (false positive, true positive가 없어야함.)
Unit Test
단위 테스트는 애플리케이션에서 가장 작은 단위의 코드(메소드, 클래스)를 독립적으로 테스트하는 것이다.
- 다른 의존성으로부터 완전히 격리 필요
- 해당 단위의 기능만을 검증하는 데 집중
- Spring 컨텍스트 로드 X
Spring Boot 기본 테스트 환경
spring-boot-starter-test
- JUnit 5: 테스트 코드를 작성하고 실행하는 데 필요한 기본적인 기능 제공
- Mockito: 객체를 mocking할 수 있도록 지원하는 라이브러리
- AssertJ: assertion 수행을 도와주는 라이브러리
- Spring Test: Spring 컨텍스트 로드, Spring 테스트 유틸리티 제공(
@SpringBootTest
,MockMvc
...) - Hamcrest: Assertion을 위한 Matcher 프레임워크 (AssertJ를 더 권장)
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform() // JUnit 5를 사용하도록 설정
}
JUnit 5 테스트 구조
- 일반적으로
src/test/java
디렉토리에 위치 - 테스트할 클래스 이름 뒤에
Test
를 붙이는 것이 관례 (UserServiceTest
,ProductControllerTest
) - 테스트 메소드 이름은 테스트하려는 기능의 설명과 기대 결과를 명확하게 나타내도록 작성 (
createUser_shouldReturnNewUser
) - 테스트 메소드에는
@Test
어노테이션 작성
테스트 코드 예시
// src/test/java/com/example/myspringtestproject/ExampleTest.java
package com.example.myspringtestproject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
// 단독으로 실행되는 간단한 JUnit 5 테스트 예시
class ExampleTest {
@Test // 이 메소드가 테스트 메소드임을 나타냅니다.
@DisplayName("덧셈 연산이 올바르게 동작하는지 테스트") // 테스트의 목적을 명확히 설명합니다.
void testAddition() {
// Given (테스트를 위한 준비)
int a = 5;
int b = 3;
// When (테스트 대상 메소드 실행)
int sum = a + b;
// Then (결과 검증)
// AssertJ의 assertThat을 사용하여 가독성 좋게 검증할 수 있습니다.
// import static org.assertj.core.api.Assertions.assertThat;
// assertThat(sum).isEqualTo(8);
Assertions.assertEquals(8, sum, "5 + 3은 8이어야 합니다.");
}
@Test
@DisplayName("문자열 길이를 정확히 반환하는지 테스트")
void testStringLength() {
// Given
String text = "hello";
// When
int length = text.length();
// Then
Assertions.assertEquals(5, length);
}
}
Given-When-Then 패턴
- Given: 테스트를 위한 초기 상태 설정 (필요한 객체 생성, Mocking 설정 등)
- When: 테스트 대상 메소드 실행 (실제 기능 호출)
- Then: 실행 결과 검증 (AssertJ, JUnit의
Assertions
활용)
class ExampleTest {
@Test
@DisplayName("덧셈 연산이 올바르게 동작하는지 테스트")
void testAddition() {
// Given (테스트를 위한 준비)
int a = 5;
int b = 3;
// When (테스트 대상 메소드 실행)
int sum = a + b;
// Then (결과 검증)
assertThat(sum).isEqualTo(8);
}
}
실제 Spring Boot 애플리케이션 테스트 예제
-
도메인 모델 (User.java)
// src/main/java/com/example/myspringtestproject/user/User.java package com.example.myspringtestproject.user; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class User { private Long id; private String name; private String email; }
-
JPA 인터페이스 (UserRepository.java)
// src/main/java/com/example/myspringtestproject/user/UserRepository.java package com.example.myspringtestproject.user; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); }
-
서비스 (UserService.java)
// src/main/java/com/example/myspringtestproject/user/UserService.java package com.example.myspringtestproject.user; import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; import org.springframework.transaction.annotation.Transactional; @Service @Transactional public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public User createUser(User user) { if (userRepository.findByEmail(user.getEmail()).isPresent()) { throw new IllegalArgumentException("이미 사용 중인 이메일입니다."); } return userRepository.save(user); } public Optional<User> getUserById(Long id) { return userRepository.findById(id); } public List<User> getAllUsers() { return userRepository.findAll(); } public User updateUser(Long id, User updatedUser) { return userRepository.findById(id) .map(existingUser -> { existingUser.setName(updatedUser.getName()); existingUser.setEmail(updatedUser.getEmail()); return userRepository.save(existingUser); }) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다. ID: " + id)); } public void deleteUser(Long id) { userRepository.deleteById(id); } }
// src/test/java/com/example/myspringtestproject/user/UserServiceTest.java
package com.example.myspringtestproject.user;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // Mockito를 JUnit 5와 함께 사용하기 위한 확장
class UserServiceTest {
@Mock // 이 필드가 Mock 객체임을 나타냅니다.
private UserRepository userRepository;
@InjectMocks // Mock 객체들이 주입될 실제 객체(테스트 대상)
private UserService userService;
// 각 테스트 메소드 실행 전에 초기화 로직이 필요하다면 사용
@BeforeEach
void setUp() {
// MockitoAnnotations.openMocks(this); // @ExtendWith(MockitoExtension.class)를 사용하면 필요 없음
}
@Test
@DisplayName("새로운 사용자를 생성한다.")
void createUser_ValidUser_ReturnsNewUser() {
// Given
User newUser = new User(null, "John Doe", "[email protected]");
User savedUser = new User(1L, "John Doe", "[email protected]");
// Mocking: userRepository.save(any(User.class))가 호출되면 savedUser를 반환하도록 설정
// any(User.class): 어떤 User 객체가 넘어와도 이 Stubbing이 동작하도록 합니다.
when(userRepository.findByEmail(newUser.getEmail())).thenReturn(Optional.empty()); // 이메일 중복 없음
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// When
User result = userService.createUser(newUser);
// Then
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getName()).isEqualTo("John Doe");
assertThat(result.getEmail()).isEqualTo("[email protected]");
// Mocking: save 메소드가 정확히 한 번 호출되었는지 검증
verify(userRepository, times(1)).save(any(User.class));
verify(userRepository, times(1)).findByEmail(newUser.getEmail());
}
@Test
@DisplayName("이미 존재하는 이메일로 사용자 생성 시 예외가 발생한다.")
void createUser_ExistingEmail_ThrowsException() {
// Given
User existingUser = new User(1L, "Jane Doe", "[email protected]");
User newUser = new User(null, "John Doe", "[email protected]"); // 이미 존재하는 이메일
// Mocking: findByEmail 호출 시 Optional.of(existingUser) 반환하도록 설정
when(userRepository.findByEmail(newUser.getEmail())).thenReturn(Optional.of(existingUser));
// When & Then
// assertThatThrownBy를 사용하여 특정 예외가 발생하는지 검증
assertThatThrownBy(() -> userService.createUser(newUser))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("이미 사용 중인 이메일입니다.");
// save 메소드는 호출되지 않았음을 검증
verify(userRepository, never()).save(any(User.class));
verify(userRepository, times(1)).findByEmail(newUser.getEmail());
}
@Test
@DisplayName("ID로 사용자를 조회한다.")
void getUserById_ExistingId_ReturnsUser() {
// Given
Long userId = 1L;
User user = new User(userId, "Test User", "[email protected]");
// Mocking: findById 호출 시 Optional.of(user) 반환하도록 설정
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// When
Optional<User> result = userService.getUserById(userId);
// Then
assertThat(result).isPresent(); // Optional이 값을 가지고 있는지 확인
assertThat(result.get().getName()).isEqualTo("Test User");
// Mocking: findById 메소드가 정확히 한 번 호출되었는지 검증
verify(userRepository, times(1)).findById(userId);
}
@Test
@DisplayName("존재하지 않는 ID로 사용자 조회 시 Optional.empty()를 반환한다.")
void getUserById_NonExistingId_ReturnsEmptyOptional() {
// Given
Long userId = 99L;
// Mocking: findById 호출 시 Optional.empty() 반환하도록 설정
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// When
Optional<User> result = userService.getUserById(userId);
// Then
assertThat(result).isEmpty(); // Optional이 비어 있는지 확인
verify(userRepository, times(1)).findById(userId);
}
@Test
@DisplayName("모든 사용자를 조회한다.")
void getAllUsers_ReturnsListOfUsers() {
// Given
List<User> users = Arrays.asList(
new User(1L, "User1", "[email protected]"),
new User(2L, "User2", "[email protected]")
);
// Mocking: findAll 호출 시 users 리스트 반환하도록 설정
when(userRepository.findAll()).thenReturn(users);
// When
List<User> result = userService.getAllUsers();
// Then
assertThat(result).hasSize(2);
assertThat(result.get(0).getName()).isEqualTo("User1");
verify(userRepository, times(1)).findAll();
}
}