개발을 하다 보면 “테스트 코드는 정말 꼭 작성해야 할까?”라는 질문을 종종 하게 됩니다. 하지만 시간이 지날수록, 기능이 많아지고 복잡도가 높아질수록 테스트 코드의 중요성은 점점 더 커집니다. 이번 글에서는 테스트 코드를 왜 작성해야 하는지, 그리고 각 테스트의 종류와 작성 방법에 대해 정리해 보겠습니다.

테스트를 작성하는 이유

1. 회귀 테스트(Regression Test)의 핵심 도구

테스트 코드를 작성하는 가장 큰 이유 중 하나는 회귀 테스트입니다.

기능 개발 당시의 검증 목적도 있지만, 시간이 지난 후 기존 코드를 수정했을 때 예상치 못한 사이드 이펙트를 빠르게 발견할 수 있습니다.

즉, 테스트는 개발자가 놓치기 쉬운 부분을 자동으로 검증해주는 방어막 역할을 합니다.

2. 코드의 의도를 설명하는 문서

테스트 코드는 그 자체로도 하나의 문서입니다.

특정 기능이나 메서드가 어떤 조건에서 어떤 결과를 기대하는지를 테스트 코드만 봐도 알 수 있기 때문에, 코드를 이해하는 데 도움이 되는 중요한 단서가 됩니다.

테스트의 종류

스크린샷

테스트는 흔히 “테스트 피라미드” 구조로 분류됩니다. 아래로 갈수록 실행 속도는 빠르고, 위로 갈수록 실제 사용자 관점에 가까워집니다.

  1. 단위 테스트 (Unit Test)
    • 하나의 메서드 또는 클래스 등 최소 단위의 로직을 테스트
    • 빠르고 독립적으로 실행됨
  2. 통합 테스트 (Integration Test)
    • DB, 외부 API 등 실제 인프라 또는 다른 모듈과의 연결을 포함한 테스트
    • 단위 테스트보다는 느리지만, 복잡한 흐름 검증 가능
  3. 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"));
    }
}

정리

  • 테스트는 회귀 방지 + 문서 역할이라는 두 가지 이유만으로도 충분히 작성할 가치가 있습니다.
  • 테스트 피라미드 구조를 고려하여, 빠르고 신뢰할 수 있는 테스트 전략을 설계하는 것이 중요합니다.
  • 처음부터 완벽한 테스트 커버리지를 목표로 하기보다는, 가장 중요한 흐름부터 테스트를 작성하는 습관을 들이는 것이 핵심입니다.

참고

https://semaphore.io/blog/testing-pyramid