테스트 코드(Test Code)

Jiwon Jung·2025년 12월 4일

스프링(Spring)

목록 보기
20/20

어디선가 개발 시간의 절반 이상을 테스트 코드 작성에 쓴다는 말을 들은 적이 있다.

테스트 주도 개발(Test-Driven Development, TDD)이라는 프로그래밍 방법론도 있는 만큼,
테스트 코드 작성은 아주 중요한 작업이라고 할 수 있겠다.

아직은 테스트 코드에 익숙하지 않기 때문에 오늘 배운 내용들을 잘 정리해둬야 할 것 같다.


🌱 테스트 주도 개발(Test-Driven Development, TDD)란?

테스트 주도 개발이란 먼저 실패하는 테스트 코드를 작성하고, 그 테스트를 통과시키기 위해 실제 코드를 작성하며 이후 테스트가 통과하면 코드를 정리하는 과정을 반복하는 소프트웨어 개발 방법론이다.

🔴 RED 단계에서는 실패하는 테스트 코드를 먼저 작성한다.
💚 GREEN 단계에서는 테스트 코드를 성공시키기 위한 실제 코드를 작성한다.
🔵 BLUE 단계에서는 중복 코드 제거, 일반화 등의 리팩토링을 수행한다.

중요한 것은 실패하는 테스트 코드를 작성할 때까지 실제 코드를 작성하지 않는 것과,
실패하는 테스트를 통과할 정도의 최소 실제 코드를 작성해야 하는 것이다.

이를 통해, 실제 코드에 대해 기대되는 바를 보다 명확하게 정의함으로써 불필요한 설계를 피할 수 있고, 정확한 요구 사항에 집중할 수 있다.

오늘은 테스트 코드가 무엇인지에 대해 알아볼 것이기 때문에 TDD에 대해서는 이쯤으로 마무리 하고 넘어가자.


🌱 테스트 피라미드

테스트에는 여러 단계가 존재하며, 이들 중 단위 테스트(Unit Test)가 가장 중요하다.

단위 테스트(Unit Test)

  • 하나의 클래스나 메소드를 독립적으로 테스트한다.

통합 테스트(Integration Test)

  • 여러 계층을 묶어 전체 플로우를 테스트한다.

종단 테스트(E2E Test)

  • 실제 사용자 시나리오 기반으로 테스트한다.

🌱 문법 정리


🌱 단위 테스트(Unit Test)

단위 테스트란 소프트웨어 개발 과정에서 가장 작은 코드 단위(메소드, 클래스)가 독립적으로 예상대로 동작하는지 검증하는 테스트 방법이다.

단위 테스트의 핵심은 독립성이다.
각 단위 테스트는 독립적으로 실행되어야 한다.

또 단위 테스트는 빠르고 정확해야 한다.
하지만 DB, 외부 API 등에 의존하면 테스트가 느려지고 실패 원인도 외부 요인과 섞여서 불명확해진다.

그래서 등장한 개념이 바로 Mock 객체(Mock Object)이다.

🥕 Mock 객체란?

실제 객체처럼 동작하지만, 테스트를 위해 만든 가짜 객체이다.
단위 테스트에서는 외부 의존성을 제거하기 위해 Mock 객체를 사용한다.

🥕 Mock을 쓰는 이유

  • 속도 향상: DB, 네트워크 없이 빠르게 테스트 가능하다.
  • 독립성 확보: 특정 클래스만 검증 할 수 있다.
  • 예측 가능성: Mock의 동작을 직접 지정할 수 있다.
  • 안정성 향상: 외부 장애나 환경 문제로 인한 테스트 실패를 방지할 수 있다.

🥕 Mock 주요 어노테이션

🥕 Mock이 꼭 필요한 건 아니다?

JwtUtil 클래스 같이 외부 의존성이 없는 경우에는 Mock이 굳이 필요하지 않다.

import static org.assertj.core.api.Assertions.assertThat;
import io.jsonwebtoken.Claims;
import org.example.nbcam_addvanced_1.common.enums.UserRoleEnum;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

class JwtUtilTest {

    private JwtUtil jwtUtil;

    private static final String SECRET_KEY = "mysecretkeymysecretkeymysecretkeymysecretkey";

    @BeforeEach
    void setUp() {
        jwtUtil = new JwtUtil();

        // 테스트용 secret key (Base64 인코딩된 256bit 키)
        String testSecretKey = "c2VjdXJldGVzdGtleW15c2VjdXJldGVzdGtleW15c2VjdXJldGVzdGtleQ==";

        // Reflection을 이용해 @Value 주입 대체
        ReflectionTestUtils.setField(jwtUtil, "secretKeyString", testSecretKey);

        // @PostConstruct 수동 호출
        jwtUtil.init();
    }

    @Test
    @DisplayName("JWT 토큰 생성 시 username과 role 정보가 정상적으로 포함된다.")
    void generateToken_정상생성_Claims확인() {
    
        // Given
        String username = "ravi";
        UserRoleEnum role = UserRoleEnum.ADMIN;

        // When
        String tokenWithPrefix = jwtUtil.generateToken(username, role);
        String pureToken = tokenWithPrefix.substring(JwtUtil.BEARER_PREFIX.length());

        // Then
        assertThat(tokenWithPrefix).startsWith("Bearer ");

        Claims claims = jwtUtil.getParser()
            .parseSignedClaims(pureToken)
            .getPayload();

        assertThat(claims.get("username", String.class)).isEqualTo(username);
        assertThat(claims.get("auth", String.class)).isEqualTo(role.name());
        assertThat(claims.getExpiration()).isAfter(new java.util.Date());
    }

}

반면 외부 의존성을 가지는 경우에는 Mock이 필요하다.

예를 들어, PostService는 내부 로직이 Repository를 의존하고 있다.

DB와 직접 연결 되는 순간 단위 테스트가 아니게 되므로,
Repository를 Mock으로 대체해야 테스트가 독립적으로 수행될 수 있다.

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class PostServiceTest {

    @Mock
    private PostRepository postRepository;
    @Mock
    private UserRepository userRepository;
    @InjectMocks
    private PostService postService;

    private User testUser;
    private Post testPost;

    @BeforeEach
    void setup() {
        testUser = new User("ravi", "1234", "ravi@example.com", 25, UserRoleEnum.ADMIN);
        testPost = new Post("테스트 게시글입니다.", testUser);
        ReflectionTestUtils.setField(testPost, "id", 1L);
    }

    @Test
    @DisplayName("게시글 생성 성공")
    void createPost_성공() {
        // Given
        when(userRepository.findByUsername("ravi")).thenReturn(Optional.of(testUser));
        when(postRepository.save(any(Post.class))).thenReturn(testPost);

        // When
        PostDto result = postService.createPost("ravi", "테스트 게시글입니다.");

        // Then
        assertThat(result.getContent()).isEqualTo("테스트 게시글입니다.");
        assertThat(result.getUsername()).isEqualTo("ravi");
        verify(postRepository, times(1)).save(any(Post.class));
    }
}

🌱 테스트 코드 작성 TIP

테스트 코드는 "이 기능이 이렇게 동작해야 한다."라는 명세서이자 안전망이라고 볼 수 있다.

좋은 테스트 코드는 자세히 보지 않아도 시스템의 의도를 이해할 수 있다.

🥕 @DisplayName을 잘 사용하기

@DisplayName를 이용해 의도를 분명하게 나타내자.

@Test
@DisplayName("사용자 저장 성공 시, DB에서 동일한 이름으로 조회된다")
void 사용자_저장_성공_조회_확인() {
    // Given
    User newUser = new User("ravi", "1234");

    // When
    userService.save(newUser);
    User foundUser = userService.findByUsername("ravi");

    // Then
    assertNotNull(foundUser);
    assertEquals("ravi", foundUser.getUsername());
}

🥕 Given-When-Then 패턴

  • Given: 테스트 실행을 준비하는 단계
  • When: 테스트를 진행하는 단계
  • Then: 테스트 결과를 검증하는 단계
@Test
@DisplayName("새로운 회원을 저장한다.")
public void 회원_저장_성공() {

    // given -> 회원을 저장하기 위한 준비 과정
    private String name = "Jane";
    private int age = 25;
    
    // when -> 실제로 회원을 저장
    User user = memberService.save(new User(name, age));
        
    // then -> 회원이 잘 추가 되었는지 검증
    User savedMember = memberService.findById(savedId).get();
    assertThat(savedMember.getName()).isEqualTo(name);
    assertThat(savedMember.getAge()).isEqualTo(age);
}

🥕 공용 테스트 데이터(Fixture) 관리

테스트 코드가 많아질수록 중복되는 데이터 또한 늘어난다.

예를 들어, new User("Jane", "1234")를 계속 생성하는 것도 이에 속한다.

이러한 중복된 코드를 줄이기 위해 공용 Fixture 또는 static 변수를 사용할 수 있다.

1️⃣ static 변수 활용

class UserServiceTest {

    private static final String DEFAULT_USERNAME = "Jane";
    private static final String DEFAULT_PASSWORD = "1234";

    private UserService userService;

    @BeforeEach
    void setup() {
        userService = new UserService();
    }

    @Test
    void 회원가입_성공() {
        User user = new User(DEFAULT_USERNAME, DEFAULT_PASSWORD);
        userService.save(user);

        assertEquals(1, userService.count());
    }

    @Test
    void 회원가입_중복_예외() {
        userService.save(new User(DEFAULT_USERNAME, DEFAULT_PASSWORD));

        assertThrows(DuplicateUserException.class,
            () -> userService.save(new User(DEFAULT_USERNAME, DEFAULT_PASSWORD)));
    }
}

static 변수는 모든 테스트 메소드에서 재사용이 가능하며, 테스트 데이터가 바뀌어도 한 곳만 수정하면 된다.

2️⃣ 별도의 Fixture 클래스 활용

그러나 프로젝트가 커질수록 static 변수보다는 Fixture 전용 클래스를 두는 편이 더 좋다.

public class UserFixture {
    public static final String DEFAULT_USERNAME = "Jane";
    public static final String DEFAULT_PASSWORD = "1234";

    public static User createUser() {
        return new User(DEFAULT_USERNAME, DEFAULT_PASSWORD);
    }
}
class UserServiceTest {

	private UserService userService;

    @BeforeEach
    void setup() {
        userService = new UserService();
    }

    @Test
    void 회원가입_성공() {
        User user = UserFixture.createUser();
        userService.save(user);

        assertEquals(1, userService.count());
    }
}

Fixture 클래스를 이용하면 중복이 제거되어 테스트 코드의 가독성이 증가하고, 여러 클래스에서 공통 데이터 재사용이 가능하다.

3️⃣ 공용 데이터 + @BeforeEach

위의 2번 방법에서 더 나아가, Fixture를 사용할 때도 @BeforeEach와 함께 쓰면 매번 깨끗한 초기 상태를 보장할 수 있다.

@BeforeEach
void setup() {
    userService = new UserService();
    testUser = UserFixture.createUser();
}

이렇게 하면 매 테스트마다 새로운 인스턴스를 생성해 테스트 간 데이터 간섭(Side-Effect)을 방지할 수 있다.


🌱 통합 테스트(Integration Test)

통합 테스트란 애플리케이션의 여러 구성 요소를 실제로 묶어 실행해 시스템 전체의 동작 흐름을 검증하는 테스트이다.

통합 테스트는 다음 계층들을 함께 테스트한다.

Controller -> Service -> Repository -> DB(H2)

🥕 주요 어노테이션

🥕 Service 통합 테스트 예시

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final UserRepository userRepository;

	  public PostDto creatPost(String username, String content) {

        User user = userRepository.findUserByUsername(username).orElseThrow(
            ()-> new IllegalArgumentException("등록된 사용자가 없습니다.")
        );

        Post post = postRepository.save(new Post(content, user));

        return PostDto.from(post);

    }
}
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Transactional
class PostServiceIntegrationTest {

    @Autowired
    private PostService postService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PostRepository postRepository;

    @Test
    @DisplayName("게시글 생성 통합 테스트 - 실제 DB에 저장 및 조회 검증")
    void createPost_통합테스트() {
    
        // Given
        User user = UserFixture.createUserAdminRole();
        userRepository.save(user);

        // When
        PostDto result = postService.createPost(user.getUsername(), "통합 테스트 게시글입니다.");

        // Then
        assertThat(result.getContent()).isEqualTo("통합 테스트 게시글입니다.");

        // 실제 DB에 저장되었는지 확인
        List<Post> savedPosts = postRepository.findAll();
        assertThat(savedPosts).hasSize(1);
        assertThat(savedPosts.get(0).getUser().getUsername()).isEqualTo("Jane");
    }
}

Mock 객체를 사용하지 않고 실제 Bean과 DB를 사용한 것을 볼 수 있다.

@Transactional로 테스트 후 자동으로 롤백한다.

🥕 Controller 통합 테스트(MockMvc) 예시

MockMvc를 사용하면 실제 HTTP 요청처럼 Controller를 테스트 할 수 있다.
브라우저나 포스트맨 없이 Spring MVC 동작을 시뮬레이션 한다.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;


@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class PostControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private JwtUtil jwtUtil;

    private String token;

    @BeforeEach
    void setup() {
        User user = UserFixture.createUserAdminRole();
        userRepository.save(user);
        token = jwtUtil.generateToken(user.getUsername(), user.getRole());
    }

    @Test
    @DisplayName("POST /api/post - 게시글 생성 요청 성공")
    void createPost_요청성공() throws Exception {
        String requestBody = """
            { "username": "Jane", "content": "통합 테스트 게시글" }
        """;

        mockMvc.perform(post("/api/post")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", token)
                .content(requestBody))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content").value("통합 테스트 게시글"))
            .andExpect(jsonPath("$.username").value("ravi"));
    }
}

MockMvc는 실제 HTTP 요청을 흉내낸다.

위 코드를 실행하면 DispatcherServlet, Controller, Servie, Repository 계층이 모두 실행된다.
DB도 실제로 접근 하지만, 테스트 후 자동으로 롤백된다.


🌱 단위 테스트 vs 통합 테스트

1개의 댓글

comment-user-thumbnail
2025년 12월 4일

낮에 올렸나봐.. 아직 따뜻해..

답글 달기