콘텐츠 대표 이미지 - ☕ 자바 테스팅의 모든 것

☕ 자바 테스팅의 모든 것

JUnit과 Mockito로 완성하는 단위·통합 테스트 마스터 가이드

🎯 왜 테스트 코드를 작성해야 할까?

친구야, 솔직히 말해볼게. 개발하다 보면 "에이, 테스트 코드는 나중에 써야지~" 하면서 미루게 되잖아? 나도 그랬어. 근데 말이야, 프로젝트가 커지고 코드가 복잡해질수록 테스트 코드 없이는 정말 답이 없더라고. 😅

테스트 코드는 단순히 버그를 찾는 도구가 아니야. 이건 코드의 안전망이자, 리팩토링의 자신감이고, 문서화의 수단이기도 해. 새로운 기능을 추가할 때마다 기존 기능이 망가지지 않았는지 확인하려면 수동으로 모든 걸 테스트해야 하는데, 그게 얼마나 비효율적인지 알지?

🎪 실제 현장 이야기

내가 예전에 참여했던 프로젝트에서는 테스트 코드가 전혀 없었어. 결제 시스템을 수정했는데, 배포 후에 회원가입 기능이 망가진 걸 고객 신고로 알게 됐지. 완전 식은땀 났지 뭐야. 그 이후로 팀 전체가 테스트 주도 개발(TDD)을 도입했고, 배포 자신감이 완전히 달라졌어. 💪

자바 생태계에서 테스팅의 양대 산맥은 바로 JUnitMockito야. JUnit은 테스트 프레임워크의 표준이고, Mockito는 의존성을 모킹(mocking)해서 격리된 테스트를 가능하게 해주는 라이브러리지. 이 둘을 제대로 활용하면 단위 테스트부터 통합 테스트까지 완벽하게 커버할 수 있어.

JUnit 테스트 프레임워크 Mockito 모킹 라이브러리 완벽한 조합 단위 + 통합 테스트 자바 테스팅 생태계의 핵심 도구들

📚 JUnit 5 완전 정복하기

JUnit 5는 JUnit 4의 후속 버전으로, 2017년에 출시됐어. 완전히 새롭게 설계된 아키텍처를 가지고 있고, 모듈화되어 있어서 필요한 부분만 선택해서 사용할 수 있지. JUnit 5는 크게 세 가지 모듈로 구성돼 있어:

🔹 JUnit Platform: JVM에서 테스트 프레임워크를 실행하기 위한 기반
🔹 JUnit Jupiter: JUnit 5의 새로운 프로그래밍 모델과 확장 모델
🔹 JUnit Vintage: JUnit 3, 4로 작성된 테스트를 실행하기 위한 엔진

🚀 기본 설정과 첫 테스트

먼저 Maven이나 Gradle에 의존성을 추가해야 해. 요즘은 대부분 Gradle을 사용하니까 Gradle 기준으로 설명할게.

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.9.3'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

이제 첫 테스트를 작성해볼까? 간단한 계산기 클래스를 만들고 테스트해보자.

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public double divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("0으로 나눌 수 없습니다");
        }
        return (double) a / b;
    }
}

이제 테스트 코드를 작성해보자. JUnit 5에서는 @Test 어노테이션을 사용해서 테스트 메서드를 정의해.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    
    @Test
    @DisplayName("두 숫자의 덧셈이 정확히 계산되어야 한다")
    void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(5, 3);
        assertEquals(8, result);
    }
    
    @Test
    @DisplayName("두 숫자의 뺄셈이 정확히 계산되어야 한다")
    void testSubtract() {
        Calculator calculator = new Calculator();
        int result = calculator.subtract(10, 4);
        assertEquals(6, result);
    }
    
    @Test
    @DisplayName("0으로 나누면 예외가 발생해야 한다")
    void testDivideByZero() {
        Calculator calculator = new Calculator();
        assertThrows(IllegalArgumentException.class, () -> {
            calculator.divide(10, 0);
        });
    }
}
💡 DisplayName의 힘

@DisplayName 어노테이션은 정말 유용해. 테스트 메서드 이름은 영어로 작성하되, DisplayName으로 한글 설명을 추가하면 테스트 결과를 볼 때 훨씬 이해하기 쉬워. 특히 팀원들과 협업할 때 테스트의 의도를 명확하게 전달할 수 있지. 🎯
🎪 생명주기 어노테이션 활용하기

JUnit 5는 테스트의 생명주기를 관리하는 여러 어노테이션을 제공해. 이걸 잘 활용하면 테스트 코드의 중복을 줄이고 효율적으로 관리할 수 있어.

import org.junit.jupiter.api.*;

class LifecycleTest {
    
    @BeforeAll
    static void initAll() {
        // 모든 테스트 실행 전 한 번만 실행
        System.out.println("테스트 클래스 초기화");
    }
    
    @BeforeEach
    void init() {
        // 각 테스트 메서드 실행 전마다 실행
        System.out.println("테스트 메서드 준비");
    }
    
    @Test
    void testOne() {
        System.out.println("테스트 1 실행");
    }
    
    @Test
    void testTwo() {
        System.out.println("테스트 2 실행");
    }
    
    @AfterEach
    void tearDown() {
        // 각 테스트 메서드 실행 후마다 실행
        System.out.println("테스트 메서드 정리");
    }
    
    @AfterAll
    static void tearDownAll() {
        // 모든 테스트 실행 후 한 번만 실행
        System.out.println("테스트 클래스 정리");
    }
}
🎬 실행 순서 이해하기

위 코드의 실행 순서는 이래:

1. initAll() - 한 번
2. init() → testOne() → tearDown()
3. init() → testTwo() → tearDown()
4. tearDownAll() - 한 번

이 패턴을 이해하면 데이터베이스 연결, 파일 열기/닫기 같은 리소스 관리를 효율적으로 할 수 있어. 😊
🔍 다양한 Assertion 메서드들

JUnit 5는 정말 다양한 assertion 메서드를 제공해. 이걸 잘 활용하면 테스트 코드가 훨씬 명확해지고 가독성도 좋아져.

import static org.junit.jupiter.api.Assertions.*;

class AssertionTest {
    
    @Test
    void basicAssertions() {
        // 기본 동등성 검사
        assertEquals(2, 1 + 1);
        assertNotEquals(3, 1 + 1);
        
        // 참/거짓 검사
        assertTrue(5 > 3);
        assertFalse(5 < 3);
        
        // null 검사
        String str = null;
        assertNull(str);
        str = "Hello";
        assertNotNull(str);
        
        // 같은 객체 참조 검사
        String a = "test";
        String b = a;
        assertSame(a, b);
    }
    
    @Test
    void arrayAssertions() {
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};
        assertArrayEquals(expected, actual);
    }
    
    @Test
    void exceptionAssertions() {
        Exception exception = assertThrows(
            ArithmeticException.class,
            () -> {
                int result = 10 / 0;
            }
        );
        assertEquals("/ by zero", exception.getMessage());
    }
    
    @Test
    void timeoutAssertions() {
        // 1초 안에 완료되어야 함
        assertTimeout(Duration.ofSeconds(1), () -> {
            Thread.sleep(500);
        });
    }
    
    @Test
    void groupedAssertions() {
        // 여러 assertion을 그룹화
        assertAll("person",
            () -> assertEquals("John", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName()),
            () -> assertEquals(30, person.getAge())
        );
    }
}
✨ assertAll의 마법

assertAll은 정말 유용한 기능이야. 일반적으로 assertion이 실패하면 그 즉시 테스트가 중단되는데, assertAll을 사용하면 모든 assertion을 실행하고 실패한 것들을 한꺼번에 보여줘. 여러 필드를 검증할 때 특히 유용하지! 🎉
🎲 파라미터화된 테스트

같은 로직을 다양한 입력값으로 테스트하고 싶을 때가 있지? 그럴 때 파라미터화된 테스트를 사용하면 코드 중복을 엄청 줄일 수 있어.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

class ParameterizedTests {
    
    @ParameterizedTest
    @ValueSource(strings = {"hello", "world", "junit"})
    void testWithValueSource(String word) {
        assertNotNull(word);
        assertTrue(word.length() > 0);
    }
    
    @ParameterizedTest
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "10, 20, 30"
    })
    void testAddition(int a, int b, int expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.add(a, b));
    }
    
    @ParameterizedTest
    @MethodSource("provideStringsForIsBlank")
    void testIsBlank(String input, boolean expected) {
        assertEquals(expected, input == null || input.trim().isEmpty());
    }
    
    static Stream<Arguments> provideStringsForIsBlank() {
        return Stream.of(
            Arguments.of(null, true),
            Arguments.of("", true),
            Arguments.of("  ", true),
            Arguments.of("not blank", false)
        );
    }
    
    @ParameterizedTest
    @EnumSource(Month.class)
    void testWithEnumSource(Month month) {
        assertNotNull(month);
    }
}

파라미터화된 테스트는 정말 강력해. 특히 경계값 테스트나 다양한 입력 케이스를 검증할 때 코드가 훨씬 깔끔해지거든. 😎

🎭 Mockito로 의존성 모킹하기

자, 이제 Mockito 이야기를 해볼까? 실제 프로젝트에서는 클래스들이 서로 복잡하게 얽혀있어. 데이터베이스, 외부 API, 파일 시스템 등 다양한 의존성이 존재하지. 이런 의존성들을 실제로 사용하면서 테스트하면 어떤 문제가 생길까?

테스트가 느려져
외부 시스템의 상태에 따라 테스트 결과가 달라져
테스트 환경 구축이 복잡해져
단위 테스트가 아니라 통합 테스트가 돼버려

이럴 때 Mockito가 등장해! Mockito는 가짜 객체(Mock Object)를 만들어서 실제 의존성을 대체할 수 있게 해줘. 이렇게 하면 테스트하려는 클래스만 격리해서 순수하게 테스트할 수 있지.

🎪 Mockito 기본 사용법

먼저 의존성을 추가하자. JUnit 5와 함께 사용할 거니까 mockito-junit-jupiter도 함께 추가해.

dependencies {
    testImplementation 'org.mockito:mockito-core:5.3.1'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.3.1'
}

간단한 예제로 시작해보자. 사용자 서비스가 사용자 저장소에 의존하는 상황을 가정해볼게.

// 사용자 엔티티
public class User {
    private Long id;
    private String name;
    private String email;
    
    // 생성자, getter, setter 생략
}

// 사용자 저장소 인터페이스
public interface UserRepository {
    User findById(Long id);
    void save(User user);
    boolean existsByEmail(String email);
}

// 사용자 서비스
public class UserService {
    private final UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    public User getUser(Long id) {
        return userRepository.findById(id);
    }
    
    public void registerUser(User user) {
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new IllegalArgumentException("이미 존재하는 이메일입니다");
        }
        userRepository.save(user);
    }
}

이제 UserService를 테스트해보자. UserRepository의 실제 구현체 없이 Mock을 사용해서 테스트할 거야.

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 static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserService userService;
    
    @Test
    @DisplayName("사용자 ID로 사용자를 조회할 수 있다")
    void testGetUser() {
        // Given: 테스트 데이터 준비
        Long userId = 1L;
        User mockUser = new User(userId, "홍길동", "hong@example.com");
        
        // Mock 동작 정의
        when(userRepository.findById(userId)).thenReturn(mockUser);
        
        // When: 테스트 실행
        User result = userService.getUser(userId);
        
        // Then: 결과 검증
        assertNotNull(result);
        assertEquals("홍길동", result.getName());
        assertEquals("hong@example.com", result.getEmail());
        
        // Mock 메서드가 호출되었는지 검증
        verify(userRepository, times(1)).findById(userId);
    }
    
    @Test
    @DisplayName("중복된 이메일로 회원가입하면 예외가 발생한다")
    void testRegisterUserWithDuplicateEmail() {
        // Given
        User newUser = new User(null, "김철수", "existing@example.com");
        when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
        
        // When & Then
        assertThrows(IllegalArgumentException.class, () -> {
            userService.registerUser(newUser);
        });
        
        // save 메서드가 호출되지 않았는지 검증
        verify(userRepository, never()).save(any(User.class));
    }
    
    @Test
    @DisplayName("새로운 이메일로 회원가입이 성공한다")
    void testRegisterUserSuccess() {
        // Given
        User newUser = new User(null, "이영희", "new@example.com");
        when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
        
        // When
        userService.registerUser(newUser);
        
        // Then
        verify(userRepository, times(1)).existsByEmail("new@example.com");
        verify(userRepository, times(1)).save(newUser);
    }
}
💡 Given-When-Then 패턴

테스트 코드를 작성할 때 Given-When-Then 패턴을 사용하면 가독성이 훨씬 좋아져.

Given: 테스트를 위한 준비 단계 (데이터, Mock 동작 정의)
When: 실제 테스트할 동작 실행
Then: 결과 검증

이 패턴은 테스트의 의도를 명확하게 전달하고, 다른 개발자가 코드를 이해하기 쉽게 만들어줘. 🎯
🎨 Mockito의 다양한 기능들

Mockito는 정말 다양한 기능을 제공해. 하나씩 살펴보자!

class MockitoAdvancedTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Test
    @DisplayName("ArgumentCaptor로 전달된 인자 검증하기")
    void testArgumentCaptor() {
        UserService userService = new UserService(userRepository);
        User user = new User(null, "테스트", "test@example.com");
        
        when(userRepository.existsByEmail(anyString())).thenReturn(false);
        
        userService.registerUser(user);
        
        // ArgumentCaptor로 save 메서드에 전달된 인자 캡처
        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
        verify(userRepository).save(userCaptor.capture());
        
        User capturedUser = userCaptor.getValue();
        assertEquals("테스트", capturedUser.getName());
        assertEquals("test@example.com", capturedUser.getEmail());
    }
    
    @Test
    @DisplayName("연속된 호출에 대해 다른 값 반환하기")
    void testConsecutiveCalls() {
        when(userRepository.findById(1L))
            .thenReturn(new User(1L, "첫번째", "first@example.com"))
            .thenReturn(new User(1L, "두번째", "second@example.com"));
        
        User first = userRepository.findById(1L);
        User second = userRepository.findById(1L);
        
        assertEquals("첫번째", first.getName());
        assertEquals("두번째", second.getName());
    }
    
    @Test
    @DisplayName("예외 던지기")
    void testThrowException() {
        when(userRepository.findById(999L))
            .thenThrow(new RuntimeException("사용자를 찾을 수 없습니다"));
        
        assertThrows(RuntimeException.class, () -> {
            userRepository.findById(999L);
        });
    }
    
    @Test
    @DisplayName("void 메서드에 대한 예외 설정")
    void testVoidMethodException() {
        User user = new User(1L, "테스트", "test@example.com");
        
        doThrow(new RuntimeException("저장 실패"))
            .when(userRepository).save(user);
        
        assertThrows(RuntimeException.class, () -> {
            userRepository.save(user);
        });
    }
    
    @Test
    @DisplayName("Answer를 사용한 복잡한 동작 정의")
    void testAnswer() {
        when(userRepository.findById(anyLong())).thenAnswer(invocation -> {
            Long id = invocation.getArgument(0);
            return new User(id, "사용자" + id, "user" + id + "@example.com");
        });
        
        User user = userRepository.findById(5L);
        assertEquals("사용자5", user.getName());
        assertEquals("user5@example.com", user.getEmail());
    }
}
🎪 실전 팁: ArgumentMatchers 활용하기

Mockito는 다양한 ArgumentMatchers를 제공해:

any(): 어떤 객체든 매칭
anyString(), anyInt(), anyLong(): 특정 타입의 어떤 값이든 매칭
eq(value): 특정 값과 정확히 일치
isNull(), isNotNull(): null 여부 매칭
argThat(matcher): 커스텀 매처 사용

이걸 잘 활용하면 테스트 코드가 훨씬 유연해져! 😊
🔍 Spy와 Mock의 차이

Mockito에는 Mock 외에도 Spy라는 개념이 있어. 둘의 차이를 이해하는 게 중요해.

class SpyVsMockTest {
    
    @Test
    @DisplayName("Mock은 모든 메서드가 기본값을 반환한다")
    void testMock() {
        List<String> mockList = mock(List.class);
        
        // Mock은 기본값 반환 (size는 0)
        assertEquals(0, mockList.size());
        
        // 동작을 정의하지 않으면 null 반환
        assertNull(mockList.get(0));
    }
    
    @Test
    @DisplayName("Spy는 실제 객체를 감싸서 일부만 모킹한다")
    void testSpy() {
        List<String> spyList = spy(new ArrayList<>());
        
        // 실제 메서드가 호출됨
        spyList.add("one");
        spyList.add("two");
        
        assertEquals(2, spyList.size());
        assertEquals("one", spyList.get(0));
        
        // 특정 메서드만 모킹 가능
        when(spyList.size()).thenReturn(100);
        assertEquals(100, spyList.size());
        
        // 하지만 실제 데이터는 그대로
        assertEquals("one", spyList.get(0));
    }
    
    @Test
    @DisplayName("Spy를 사용한 실전 예제")
    void testSpyRealWorld() {
        UserService realService = new UserService(mock(UserRepository.class));
        UserService spyService = spy(realService);
        
        // 특정 메서드만 모킹하고 나머지는 실제 동작 사용
        doReturn(new User(1L, "모킹된사용자", "mock@example.com"))
            .when(spyService).getUser(1L);
        
        User user = spyService.getUser(1L);
        assertEquals("모킹된사용자", user.getName());
    }
}
⚠️ Spy 사용 시 주의사항

Spy는 실제 객체를 사용하기 때문에 조심해야 해. 특히 when() 대신 doReturn()을 사용하는 게 안전해. when()을 사용하면 실제 메서드가 먼저 호출되고 그 다음에 모킹되기 때문에 예상치 못한 부작용이 발생할 수 있거든. 😅
Mock vs Spy 비교 Mock 객체 완전히 가짜 객체 모든 메서드 기본값 반환 명시적으로 정의한 동작만 수행 가볍고 빠름 단위 테스트에 적합 when().thenReturn() Spy 객체 실제 객체를 감쌈 실제 메서드 호출 일부 메서드만 선택적으로 모킹 실제 객체 생성 필요 부분 모킹에 적합 doReturn().when() VS

🏗️ 통합 테스트 작성하기

단위 테스트만으로는 충분하지 않아. 실제로 여러 컴포넌트가 함께 동작할 때 문제가 발생하는 경우가 많거든. 그래서 통합 테스트가 필요해. 통합 테스트는 여러 계층이나 컴포넌트가 함께 올바르게 동작하는지 검증하는 거야.

Spring Boot를 사용한다면 @SpringBootTest 어노테이션을 활용할 수 있어. 이건 전체 애플리케이션 컨텍스트를 로드해서 실제 환경과 유사한 상태에서 테스트할 수 있게 해줘.

🎯 Spring Boot 통합 테스트
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    @DisplayName("사용자 생성 API 통합 테스트")
    void testCreateUser() {
        // Given
        UserDto userDto = new UserDto("홍길동", "hong@example.com");
        
        // When
        ResponseEntity<UserDto> response = restTemplate.postForEntity(
            "/api/users",
            userDto,
            UserDto.class
        );
        
        // Then
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody());
        assertEquals("홍길동", response.getBody().getName());
    }
    
    @Test
    @DisplayName("사용자 조회 API 통합 테스트")
    void testGetUser() {
        // Given: 먼저 사용자 생성
        UserDto createDto = new UserDto("김철수", "kim@example.com");
        ResponseEntity<UserDto> createResponse = restTemplate.postForEntity(
            "/api/users",
            createDto,
            UserDto.class
        );
        Long userId = createResponse.getBody().getId();
        
        // When: 생성된 사용자 조회
        ResponseEntity<UserDto> response = restTemplate.getForEntity(
            "/api/users/" + userId,
            UserDto.class
        );
        
        // Then
        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertEquals("김철수", response.getBody().getName());
    }
}
🗄️ 데이터베이스 통합 테스트

데이터베이스와 연동하는 코드를 테스트할 때는 실제 DB 대신 인메모리 DB를 사용하는 게 좋아. H2 같은 인메모리 데이터베이스를 사용하면 테스트가 빠르고 격리되어 있어서 안전해.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

@DataJpaTest
class UserRepositoryIntegrationTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    @DisplayName("이메일로 사용자를 찾을 수 있다")
    void testFindByEmail() {
        // Given
        User user = new User(null, "이영희", "lee@example.com");
        entityManager.persist(user);
        entityManager.flush();
        
        // When
        Optional<User> found = userRepository.findByEmail("lee@example.com");
        
        // Then
        assertTrue(found.isPresent());
        assertEquals("이영희", found.get().getName());
    }
    
    @Test
    @DisplayName("존재하지 않는 이메일로 조회하면 빈 Optional을 반환한다")
    void testFindByEmailNotFound() {
        // When
        Optional<User> found = userRepository.findByEmail("notexist@example.com");
        
        // Then
        assertFalse(found.isPresent());
    }
    
    @Test
    @DisplayName("사용자를 저장하고 조회할 수 있다")
    void testSaveAndFind() {
        // Given
        User user = new User(null, "박민수", "park@example.com");
        
        // When
        User saved = userRepository.save(user);
        User found = userRepository.findById(saved.getId()).orElse(null);
        
        // Then
        assertNotNull(found);
        assertEquals("박민수", found.getName());
        assertEquals("park@example.com", found.getEmail());
    }
}
💡 @DataJpaTest의 장점

@DataJpaTest는 JPA 관련 컴포넌트만 로드해서 테스트를 빠르게 만들어줘. 또한 각 테스트 메서드마다 자동으로 트랜잭션을 롤백해서 테스트 간 격리를 보장해. 데이터베이스 테스트할 때 정말 유용한 어노테이션이야! 🎯
🎪 MockMvc를 활용한 컨트롤러 테스트

전체 애플리케이션을 띄우지 않고도 컨트롤러를 테스트할 수 있어. MockMvc를 사용하면 HTTP 요청을 시뮬레이션하고 응답을 검증할 수 있지.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    @DisplayName("사용자 조회 API 테스트")
    void testGetUser() throws Exception {
        // Given
        Long userId = 1L;
        User user = new User(userId, "홍길동", "hong@example.com");
        when(userService.getUser(userId)).thenReturn(user);
        
        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id", is(1)))
            .andExpect(jsonPath("$.name", is("홍길동")))
            .andExpect(jsonPath("$.email", is("hong@example.com")));
        
        verify(userService, times(1)).getUser(userId);
    }
    
    @Test
    @DisplayName("사용자 생성 API 테스트")
    void testCreateUser() throws Exception {
        // Given
        String userJson = """
            {
                "name": "김철수",
                "email": "kim@example.com"
            }
            """;
        
        User createdUser = new User(1L, "김철수", "kim@example.com");
        when(userService.createUser(any(User.class))).thenReturn(createdUser);
        
        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(userJson))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id", is(1)))
            .andExpect(jsonPath("$.name", is("김철수")));
    }
    
    @Test
    @DisplayName("존재하지 않는 사용자 조회 시 404 반환")
    void testGetUserNotFound() throws Exception {
        // Given
        Long userId = 999L;
        when(userService.getUser(userId))
            .thenThrow(new UserNotFoundException("사용자를 찾을 수 없습니다"));
        
        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpect(status().isNotFound());
    }
    
    @Test
    @DisplayName("잘못된 요청 데이터로 사용자 생성 시 400 반환")
    void testCreateUserBadRequest() throws Exception {
        // Given: 이메일이 없는 잘못된 요청
        String invalidUserJson = """
            {
                "name": "테스트"
            }
            """;
        
        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidUserJson))
            .andExpect(status().isBadRequest());
    }
}
🎬 MockMvc의 강력한 기능들

MockMvc는 정말 다양한 검증을 할 수 있어:

HTTP 상태 코드 검증
응답 헤더 검증
JSON 경로 기반 응답 내용 검증
쿠키, 세션 검증
리다이렉트 검증

특히 jsonPath를 사용하면 복잡한 JSON 응답도 쉽게 검증할 수 있어. 정말 편리하지! 😊

🎨 테스트 코드 작성 베스트 프랙티스

자, 이제 실전에서 정말 중요한 베스트 프랙티스들을 알려줄게. 이건 내가 수년간 삽질하면서 배운 교훈들이야. 😅

📝 1. 테스트는 독립적이어야 해

각 테스트는 다른 테스트에 의존하지 않고 독립적으로 실행될 수 있어야 해. 테스트 순서가 바뀌어도 결과가 같아야 하지.

// ❌ 나쁜 예: 테스트 간 의존성
class BadTest {
    private static User createdUser;
    
    @Test
    void test1_createUser() {
        createdUser = userService.createUser(new User("홍길동"));
        assertNotNull(createdUser.getId());
    }
    
    @Test
    void test2_updateUser() {
        // test1에 의존함 - 나쁜 패턴!
        createdUser.setName("김철수");
        userService.updateUser(createdUser);
    }
}

// ✅ 좋은 예: 독립적인 테스트
class GoodTest {
    
    @BeforeEach
    void setUp() {
        // 각 테스트마다 독립적으로 데이터 준비
    }
    
    @Test
    void testCreateUser() {
        User user = userService.createUser(new User("홍길동"));
        assertNotNull(user.getId());
    }
    
    @Test
    void testUpdateUser() {
        // 이 테스트에 필요한 데이터를 직접 준비
        User user = userService.createUser(new User("홍길동"));
        user.setName("김철수");
        userService.updateUser(user);
        
        User updated = userService.getUser(user.getId());
        assertEquals("김철수", updated.getName());
    }
}
🎯 2. 하나의 테스트는 하나의 개념만 검증해

테스트 메서드 하나에 너무 많은 검증을 넣지 마. 테스트가 실패했을 때 원인을 파악하기 어려워져.

// ❌ 나쁜 예: 여러 개념을 한 번에 테스트
@Test
void testUserOperations() {
    // 생성 테스트
    User user = userService.createUser(new User("홍길동"));
    assertNotNull(user.getId());
    
    // 조회 테스트
    User found = userService.getUser(user.getId());
    assertEquals("홍길동", found.getName());
    
    // 수정 테스트
    user.setName("김철수");
    userService.updateUser(user);
    
    // 삭제 테스트
    userService.deleteUser(user.getId());
    assertThrows(UserNotFoundException.class, () -> {
        userService.getUser(user.getId());
    });
}

// ✅ 좋은 예: 각 개념을 별도로 테스트
@Test
@DisplayName("사용자를 생성할 수 있다")
void testCreateUser() {
    User user = userService.createUser(new User("홍길동"));
    assertNotNull(user.getId());
    assertEquals("홍길동", user.getName());
}

@Test
@DisplayName("사용자를 조회할 수 있다")
void testGetUser() {
    User user = userService.createUser(new User("홍길동"));
    User found = userService.getUser(user.getId());
    assertEquals("홍길동", found.getName());
}

@Test
@DisplayName("사용자를 수정할 수 있다")
void testUpdateUser() {
    User user = userService.createUser(new User("홍길동"));
    user.setName("김철수");
    userService.updateUser(user);
    
    User updated = userService.getUser(user.getId());
    assertEquals("김철수", updated.getName());
}

@Test
@DisplayName("사용자를 삭제할 수 있다")
void testDeleteUser() {
    User user = userService.createUser(new User("홍길동"));
    userService.deleteUser(user.getId());
    
    assertThrows(UserNotFoundException.class, () -> {
        userService.getUser(user.getId());
    });
}
🏗️ 3. 테스트 픽스처를 재사용해

테스트 데이터를 준비하는 코드가 중복되면 유지보수가 어려워져. 테스트 픽스처를 만들어서 재사용하자.

// 테스트 픽스처 클래스
public class UserFixtures {
    
    public static User createDefaultUser() {
        return new User(null, "홍길동", "hong@example.com");
    }
    
    public static User createUserWithEmail(String email) {
        return new User(null, "테스트사용자", email);
    }
    
    public static User createUserWithName(String name) {
        return new User(null, name, "test@example.com");
    }
    
    public static List<User> createMultipleUsers(int count) {
        List<User> users = new ArrayList<>();
        for (int i = 0; i < count; i++) {
            users.add(new User(null, "사용자" + i, "user" + i + "@example.com"));
        }
        return users;
    }
}

// 테스트에서 사용
class UserServiceTest {
    
    @Test
    void testCreateUser() {
        User user = UserFixtures.createDefaultUser();
        User created = userService.createUser(user);
        assertNotNull(created.getId());
    }
    
    @Test
    void testDuplicateEmail() {
        User user1 = UserFixtures.createUserWithEmail("duplicate@example.com");
        User user2 = UserFixtures.createUserWithEmail("duplicate@example.com");
        
        userService.createUser(user1);
        assertThrows(DuplicateEmailException.class, () -> {
            userService.createUser(user2);
        });
    }
}
✨ 테스트 픽스처의 장점

테스트 픽스처를 사용하면:

🎯 테스트 데이터 준비 코드의 중복이 줄어들어
🎯 테스트 데이터 구조가 변경되어도 한 곳만 수정하면 돼
🎯 테스트 코드가 더 읽기 쉬워져
🎯 다양한 시나리오의 테스트 데이터를 쉽게 만들 수 있어

특히 복잡한 도메인 객체를 다룰 때 정말 유용해! 🚀
⚡ 4. 테스트는 빨라야 해

테스트가 느리면 개발자들이 실행을 꺼리게 돼. 빠른 테스트를 위한 팁들:

🚀 테스트 속도 개선 방법

1. 단위 테스트와 통합 테스트를 분리해
단위 테스트는 빠르게 자주 실행하고, 통합 테스트는 필요할 때만 실행하도록 분리하자.

2. 인메모리 데이터베이스 사용
실제 DB 대신 H2 같은 인메모리 DB를 사용하면 훨씬 빨라.

3. 병렬 실행 활용
JUnit 5는 테스트 병렬 실행을 지원해. junit-platform.properties 파일에 설정하면 돼:
junit.jupiter.execution.parallel.enabled=true

4. 불필요한 Spring Context 로딩 피하기
@SpringBootTest 대신 @WebMvcTest, @DataJpaTest 같은 슬라이스 테스트를 사용하자.

5. Mock을 적극 활용
외부 의존성은 Mock으로 대체해서 테스트 속도를 높이자.
📊 5. 테스트 커버리지를 측정해

테스트 커버리지는 코드의 얼마나 많은 부분이 테스트되고 있는지 보여줘. JaCoCo 같은 도구를 사용하면 쉽게 측정할 수 있어.

// build.gradle에 JaCoCo 플러그인 추가
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.9"
}

test {
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80  // 80% 커버리지 요구
            }
        }
    }
}
⚠️ 커버리지의 함정

커버리지 100%가 완벽한 테스트를 의미하는 건 아니야. 코드가 실행되었다는 것만 보여줄 뿐, 제대로 검증되었는지는 알 수 없거든. 커버리지는 참고 지표일 뿐이고, 중요한 건 의미 있는 테스트를 작성하는 거야. 😊
테스트 피라미드 단위 테스트 Unit Tests 빠르고 많이 통합 테스트 Integration Tests E2E 느리고 적게 70% 단위 테스트 20% 통합 테스트 10% E2E

🔥 실전 예제: 완전한 테스트 시나리오

이제 지금까지 배운 걸 모두 활용해서 실전 예제를 만들어볼게. 간단한 주문 시스템을 테스트하는 시나리오야.

📦 도메인 모델
// 주문 엔티티
public class Order {
    private Long id;
    private Long userId;
    private List<OrderItem> items;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private LocalDateTime createdAt;
    
    public void addItem(OrderItem item) {
        items.add(item);
        calculateTotalAmount();
    }
    
    private void calculateTotalAmount() {
        totalAmount = items.stream()
            .map(item -> item.getPrice().multiply(
                BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    
    public void confirm() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다");
        }
        status = OrderStatus.CONFIRMED;
    }
    
    public void cancel() {
        if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
            throw new IllegalStateException("배송 중이거나 완료된 주문은 취소할 수 없습니다");
        }
        status = OrderStatus.CANCELLED;
    }
}

// 주문 아이템
public class OrderItem {
    private Long productId;
    private String productName;
    private BigDecimal price;
    private int quantity;
    
    // 생성자, getter, setter
}

// 주문 상태
public enum OrderStatus {
    PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}

// 주문 서비스
public class OrderService {
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final PaymentService paymentService;
    private final NotificationService notificationService;
    
    public OrderService(OrderRepository orderRepository,
                       ProductRepository productRepository,
                       PaymentService paymentService,
                       NotificationService notificationService) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
        this.paymentService = paymentService;
        this.notificationService = notificationService;
    }
    
    public Order createOrder(Long userId, List<OrderItemRequest> itemRequests) {
        Order order = new Order();
        order.setUserId(userId);
        order.setStatus(OrderStatus.PENDING);
        order.setCreatedAt(LocalDateTime.now());
        
        for (OrderItemRequest request : itemRequests) {
            Product product = productRepository.findById(request.getProductId())
                .orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다"));
            
            if (product.getStock() < request.getQuantity()) {
                throw new InsufficientStockException("재고가 부족합니다");
            }
            
            OrderItem item = new OrderItem(
                product.getId(),
                product.getName(),
                product.getPrice(),
                request.getQuantity()
            );
            order.addItem(item);
        }
        
        return orderRepository.save(order);
    }
    
    public void confirmOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException("주문을 찾을 수 없습니다"));
        
        // 결제 처리
        boolean paymentSuccess = paymentService.processPayment(
            order.getUserId(),
            order.getTotalAmount()
        );
        
        if (!paymentSuccess) {
            throw new PaymentFailedException("결제에 실패했습니다");
        }
        
        // 재고 차감
        for (OrderItem item : order.getItems()) {
            productRepository.decreaseStock(item.getProductId(), item.getQuantity());
        }
        
        // 주문 확정
        order.confirm();
        orderRepository.save(order);
        
        // 알림 발송
        notificationService.sendOrderConfirmation(order);
    }
    
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException("주문을 찾을 수 없습니다"));
        
        order.cancel();
        
        // 결제 취소
        if (order.getStatus() == OrderStatus.CONFIRMED) {
            paymentService.refund(order.getUserId(), order.getTotalAmount());
        }
        
        // 재고 복구
        for (OrderItem item : order.getItems()) {
            productRepository.increaseStock(item.getProductId(), item.getQuantity());
        }
        
        orderRepository.save(order);
        notificationService.sendOrderCancellation(order);
    }
}
🧪 단위 테스트
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    
    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private ProductRepository productRepository;
    
    @Mock
    private PaymentService paymentService;
    
    @Mock
    private NotificationService notificationService;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    @DisplayName("주문을 생성할 수 있다")
    void testCreateOrder() {
        // Given
        Long userId = 1L;
        Product product = new Product(1L, "테스트 상품", new BigDecimal("10000"), 100);
        
        OrderItemRequest itemRequest = new OrderItemRequest(1L, 2);
        List<OrderItemRequest> itemRequests = List.of(itemRequest);
        
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));
        when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
            Order order = invocation.getArgument(0);
            order.setId(1L);
            return order;
        });
        
        // When
        Order order = orderService.createOrder(userId, itemRequests);
        
        // Then
        assertNotNull(order);
        assertEquals(userId, order.getUserId());
        assertEquals(OrderStatus.PENDING, order.getStatus());
        assertEquals(1, order.getItems().size());
        assertEquals(new BigDecimal("20000"), order.getTotalAmount());
        
        verify(productRepository, times(1)).findById(1L);
        verify(orderRepository, times(1)).save(any(Order.class));
    }
    
    @Test
    @DisplayName("재고가 부족하면 주문 생성이 실패한다")
    void testCreateOrderInsufficientStock() {
        // Given
        Long userId = 1L;
        Product product = new Product(1L, "테스트 상품", new BigDecimal("10000"), 5);
        
        OrderItemRequest itemRequest = new OrderItemRequest(1L, 10);  // 재고보다 많이 주문
        List<OrderItemRequest> itemRequests = List.of(itemRequest);
        
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));
        
        // When & Then
        assertThrows(InsufficientStockException.class, () -> {
            orderService.createOrder(userId, itemRequests);
        });
        
        verify(orderRepository, never()).save(any(Order.class));
    }
    
    @Test
    @DisplayName("주문을 확정할 수 있다")
    void testConfirmOrder() {
        // Given
        Long orderId = 1L;
        Order order = createTestOrder();
        
        when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
        when(paymentService.processPayment(anyLong(), any(BigDecimal.class)))
            .thenReturn(true);
        when(orderRepository.save(any(Order.class))).thenReturn(order);
        
        // When
        orderService.confirmOrder(orderId);
        
        // Then
        assertEquals(OrderStatus.CONFIRMED, order.getStatus());
        
        verify(paymentService, times(1))
            .processPayment(order.getUserId(), order.getTotalAmount());
        verify(productRepository, times(1))
            .decreaseStock(anyLong(), anyInt());
        verify(notificationService, times(1))
            .sendOrderConfirmation(order);
    }
    
    @Test
    @DisplayName("결제 실패 시 주문 확정이 실패한다")
    void testConfirmOrderPaymentFailed() {
        // Given
        Long orderId = 1L;
        Order order = createTestOrder();
        
        when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
        when(paymentService.processPayment(anyLong(), any(BigDecimal.class)))
            .thenReturn(false);
        
        // When & Then
        assertThrows(PaymentFailedException.class, () -> {
            orderService.confirmOrder(orderId);
        });
        
        assertEquals(OrderStatus.PENDING, order.getStatus());
        verify(productRepository, never()).decreaseStock(anyLong(), anyInt());
        verify(notificationService, never()).sendOrderConfirmation(any());
    }
    
    @Test
    @DisplayName("주문을 취소할 수 있다")
    void testCancelOrder() {
        // Given
        Long orderId = 1L;
        Order order = createTestOrder();
        order.setStatus(OrderStatus.CONFIRMED);
        
        when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
        when(orderRepository.save(any(Order.class))).thenReturn(order);
        
        // When
        orderService.cancelOrder(orderId);
        
        // Then
        assertEquals(OrderStatus.CANCELLED, order.getStatus());
        
        verify(paymentService, times(1))
            .refund(order.getUserId(), order.getTotalAmount());
        verify(productRepository, times(1))
            .increaseStock(anyLong(), anyInt());
        verify(notificationService, times(1))
            .sendOrderCancellation(order);
    }
    
    @Test
    @DisplayName("배송 중인 주문은 취소할 수 없다")
    void testCancelShippedOrder() {
        // Given
        Long orderId = 1L;
        Order order = createTestOrder();
        order.setStatus(OrderStatus.SHIPPED);
        
        when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
        
        // When & Then
        assertThrows(IllegalStateException.class, () -> {
            orderService.cancelOrder(orderId);
        });
        
        verify(paymentService, never()).refund(anyLong(), any(BigDecimal.class));
    }
    
    private Order createTestOrder() {
        Order order = new Order();
        order.setId(1L);
        order.setUserId(1L);
        order.setStatus(OrderStatus.PENDING);
        
        OrderItem item = new OrderItem(1L, "테스트 상품", new BigDecimal("10000"), 2);
        order.addItem(item);
        
        return order;
    }
}
💡 테스트 헬퍼 메서드

createTestOrder() 같은 헬퍼 메서드를 만들면 테스트 코드가 훨씬 깔끔해져. 복잡한 객체 생성 로직을 재사용할 수 있고, 테스트의 의도가 더 명확해지지. 이런 작은 습관들이 모여서 유지보수하기 좋은 테스트 코드가 만들어져! 🎯
🔗 통합 테스트
@SpringBootTest
@Transactional
class OrderServiceIntegrationTest {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Test
    @DisplayName("주문 생성부터 확정까지 전체 플로우가 정상 동작한다")
    void testCompleteOrderFlow() {
        // Given: 상품 준비
        Product product = new Product(null, "통합테스트 상품", 
            new BigDecimal("15000"), 50);
        product = productRepository.save(product);
        
        Long userId = 1L;
        OrderItemRequest itemRequest = new OrderItemRequest(product.getId(), 3);
        
        // When: 주문 생성
        Order order = orderService.createOrder(userId, List.of(itemRequest));
        
        // Then: 주문이 생성되었는지 확인
        assertNotNull(order.getId());
        assertEquals(OrderStatus.PENDING, order.getStatus());
        assertEquals(new BigDecimal("45000"), order.getTotalAmount());
        
        // When: 주문 확정
        orderService.confirmOrder(order.getId());
        
        // Then: 주문이 확정되었는지 확인
        Order confirmedOrder = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals(OrderStatus.CONFIRMED, confirmedOrder.getStatus());
        
        // 재고가 차감되었는지 확인
        Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
        assertEquals(47, updatedProduct.getStock());  // 50 - 3 = 47
    }
    
    @Test
    @DisplayName("주문 취소 시 재고가 복구된다")
    void testOrderCancellationRestoresStock() {
        // Given
        Product product = new Product(null, "취소테스트 상품", 
            new BigDecimal("20000"), 30);
        product = productRepository.save(product);
        
        Long userId = 1L;
        OrderItemRequest itemRequest = new OrderItemRequest(product.getId(), 5);
        
        Order order = orderService.createOrder(userId, List.of(itemRequest));
        orderService.confirmOrder(order.getId());
        
        // 재고 확인
        Product afterOrder = productRepository.findById(product.getId()).orElseThrow();
        assertEquals(25, afterOrder.getStock());  // 30 - 5 = 25
        
        // When: 주문 취소
        orderService.cancelOrder(order.getId());
        
        // Then: 재고가 복구되었는지 확인
        Product afterCancel = productRepository.findById(product.getId()).orElseThrow();
        assertEquals(30, afterCancel.getStock());  // 25 + 5 = 30
        
        Order cancelledOrder = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals(OrderStatus.CANCELLED, cancelledOrder.getStatus());
    }
}

🎓 테스트 주도 개발(TDD) 실천하기

TDD는 테스트를 먼저 작성하고 그 다음에 구현 코드를 작성하는 개발 방법론이야. 처음에는 좀 어색하고 느리게 느껴질 수 있는데, 익숙해지면 오히려 개발 속도가 빨라지고 코드 품질도 좋아져. 😊

🔄 TDD 사이클: Red-Green-Refactor
🎪 TDD의 3단계

🔴 Red: 실패하는 테스트를 먼저 작성해
구현하고 싶은 기능을 테스트로 표현하고, 당연히 실패하는 걸 확인해.

🟢 Green: 테스트를 통과하는 최소한의 코드를 작성해
완벽한 코드가 아니어도 괜찮아. 일단 테스트만 통과하면 돼.

🔵 Refactor: 코드를 개선해
테스트가 통과하는 상태를 유지하면서 코드를 리팩토링해.

실제 예제로 TDD를 실천해보자. 문자열 계산기를 만들어볼게.

// 1단계: Red - 실패하는 테스트 작성
class StringCalculatorTest {
    
    @Test
    @DisplayName("빈 문자열은 0을 반환한다")
    void testEmptyString() {
        StringCalculator calculator = new StringCalculator();
        assertEquals(0, calculator.add(""));
    }
}

// 이 시점에서 StringCalculator 클래스가 없으므로 컴파일 에러!

// 2단계: Green - 테스트를 통과하는 최소한의 코드
public class StringCalculator {
    public int add(String numbers) {
        return 0;  // 일단 0만 반환
    }
}

// 테스트 통과! 이제 다음 테스트 작성

@Test
@DisplayName("숫자 하나를 전달하면 해당 숫자를 반환한다")
void testSingleNumber() {
    StringCalculator calculator = new StringCalculator();
    assertEquals(5, calculator.add("5"));
}

// 테스트 실패! 이제 구현 수정

public class StringCalculator {
    public int add(String numbers) {
        if (numbers.isEmpty()) {
            return 0;
        }
        return Integer.parseInt(numbers);
    }
}

// 테스트 통과! 다음 테스트

@Test
@DisplayName("쉼표로 구분된 두 숫자의 합을 반환한다")
void testTwoNumbers() {
    StringCalculator calculator = new StringCalculator();
    assertEquals(8, calculator.add("3,5"));
}

// 테스트 실패! 구현 수정

public class StringCalculator {
    public int add(String numbers) {
        if (numbers.isEmpty()) {
            return 0;
        }
        
        String[] tokens = numbers.split(",");
        if (tokens.length == 1) {
            return Integer.parseInt(tokens[0]);
        }
        
        return Integer.parseInt(tokens[0]) + Integer.parseInt(tokens[1]);
    }
}

// 테스트 통과! 하지만 코드가 지저분해. 리팩토링하자

// 3단계: Refactor - 코드 개선
public class StringCalculator {
    public int add(String numbers) {
        if (numbers.isEmpty()) {
            return 0;
        }
        
        String[] tokens = numbers.split(",");
        return Arrays.stream(tokens)
            .mapToInt(Integer::parseInt)
            .sum();
    }
}

// 테스트는 여전히 통과! 이제 더 많은 숫자도 처리할 수 있어

@Test
@DisplayName("여러 개의 숫자를 더할 수 있다")
void testMultipleNumbers() {
    StringCalculator calculator = new StringCalculator();
    assertEquals(15, calculator.add("1,2,3,4,5"));
}

// 추가 구현 없이도 테스트 통과!

@Test
@DisplayName("음수를 전달하면 예외가 발생한다")
void testNegativeNumber() {
    StringCalculator calculator = new StringCalculator();
    assertThrows(IllegalArgumentException.class, () -> {
        calculator.add("1,-2,3");
    });
}

// 테스트 실패! 음수 검증 로직 추가

public class StringCalculator {
    public int add(String numbers) {
        if (numbers.isEmpty()) {
            return 0;
        }
        
        String[] tokens = numbers.split(",");
        int[] nums = Arrays.stream(tokens)
            .mapToInt(Integer::parseInt)
            .toArray();
        
        // 음수 검증
        for (int num : nums) {
            if (num < 0) {
                throw new IllegalArgumentException("음수는 허용되지 않습니다: " + num);
            }
        }
        
        return Arrays.stream(nums).sum();
    }
}

// 모든 테스트 통과!
✨ TDD의 장점

🎯 명확한 요구사항: 테스트가 곧 명세서가 돼
🎯 빠른 피드백: 코드 작성 즉시 동작 확인 가능
🎯 리팩토링 자신감: 테스트가 있으니 안전하게 코드 개선
🎯 과도한 설계 방지: 필요한 만큼만 구현하게 돼
🎯 버그 감소: 테스트를 먼저 작성하니 엣지 케이스를 미리 고려

처음에는 어색하지만, 한 달만 연습하면 자연스러워져. 정말 추천해! 🚀

🛠️ 유용한 테스팅 도구와 라이브러리

JUnit과 Mockito 외에도 테스팅을 도와주는 유용한 도구들이 많아. 몇 가지 소개해줄게!

🎨 AssertJ - 유창한 Assertion

AssertJ는 JUnit의 기본 assertion보다 훨씬 읽기 쉽고 강력한 assertion을 제공해.

import static org.assertj.core.api.Assertions.*;

class AssertJExampleTest {
    
    @Test
    void testWithAssertJ() {
        // 기본 assertion
        String name = "홍길동";
        assertThat(name)
            .isNotNull()
            .isNotEmpty()
            .startsWith("홍")
            .endsWith("동")
            .hasSize(3);
        
        // 컬렉션 assertion
        List<String> names = Arrays.asList("김철수", "이영희", "박민수");
        assertThat(names)
            .hasSize(3)
            .contains("김철수", "이영희")
            .doesNotContain("홍길동")
            .allMatch(name -> name.length() == 3);
        
        // 객체 assertion
        User user = new User(1L, "홍길동", "hong@example.com");
        assertThat(user)
            .isNotNull()
            .extracting("name", "email")
            .containsExactly("홍길동", "hong@example.com");
        
        // 예외 assertion
        assertThatThrownBy(() -> {
            throw new IllegalArgumentException("잘못된 인자");
        })
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("잘못된 인자")
            .hasNoCause();
    }
}
🏗️ TestContainers - 실제 환경 테스트

TestContainers는 Docker 컨테이너를 사용해서 실제 데이터베이스나 메시지 큐 등을 테스트 환경에서 사용할 수 있게 해줘.

import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
@SpringBootTest
class TestContainersExampleTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void testWithRealDatabase() {
        // 실제 PostgreSQL 컨테이너를 사용한 테스트
        User user = new User(null, "테스트", "test@example.com");
        User saved = userRepository.save(user);
        
        assertThat(saved.getId()).isNotNull();
    }
}
🎭 WireMock - HTTP 모킹

외부 API를 호출하는 코드를 테스트할 때 WireMock을 사용하면 실제 API 없이도 테스트할 수 있어.

import com.github.tomakehurst.wiremock.WireMockServer;
import static com.github.tomakehurst.wiremock.client.WireMock.*;

class WireMockExampleTest {
    
    private WireMockServer wireMockServer;
    
    @BeforeEach
    void setUp() {
        wireMockServer = new WireMockServer(8089);
        wireMockServer.start();
        configureFor("localhost", 8089);
    }
    
    @AfterEach
    void tearDown() {
        wireMockServer.stop();
    }
    
    @Test
    void testExternalApiCall() {
        // Mock API 응답 설정
        stubFor(get(urlEqualTo("/api/users/1"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "id": 1,
                        "name": "홍길동",
                        "email": "hong@example.com"
                    }
                    """)));
        
        // 실제 HTTP 클라이언트로 호출
        RestTemplate restTemplate = new RestTemplate();
        String url = "http://localhost:8089/api/users/1";
        User user = restTemplate.getForObject(url, User.class);
        
        assertThat(user.getName()).isEqualTo("홍길동");
        
        // 호출 검증
        verify(getRequestedFor(urlEqualTo("/api/users/1")));
    }
}
📊 ArchUnit - 아키텍처 테스트

ArchUnit은 코드의 아키텍처 규칙을 테스트할 수 있게 해줘. 레이어 간 의존성 규칙 같은 걸 검증할 수 있어.

import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

class ArchitectureTest {
    
    private JavaClasses classes = new ClassFileImporter()
        .importPackages("com.example.myapp");
    
    @Test
    void testLayerDependencies() {
        ArchRule rule = layeredArchitecture()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")
            
            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
        
        rule.check(classes);
    }
    
    @Test
    void testNamingConventions() {
        ArchRule rule = classes()
            .that().resideInAPackage("..service..")
            .should().haveSimpleNameEndingWith("Service");
        
        rule.check(classes);
    }
    
    @Test
    void testNoFieldInjection() {
        ArchRule rule = noFields()
            .should().beAnnotatedWith(Autowired.class);
        
        rule.check(classes);
    }
}
🎪 도구 선택 가이드

AssertJ: 모든 프로젝트에 추천. 가독성이 정말 좋아.

TestContainers: 데이터베이스나 메시지 큐 같은 인프라 컴포넌트를 테스트할 때.

WireMock: 외부 API에 의존하는 코드를 테스트할 때.

ArchUnit: 대규모 프로젝트에서 아키텍처 규칙을 강제하고 싶을 때.

프로젝트 규모와 필요에 따라 적절히 선택해서 사용하면 돼! 😊

💡 실무에서 자주 마주치는 테스트 시나리오

이론은 충분히 배웠으니, 이제 실무에서 자주 마주치는 까다로운 상황들을 어떻게 테스트하는지 알아보자!

⏰ 시간 의존적인 코드 테스트하기

현재 시간에 의존하는 코드는 테스트하기 까다로워. 매번 다른 결과가 나올 수 있거든. 이럴 때는 시간을 추상화해서 주입받는 게 좋아.

// 시간 제공자 인터페이스
public interface TimeProvider {
    LocalDateTime now();
}

// 실제 구현
public class SystemTimeProvider implements TimeProvider {
    @Override
    public LocalDateTime now() {
        return LocalDateTime.now();
    }
}

// 쿠폰 서비스
public class CouponService {
    private final TimeProvider timeProvider;
    
    public CouponService(TimeProvider timeProvider) {
        this.timeProvider = timeProvider;
    }
    
    public boolean isValid(Coupon coupon) {
        LocalDateTime now = timeProvider.now();
        return now.isAfter(coupon.getStartDate()) 
            && now.isBefore(coupon.getEndDate());
    }
}

// 테스트
class CouponServiceTest {
    
    @Test
    void testValidCoupon() {
        // Given: 고정된 시간 제공자
        LocalDateTime fixedTime = LocalDateTime.of(2024, 1, 15, 10, 0);
        TimeProvider timeProvider = () -> fixedTime;
        
        CouponService service = new CouponService(timeProvider);
        
        Coupon coupon = new Coupon(
            LocalDateTime.of(2024, 1, 1, 0, 0),
            LocalDateTime.of(2024, 1, 31, 23, 59)
        );
        
        // When & Then
        assertTrue(service.isValid(coupon));
    }
    
    @Test
    void testExpiredCoupon() {
        LocalDateTime fixedTime = LocalDateTime.of(2024, 2, 1, 0, 0);
        TimeProvider timeProvider = () -> fixedTime;
        
        CouponService service = new CouponService(timeProvider);
        
        Coupon coupon = new Coupon(
            LocalDateTime.of(2024, 1, 1, 0, 0),
            LocalDateTime.of(2024, 1, 31, 23, 59)
        );
        
        assertFalse(service.isValid(coupon));
    }
}
🎲 랜덤 값을 다루는 코드 테스트하기

랜덤 값도 시간과 비슷하게 추상화해서 테스트할 수 있어.

// 랜덤 제공자
public interface RandomProvider {
    int nextInt(int bound);
}

// 게임 서비스
public class GameService {
    private final RandomProvider randomProvider;
    
    public GameService(RandomProvider randomProvider) {
        this.randomProvider = randomProvider;
    }
    
    public GameResult playDice() {
        int dice1 = randomProvider.nextInt(6) + 1;
        int dice2 = randomProvider.nextInt(6) + 1;
        int sum = dice1 + dice2;
        
        if (sum == 7) {
            return GameResult.WIN;
        } else if (sum == 2 || sum == 12) {
            return GameResult.LOSE;
        } else {
            return GameResult.CONTINUE;
        }
    }
}

// 테스트
class GameServiceTest {
    
    @Test
    void testWinWithSeven() {
        // Given: 항상 3과 4를 반환하는 랜덤 제공자 (합 7)
        RandomProvider randomProvider = new RandomProvider() {
            private int callCount = 0;
            
            @Override
            public int nextInt(int bound) {
                return callCount++ == 0 ? 2 : 3;  // 3, 4 반환
            }
        };
        
        GameService service = new GameService(randomProvider);
        
        // When
        GameResult result = service.playDice();
        
        // Then
        assertEquals(GameResult.WIN, result);
    }
    
    @Test
    void testLoseWithTwo() {
        RandomProvider randomProvider = new RandomProvider() {
            @Override
            public int nextInt(int bound) {
                return 0;  // 항상 1 반환 (합 2)
            }
        };
        
        GameService service = new GameService(randomProvider);
        
        assertEquals(GameResult.LOSE, service.playDice());
    }
}
📧 비동기 처리 테스트하기

비동기 코드는 테스트하기 까다로워. Awaitility 같은 라이브러리를 사용하면 편해.

import org.awaitility.Awaitility;
import java.util.concurrent.TimeUnit;

class AsyncServiceTest {
    
    @Test
    void testAsyncEmailSending() {
        // Given
        EmailService emailService = new EmailService();
        String recipient = "test@example.com";
        
        // When
        emailService.sendEmailAsync(recipient, "제목", "내용");
        
        // Then: 비동기 작업이 완료될 때까지 대기
        Awaitility.await()
            .atMost(5, TimeUnit.SECONDS)
            .until(() -> emailService.isEmailSent(recipient));
        
        assertTrue(emailService.isEmailSent(recipient));
    }
    
    @Test
    void testAsyncProcessingWithCallback() {
        AsyncProcessor processor = new AsyncProcessor();
        AtomicBoolean callbackExecuted = new AtomicBoolean(false);
        
        processor.processAsync("data", result -> {
            callbackExecuted.set(true);
        });
        
        Awaitility.await()
            .atMost(3, TimeUnit.SECONDS)
            .untilTrue(callbackExecuted);
    }
}
🔐 보안 관련 코드 테스트하기

Spring Security를 사용하는 경우 인증/인가 테스트도 중요해.

import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithAnonymousUser;

@WebMvcTest(SecureController.class)
class SecurityTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    @WithMockUser(username = "user", roles = {"USER"})
    void testAuthenticatedAccess() throws Exception {
        mockMvc.perform(get("/api/secure/data"))
            .andExpect(status().isOk());
    }
    
    @Test
    @WithAnonymousUser
    void testUnauthenticatedAccess() throws Exception {
        mockMvc.perform(get("/api/secure/data"))
            .andExpect(status().isUnauthorized());
    }
    
    @Test
    @WithMockUser(username = "admin", roles = {"ADMIN"})
    void testAdminAccess() throws Exception {
        mockMvc.perform(delete("/api/users/1"))
            .andExpect(status().isOk());
    }
    
    @Test
    @WithMockUser(username = "user", roles = {"USER"})
    void testInsufficientPermission() throws Exception {
        mockMvc.perform(delete("/api/users/1"))
            .andExpect(status().isForbidden());
    }
}
⚠️ 보안 테스트 주의사항

보안 관련 코드는 특히 꼼꼼하게 테스트해야 해. 인증 우회, 권한 상승, SQL 인젝션 같은 취약점을 테스트로 검증하는 게 중요해. 실제 운영 환경에서 보안 문제가 발생하면 정말 큰일이거든. 😰

🎯 테스트 코드 리뷰 체크리스트

테스트 코드도 프로덕션 코드만큼 중요해. 코드 리뷰할 때 체크해야 할 항목들을 정리해봤어.

✅ 테스트 코드 품질 체크리스트

📝 명확성
□ 테스트 이름이 테스트 내용을 명확히 설명하는가?
□ Given-When-Then 구조가 명확한가?
□ 주석 없이도 테스트 의도를 이해할 수 있는가?

🎯 독립성
□ 각 테스트가 독립적으로 실행 가능한가?
□ 테스트 순서에 의존하지 않는가?
□ 외부 상태에 의존하지 않는가?

⚡ 속도
□ 테스트가 충분히 빠른가? (단위 테스트는 밀리초 단위)
□ 불필요한 대기 시간이 없는가?
□ 실제 외부 리소스 대신 Mock을 사용하는가?

🔍 완전성
□ 정상 케이스를 테스트하는가?
□ 예외 케이스를 테스트하는가?
□ 경계값을 테스트하는가?
□ null, 빈 값 등 엣지 케이스를 고려하는가?

🛠️ 유지보수성
□ 테스트 데이터 준비 코드가 재사용 가능한가?
□ 중복 코드가 최소화되어 있는가?
□ 테스트가 실패했을 때 원인을 쉽게 파악할 수 있는가?

🎨 가독성
□ assertion 메시지가 명확한가?
□ 매직 넘버 대신 상수를 사용하는가?
□ 테스트 코드가 간결한가?

이 체크리스트를 팀에서 공유하고 코드 리뷰 때 활용하면 테스트 코드 품질이 확실히 좋아져. 😊

🚀 마치며: 테스팅 문화 만들기

자, 여기까지 자바 테스팅에 대해 정말 많은 걸 다뤘어. JUnit과 Mockito의 기본부터 실전 예제, 베스트 프랙티스까지 말이야. 하지만 가장 중요한 건 테스팅 문화를 만드는 거야.

테스트 코드는 단순히 버그를 찾는 도구가 아니야. 이건 코드의 설계를 개선하고, 리팩토링의 자신감을 주고, 문서화의 역할도 하지. 무엇보다 팀 전체의 개발 속도를 높여줘.

✨ 테스팅 문화를 만드는 팁

1️⃣ 작은 것부터 시작해
모든 코드에 테스트를 작성하려고 하지 마. 중요한 비즈니스 로직부터 시작하자.

2️⃣ 테스트를 먼저 작성하는 습관
TDD를 완벽하게 하지 못해도 괜찮아. 새로운 기능을 추가할 때 테스트를 먼저 생각하는 습관만 들여도 큰 변화야.

3️⃣ 팀원들과 지식 공유
좋은 테스트 코드를 발견하면 팀원들과 공유하자. 코드 리뷰 때 테스트 코드도 꼼꼼히 봐주고.

4️⃣ CI/CD에 테스트 통합
자동화된 테스트 실행은 필수야. 배포 전에 모든 테스트가 통과하도록 하자.

5️⃣ 실패를 두려워하지 마
테스트가 실패하는 건 좋은 거야. 배포 전에 문제를 발견한 거니까. 실패를 통해 배우자.

재능넷(https://www.jaenung.net)에서는 다양한 프로그래밍 재능을 가진 개발자들이 활동하고 있어. 테스팅이나 코드 리뷰에 대해 더 배우고 싶다면, 재능넷에서 경험 많은 개발자들에게 멘토링을 받아보는 것도 좋은 방법이야. 실무 경험을 가진 전문가들의 조언은 정말 값지거든! 💎

테스트 코드 작성은 처음에는 시간이 더 걸리는 것처럼 느껴질 수 있어. 하지만 장기적으로 보면 엄청난 시간 절약이야. 버그 수정에 들어가는 시간, 회귀 테스트에 들어가는 시간, 리팩토링할 때의 불안감... 이 모든 게 줄어들거든.

나도 처음에는 "테스트 코드 작성할 시간에 기능 하나 더 만들지"라고 생각했어. 근데 프로젝트가 커지고 유지보수 단계에 들어가면서 생각이 완전히 바뀌었지. 테스트 코드가 없었다면 지금쯤 코드베이스가 엉망이 됐을 거야. 😅

마지막으로 한 가지만 더 말할게. 완벽한 테스트는 없어. 100% 커버리지를 목표로 하지 마. 대신 중요한 부분을 제대로 테스트하는 데 집중하자. 테스트 코드도 유지보수 비용이 들어가니까, 가치 있는 테스트를 작성하는 게 중요해.

자, 이제 당신의 프로젝트로 돌아가서 테스트 코드를 작성해보자! 처음에는 어색하고 느릴 수 있지만, 한 달만 꾸준히 연습하면 자연스러워질 거야. 그리고 3개월 후에는 테스트 없이는 코드를 작성하기 불안해질 거야. 😊

Happy Testing! 🎉

🎓 테스팅은 기술이 아니라 습관입니다
오늘부터 하나씩 실천해보세요!
작은 테스트 하나가 큰 버그를 막습니다 🛡️

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0