개발을 하다 보면 “테스트 코드는 정말 꼭 작성해야 할까?”라는 질문을 종종 하게 됩니다. 하지만 시간이 지날수록, 기능이 많아지고 복잡도가 높아질수록 테스트 코드의 중요성은 점점 더 커집니다. 이번 글에서는 테스트 코드를 왜 작성해야 하는지, 그리고 각 테스트의 종류와 작성 방법에 대해 정리해 보겠습니다.
테스트를 작성하는 이유
1. 회귀 테스트(Regression Test)의 핵심 도구
테스트 코드를 작성하는 가장 큰 이유 중 하나는 회귀 테스트입니다.
기능 개발 당시의 검증 목적도 있지만, 시간이 지난 후 기존 코드를 수정했을 때 예상치 못한 사이드 이펙트를 빠르게 발견할 수 있습니다.
즉, 테스트는 개발자가 놓치기 쉬운 부분을 자동으로 검증해주는 방어막 역할을 합니다.
2. 코드의 의도를 설명하는 문서
테스트 코드는 그 자체로도 하나의 문서입니다.
특정 기능이나 메서드가 어떤 조건에서 어떤 결과를 기대하는지를 테스트 코드만 봐도 알 수 있기 때문에, 코드를 이해하는 데 도움이 되는 중요한 단서가 됩니다.
테스트의 종류
테스트는 흔히 “테스트 피라미드” 구조로 분류됩니다. 아래로 갈수록 실행 속도는 빠르고, 위로 갈수록 실제 사용자 관점에 가까워집니다.
- 단위 테스트 (Unit Test)
- 하나의 메서드 또는 클래스 등 최소 단위의 로직을 테스트
- 빠르고 독립적으로 실행됨
- 통합 테스트 (Integration Test)
- DB, 외부 API 등 실제 인프라 또는 다른 모듈과의 연결을 포함한 테스트
- 단위 테스트보다는 느리지만, 복잡한 흐름 검증 가능
- E2E 테스트 (End-to-End Test)
- 브라우저나 앱을 통해 전체 사용자 플로우를 시나리오 기반으로 테스트
- 실제 유저 관점에서 기능을 보장할 수 있음
단위 테스트를 작성하는 방법
- Java 환경에서는 일반적으로 JUnit을 사용합니다.
- 각 메서드의 입력과 출력을 검증하고, 의존성은 Mocking을 통해 격리합니다.
- 예시 도구:
JUnit 5,Mockito,AssertJ
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class PointServiceTest {
private final PointService pointService = new PointService();
@Test
void 보유포인트보다_적은금액을_차감요청하면_정상적으로_차감된다() {
// given
int 보유포인트 = 1000;
int 차감요청 = 300;
// when
int 결과 = pointService.deduct(보유포인트, 차감요청);
// then
assertThat(결과).isEqualTo(700);
}
@Test
void 보유포인트보다_많은금액을_차감요청하면_예외가_발생한다() {
// given
int 보유포인트 = 500;
int 차감요청 = 1000;
// when & then
assertThatThrownBy(() -> pointService.deduct(보유포인트, 차감요청))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("보유 포인트보다 많이 차감할 수 없습니다.");
}
}
통합 테스트 작성하는 방법
통합 테스트는 실제 시스템 간 상호작용을 테스트합니다.
- DB와 연동되는 테스트
- 테스트용 DB를 로컬에서 직접 사용하거나, Testcontainers를 활용하여 실제 DB 환경을 Docker로 띄우고 테스트할 수 있습니다.
- 외부 API와 연동되는 테스트
- 외부 API를 MockServer 또는 WireMock 등으로 가상화하거나, 실제 호출로 동작 확인
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgresDB = new PostgreSQLContainer<>("postgres:13")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private UserRepository userRepository;
@DynamicPropertySource
static void overrideProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgresDB::getJdbcUrl);
registry.add("spring.datasource.username", postgresDB::getUsername);
registry.add("spring.datasource.password", postgresDB::getPassword);
}
@Test
void 유저_엔티티를_저장하면_아이디로_조회할_수_있다() {
// given
User user = new User();
user.setEmail("test@example.com");
// when
userRepository.save(user);
// then
Optional<User> found = userRepository.findById(user.getId());
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}
}
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class WeatherServiceIntegrationTest {
private static final MockWebServer mockWebServer = new MockWebServer();
@Autowired
private WeatherService weatherService;
@DynamicPropertySource
static void overrideBaseUrl(DynamicPropertyRegistry registry) throws IOException {
mockWebServer.start();
registry.add("weather.api.base-url", () -> mockWebServer.url("/").toString());
}
@BeforeEach
void setupMockResponse() {
mockWebServer.enqueue(new MockResponse()
.setBody("""
{
"city": "Seoul",
"temperature": 26
}
""")
.addHeader("Content-Type", "application/json"));
}
@Test
void 도시명으로_날씨조회시_API호출후_온도반환() {
int temp = weatherService.getWeather("Seoul");
assertThat(temp).isEqualTo(26);
}
@AfterAll
static void shutdown() throws IOException {
mockWebServer.shutdown();
}
}
E2E 테스트 작성하는 방법
- 전체 시스템이 실행된 상태에서 브라우저나 앱을 통해 사용자 시나리오를 그대로 따라가는 테스트
- 실제 버튼 클릭, 페이지 이동 등 UI 이벤트 기반 테스트
@SpringBootTest
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class UserRegistrationE2ETest {
@Autowired
private MockMvc mockMvc;
@Test
void 회원가입_및_로그인_시나리오_테스트() throws Exception {
// 1. 회원가입 요청
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"email": "test@example.com",
"password": "secure123!",
"nickname": "tester"
}
"""))
.andExpect(status().isOk());
// 2. 로그인 요청 → JWT 반환
MvcResult result = mockMvc.perform(post("/api/users/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"email": "test@example.com",
"password": "secure123!"
}
"""))
.andExpect(status().isOk())
.andReturn();
String response = result.getResponse().getContentAsString();
String token = JsonPath.read(response, "$.token");
// 3. 로그인된 상태로 마이페이지 접근
mockMvc.perform(get("/api/users/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("test@example.com"));
}
}
정리
- 테스트는 회귀 방지 + 문서 역할이라는 두 가지 이유만으로도 충분히 작성할 가치가 있습니다.
- 테스트 피라미드 구조를 고려하여, 빠르고 신뢰할 수 있는 테스트 전략을 설계하는 것이 중요합니다.
- 처음부터 완벽한 테스트 커버리지를 목표로 하기보다는, 가장 중요한 흐름부터 테스트를 작성하는 습관을 들이는 것이 핵심입니다.