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