Kim-Baek 개발자 이야기

Kotlin 테스트 완벽 가이드 | Mockito-Kotlin, JUnit5, Spring Boot Test 본문

개발/java basic

Kotlin 테스트 완벽 가이드 | Mockito-Kotlin, JUnit5, Spring Boot Test

김백개발자 2025. 12. 9. 15:52
반응형

Mockito-Kotlin으로 더 간결하고 강력한 테스트 코드 작성하기


목차

  1. Kotlin 테스트 환경 설정
  2. JUnit5 기본 테스트
  3. Mockito-Kotlin 소개
  4. Mock 객체 생성과 스터빙
  5. Argument Matchers와 Captors
  6. Verification - 호출 검증
  7. Spring Boot 통합 테스트
  8. 실전 테스트 패턴

들어가며

Java에서 Mockito를 사용하다가 Kotlin으로 넘어왔을 때, 처음에는 당황스러웠습니다. when()이 Kotlin 예약어라서 컴파일 오류가 나고, any() matcher가 null을 허용하지 않아서 NPE가 발생했습니다.

// ❌ 컴파일 오류
when(userRepository.findById(1L)).thenReturn(user)

// ❌ NPE 발생
verify(userService).createUser(any())

Mockito-Kotlin 라이브러리를 발견하고 나서는 이런 문제들이 모두 해결되었습니다. Kotlin 전용 DSL과 Extension Functions 덕분에 테스트 코드가 훨씬 읽기 쉬워졌습니다.

// ✅ Mockito-Kotlin
whenever(userRepository.findById(1L)).thenReturn(user)
verify(userService).createUser(any())

오늘은 Kotlin에서 테스트 코드를 작성하는 모든 방법을 다뤄보겠습니다.


1. Kotlin 테스트 환경 설정

1.1 의존성 추가

build.gradle.kts

dependencies {
    // JUnit5
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    
    // Mockito-Kotlin
    testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
    
    // AssertJ (선택사항)
    testImplementation("org.assertj:assertj-core:3.24.2")
    
    // Kotest (선택사항)
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest:kotest-assertions-core:5.8.0")
    
    // Spring Boot Test
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.mockito", module = "mockito-core")
    }
    
    // MockK (Kotlin 전용, 선택사항)
    testImplementation("io.mockk:mockk:1.13.8")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

1.2 테스트 디렉토리 구조

src/
├── main/
│   └── kotlin/
│       └── com/example/
│           ├── domain/
│           ├── service/
│           └── controller/
└── test/
    └── kotlin/
        └── com/example/
            ├── domain/
            ├── service/
            │   └── UserServiceTest.kt
            └── controller/
                └── UserControllerTest.kt

2. JUnit5 기본 테스트

2.1 기본 테스트 작성

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class CalculatorTest {
    
    @Test
    fun `덧셈 테스트`() {
        // Given
        val calculator = Calculator()
        
        // When
        val result = calculator.add(2, 3)
        
        // Then
        assertEquals(5, result)
    }
    
    @Test
    fun `나눗셈 테스트`() {
        val calculator = Calculator()
        val result = calculator.divide(10, 2)
        assertEquals(5, result)
    }
    
    @Test
    fun `0으로 나누면 예외 발생`() {
        val calculator = Calculator()
        
        assertThrows<ArithmeticException> {
            calculator.divide(10, 0)
        }
    }
}

2.2 테스트 라이프사이클

import org.junit.jupiter.api.*

class UserServiceTest {
    
    companion object {
        @JvmStatic
        @BeforeAll
        fun setupAll() {
            println("모든 테스트 실행 전 1번 실행")
        }
        
        @JvmStatic
        @AfterAll
        fun teardownAll() {
            println("모든 테스트 실행 후 1번 실행")
        }
    }
    
    @BeforeEach
    fun setup() {
        println("각 테스트 실행 전")
    }
    
    @AfterEach
    fun teardown() {
        println("각 테스트 실행 후")
    }
    
    @Test
    fun `테스트 1`() {
        println("테스트 1 실행")
    }
    
    @Test
    fun `테스트 2`() {
        println("테스트 2 실행")
    }
}

2.3 Assertions

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

@Test
fun `다양한 Assertions`() {
    // 동등성
    assertEquals(5, 2 + 3)
    assertNotEquals(10, 2 + 3)
    
    // 참/거짓
    assertTrue(5 > 3)
    assertFalse(5 < 3)
    
    // Null 체크
    val value: String? = "hello"
    assertNotNull(value)
    assertNull(null)
    
    // 동일성 (같은 객체)
    val obj1 = User("John")
    val obj2 = obj1
    assertSame(obj1, obj2)
    
    // 컬렉션
    val list = listOf(1, 2, 3)
    assertTrue(list.contains(2))
    assertEquals(3, list.size)
    
    // 예외
    assertThrows<IllegalArgumentException> {
        throw IllegalArgumentException("오류")
    }
    
    // 여러 assertion 동시 실행
    assertAll(
        { assertEquals(5, 2 + 3) },
        { assertTrue(5 > 3) },
        { assertNotNull("test") }
    )
}

2.4 Parameterized Tests

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

class ParameterizedTestExample {
    
    @ParameterizedTest
    @ValueSource(ints = [1, 2, 3, 4, 5])
    fun `양수 테스트`(number: Int) {
        assertTrue(number > 0)
    }
    
    @ParameterizedTest
    @ValueSource(strings = ["", "  ", "\t", "\n"])
    fun `빈 문자열 테스트`(input: String) {
        assertTrue(input.isBlank())
    }
    
    @ParameterizedTest
    @CsvSource(
        "1, 2, 3",
        "10, 20, 30",
        "100, 200, 300"
    )
    fun `덧셈 테스트`(a: Int, b: Int, expected: Int) {
        assertEquals(expected, a + b)
    }
    
    @ParameterizedTest
    @MethodSource("provideUsers")
    fun `사용자 유효성 테스트`(user: User) {
        assertTrue(user.age >= 18)
    }
    
    companion object {
        @JvmStatic
        fun provideUsers() = listOf(
            User("John", 20),
            User("Jane", 25),
            User("Bob", 30)
        )
    }
}

3. Mockito-Kotlin 소개

3.1 왜 Mockito-Kotlin인가?

Java Mockito의 문제점

// ❌ when은 Kotlin 예약어
when(userRepository.findById(1L)).thenReturn(user)

// ❌ any()는 null을 반환해서 NPE 발생
verify(userService).createUser(any())

// ❌ 타입 추론 문제
when(userRepository.save(any(User::class.java))).thenReturn(user)

Mockito-Kotlin의 해결책

// ✅ whenever로 대체
whenever(userRepository.findById(1L)).thenReturn(user)

// ✅ Kotlin nullable 지원
verify(userService).createUser(any())

// ✅ 타입 추론
whenever(userRepository.save(any())).thenReturn(user)

3.2 Import 가이드

// Mockito-Kotlin
import org.mockito.kotlin.*

// JUnit5
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*

// 또는 AssertJ 사용
import org.assertj.core.api.Assertions.*

4. Mock 객체 생성과 스터빙

4.1 Mock 객체 생성

import org.mockito.kotlin.*

class UserServiceTest {
    
    // 방법 1: mock() 함수
    private val userRepository: UserRepository = mock()
    private val emailService: EmailService = mock()
    
    // 방법 2: mock<T>() 타입 추론
    private val userRepository = mock<UserRepository>()
    
    // 방법 3: @Mock 어노테이션 (JUnit 확장 필요)
    @Mock
    private lateinit var userRepository: UserRepository
    
    private lateinit var userService: UserService
    
    @BeforeEach
    fun setup() {
        userService = UserService(userRepository, emailService)
    }
}

4.2 Stub (스터빙)

기본 스터빙

@Test
fun `사용자 조회 테스트`() {
    // Given
    val userId = 1L
    val expectedUser = User(id = userId, name = "John", email = "john@example.com")
    
    whenever(userRepository.findById(userId))
        .thenReturn(Optional.of(expectedUser))
    
    // When
    val result = userService.getUser(userId)
    
    // Then
    assertEquals(expectedUser, result)
}

여러 값 반환

@Test
fun `연속 호출 테스트`() {
    val user1 = User(1L, "John")
    val user2 = User(2L, "Jane")
    
    whenever(userRepository.findById(any()))
        .thenReturn(Optional.of(user1))
        .thenReturn(Optional.of(user2))
    
    assertEquals(user1, userService.getUser(1L))
    assertEquals(user2, userService.getUser(2L))
}

예외 던지기

@Test
fun `사용자 없을 때 예외 발생`() {
    // Given
    whenever(userRepository.findById(any()))
        .thenThrow(NotFoundException("User not found"))
    
    // When & Then
    assertThrows<NotFoundException> {
        userService.getUser(999L)
    }
}

조건부 스터빙

@Test
fun `조건에 따른 다른 응답`() {
    whenever(userRepository.findById(1L))
        .thenReturn(Optional.of(User(1L, "John")))
    
    whenever(userRepository.findById(2L))
        .thenReturn(Optional.empty())
    
    assertNotNull(userService.getUser(1L))
    assertThrows<NotFoundException> {
        userService.getUser(2L)
    }
}

4.3 doReturn / doThrow

@Test
fun `doReturn 사용`() {
    // when-thenReturn 대신 doReturn-whenever
    doReturn(Optional.of(user))
        .whenever(userRepository).findById(1L)
    
    val result = userService.getUser(1L)
    assertEquals(user, result)
}

@Test
fun `doThrow 사용`() {
    doThrow(RuntimeException("DB 오류"))
        .whenever(userRepository).findById(any())
    
    assertThrows<RuntimeException> {
        userService.getUser(1L)
    }
}

4.4 Unit 반환 함수 스터빙

@Test
fun `void 메서드 스터빙`() {
    // Unit 반환 함수는 doNothing 사용
    doNothing().whenever(emailService).send(any(), any())
    
    // 또는 아무것도 하지 않음 (기본 동작)
    userService.sendWelcomeEmail(user)
    
    verify(emailService).send(user.email, "Welcome")
}

4.5 Answer 사용

@Test
fun `Answer로 동적 응답`() {
    whenever(userRepository.save(any<User>())).thenAnswer { invocation ->
        val user = invocation.getArgument<User>(0)
        user.copy(id = 1L)  // ID 할당
    }
    
    val newUser = User(name = "John", email = "john@example.com")
    val savedUser = userService.createUser(newUser)
    
    assertEquals(1L, savedUser.id)
}

5. Argument Matchers와 Captors

5.1 기본 Matchers

import org.mockito.kotlin.*

@Test
fun `Argument Matchers 사용`() {
    // any() - 모든 값
    whenever(userRepository.findById(any())).thenReturn(Optional.of(user))
    
    // eq() - 특정 값
    whenever(userRepository.findByEmail(eq("test@example.com")))
        .thenReturn(user)
    
    // anyOrNull() - null 포함 모든 값
    whenever(userService.createUser(anyOrNull())).thenReturn(user)
    
    // isNull() / isNotNull()
    whenever(userRepository.findByEmail(isNotNull())).thenReturn(user)
}

5.2 타입별 Matchers

@Test
fun `타입별 Matchers`() {
    // 문자열
    whenever(userRepository.findByEmail(anyString())).thenReturn(user)
    
    // 숫자
    whenever(orderService.calculatePrice(anyInt(), anyDouble()))
        .thenReturn(100.0)
    
    // 컬렉션
    whenever(userService.findUsers(anyList())).thenReturn(emptyList())
    
    // 커스텀 타입
    whenever(userRepository.save(any<User>())).thenReturn(user)
}

5.3 Argument Captors

@Test
fun `Argument Captor로 인자 캡처`() {
    // Given
    val userCaptor = argumentCaptor<User>()
    
    // When
    userService.createUser(UserCreateRequest("John", "john@example.com"))
    
    // Then
    verify(userRepository).save(userCaptor.capture())
    
    val capturedUser = userCaptor.firstValue
    assertEquals("John", capturedUser.name)
    assertEquals("john@example.com", capturedUser.email)
}

@Test
fun `여러 번 캡처`() {
    // Given
    val captor = argumentCaptor<String>()
    
    // When
    emailService.send("user1@example.com", "Message 1")
    emailService.send("user2@example.com", "Message 2")
    
    // Then
    verify(emailService, times(2)).send(captor.capture(), any())
    
    val capturedEmails = captor.allValues
    assertEquals(2, capturedEmails.size)
    assertEquals("user1@example.com", capturedEmails[0])
    assertEquals("user2@example.com", capturedEmails[1])
}

5.4 커스텀 Matchers

@Test
fun `커스텀 Matcher 사용`() {
    // 이메일 형식 검증 Matcher
    fun isValidEmail() = argThat<String> { 
        this.contains("@") && this.contains(".")
    }
    
    whenever(userRepository.findByEmail(isValidEmail()))
        .thenReturn(user)
    
    // 사용
    assertNotNull(userService.findByEmail("valid@example.com"))
    assertThrows<NotFoundException> {
        userService.findByEmail("invalid-email")
    }
}

@Test
fun `복잡한 객체 Matcher`() {
    fun isAdultUser() = argThat<User> { 
        this.age >= 18 
    }
    
    whenever(userRepository.save(isAdultUser()))
        .thenReturn(user)
    
    val adultUser = User(name = "John", age = 25)
    val minorUser = User(name = "Jane", age = 15)
    
    assertDoesNotThrow { userService.createUser(adultUser) }
}

5.5 check 함수

@Test
fun `check로 인자 검증`() {
    userService.createUser(UserCreateRequest("John", "john@example.com"))
    
    verify(userRepository).save(check { user ->
        assertEquals("John", user.name)
        assertTrue(user.email.contains("@"))
        assertNotNull(user.createdAt)
    })
}

6. Verification - 호출 검증

6.1 기본 Verification

@Test
fun `메서드 호출 검증`() {
    // Given
    val userId = 1L
    
    // When
    userService.getUser(userId)
    
    // Then - 1번 호출 확인 (기본)
    verify(userRepository).findById(userId)
    
    // 정확히 1번
    verify(userRepository, times(1)).findById(userId)
}

6.2 호출 횟수 검증

@Test
fun `호출 횟수 검증`() {
    // 여러 번 호출
    repeat(3) {
        userService.getUser(1L)
    }
    
    // 정확히 3번
    verify(userRepository, times(3)).findById(1L)
    
    // 최소 2번
    verify(userRepository, atLeast(2)).findById(1L)
    
    // 최대 5번
    verify(userRepository, atMost(5)).findById(1L)
    
    // 1번 이상
    verify(userRepository, atLeastOnce()).findById(1L)
}

6.3 호출 안 됨 검증

@Test
fun `메서드가 호출되지 않음을 검증`() {
    userService.getUser(1L)
    
    // 특정 메서드는 호출 안 됨
    verify(userRepository, never()).deleteById(any())
    
    // 아무 메서드도 호출 안 됨
    verifyNoInteractions(emailService)
    
    // 더 이상 호출 없음
    verify(userRepository).findById(1L)
    verifyNoMoreInteractions(userRepository)
}

6.4 호출 순서 검증

@Test
fun `호출 순서 검증`() {
    // When
    userService.createAndNotify(request)
    
    // Then - 순서대로 호출 확인
    inOrder(userRepository, emailService) {
        verify(userRepository).save(any())
        verify(emailService).send(any(), any())
    }
}

6.5 Timeout 검증

@Test
fun `타임아웃 내 호출 검증`() {
    // 비동기 작업
    userService.createUserAsync(request)
    
    // 2초 내에 호출되어야 함
    verify(userRepository, timeout(2000)).save(any())
}

6.6 실전 예제

@Test
fun `사용자 생성 시 이메일 발송 확인`() {
    // Given
    val request = UserCreateRequest("John", "john@example.com")
    val savedUser = User(1L, "John", "john@example.com")
    
    whenever(userRepository.save(any<User>())).thenReturn(savedUser)
    doNothing().whenever(emailService).send(any(), any())
    
    // When
    userService.createUser(request)
    
    // Then
    verify(userRepository).save(any<User>())
    verify(emailService).send(
        eq("john@example.com"),
        argThat { it.contains("환영") }
    )
    verifyNoMoreInteractions(userRepository, emailService)
}

@Test
fun `사용자 삭제 시 연관 데이터도 삭제`() {
    // Given
    val userId = 1L
    val user = User(userId, "John", "john@example.com")
    whenever(userRepository.findById(userId)).thenReturn(Optional.of(user))
    
    // When
    userService.deleteUser(userId)
    
    // Then - 순서대로 실행 확인
    inOrder(userRepository, orderRepository, emailService) {
        verify(userRepository).findById(userId)
        verify(orderRepository).deleteByUserId(userId)
        verify(userRepository).delete(user)
        verify(emailService).sendGoodbyeEmail(user.email)
    }
}

7. Spring Boot 통합 테스트

7.1 @SpringBootTest

@SpringBootTest
class UserServiceIntegrationTest {
    
    @Autowired
    private lateinit var userService: UserService
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @BeforeEach
    fun setup() {
        userRepository.deleteAll()
    }
    
    @Test
    fun `사용자 생성 통합 테스트`() {
        // Given
        val request = UserCreateRequest("John", "john@example.com")
        
        // When
        val createdUser = userService.createUser(request)
        
        // Then
        assertNotNull(createdUser.id)
        assertEquals("John", createdUser.name)
        
        // DB 확인
        val foundUser = userRepository.findById(createdUser.id!!)
        assertTrue(foundUser.isPresent)
    }
}

7.2 @WebMvcTest (Controller 테스트)

@WebMvcTest(UserController::class)
class UserControllerTest {
    
    @Autowired
    private lateinit var mockMvc: MockMvc
    
    @MockBean
    private lateinit var userService: UserService
    
    @Autowired
    private lateinit var objectMapper: ObjectMapper
    
    @Test
    fun `사용자 조회 API 테스트`() {
        // Given
        val userId = 1L
        val user = UserDto(userId, "John", "john@example.com")
        
        whenever(userService.getUser(userId)).thenReturn(user)
        
        // When & Then
        mockMvc.perform(get("/api/users/$userId"))
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.id").value(userId))
            .andExpect(jsonPath("$.name").value("John"))
            .andExpect(jsonPath("$.email").value("john@example.com"))
    }
    
    @Test
    fun `사용자 생성 API 테스트`() {
        // Given
        val request = UserCreateRequest("John", "john@example.com")
        val createdUser = UserDto(1L, "John", "john@example.com")
        
        whenever(userService.createUser(any())).thenReturn(createdUser)
        
        // When & Then
        mockMvc.perform(
            post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        )
            .andExpect(status().isCreated)
            .andExpect(header().exists("Location"))
            .andExpect(jsonPath("$.id").value(1L))
            .andExpect(jsonPath("$.name").value("John"))
    }
    
    @Test
    fun `사용자 없을 때 404 반환`() {
        // Given
        whenever(userService.getUser(any()))
            .thenThrow(NotFoundException("User not found"))
        
        // When & Then
        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound)
    }
}

7.3 @DataJpaTest (Repository 테스트)

@DataJpaTest
class UserRepositoryTest {
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @Autowired
    private lateinit var testEntityManager: TestEntityManager
    
    @Test
    fun `이메일로 사용자 조회`() {
        // Given
        val user = User(name = "John", email = "john@example.com")
        testEntityManager.persist(user)
        testEntityManager.flush()
        
        // When
        val foundUser = userRepository.findByEmail("john@example.com")
        
        // Then
        assertNotNull(foundUser)
        assertEquals("John", foundUser?.name)
    }
    
    @Test
    fun `이메일 존재 여부 확인`() {
        // Given
        val user = User(name = "John", email = "john@example.com")
        testEntityManager.persist(user)
        testEntityManager.flush()
        
        // When & Then
        assertTrue(userRepository.existsByEmail("john@example.com"))
        assertFalse(userRepository.existsByEmail("notexist@example.com"))
    }
}

7.4 @MockBean vs @Mock

// @MockBean - Spring 컨텍스트에 Mock 주입
@SpringBootTest
class ServiceWithMockBeanTest {
    
    @MockBean  // Spring Bean으로 등록
    private lateinit var userRepository: UserRepository
    
    @Autowired  // 실제 Bean 주입 (Mock이 주입됨)
    private lateinit var userService: UserService
    
    @Test
    fun test() {
        whenever(userRepository.findById(1L)).thenReturn(Optional.of(user))
        // userService는 Mock된 userRepository 사용
    }
}

// @Mock - 순수 Mockito Mock
class ServiceWithMockTest {
    
    @Mock  // Mockito Mock
    private lateinit var userRepository: UserRepository
    
    @InjectMocks  // Mock 주입
    private lateinit var userService: UserService
    
    @BeforeEach
    fun setup() {
        MockitoAnnotations.openMocks(this)
    }
    
    @Test
    fun test() {
        whenever(userRepository.findById(1L)).thenReturn(Optional.of(user))
    }
}

7.5 TestConfiguration

@TestConfiguration
class TestConfig {
    
    @Bean
    @Primary  // 테스트용 Bean이 우선
    fun testEmailService(): EmailService {
        return object : EmailService {
            override fun send(to: String, message: String) {
                println("테스트 이메일 발송: $to - $message")
            }
        }
    }
}

@SpringBootTest
@Import(TestConfig::class)
class UserServiceTest {
    
    @Autowired
    private lateinit var userService: UserService
    
    @Autowired
    private lateinit var emailService: EmailService
    
    @Test
    fun `이메일 발송 테스트`() {
        userService.sendWelcomeEmail(user)
        // TestConfig의 EmailService 사용됨
    }
}

8. 실전 테스트 패턴

8.1 Given-When-Then 패턴

@Test
fun `사용자 생성 테스트`() {
    // Given - 테스트 준비
    val request = UserCreateRequest("John", "john@example.com")
    val savedUser = User(1L, "John", "john@example.com")
    
    whenever(userRepository.existsByEmail(request.email))
        .thenReturn(false)
    whenever(userRepository.save(any<User>()))
        .thenReturn(savedUser)
    
    // When - 테스트 실행
    val result = userService.createUser(request)
    
    // Then - 검증
    assertNotNull(result)
    assertEquals(1L, result.id)
    assertEquals("John", result.name)
    verify(userRepository).save(any<User>())
    verify(emailService).sendWelcomeEmail(request.email)
}

8.2 Test Fixture 패턴

abstract class TestBase {
    
    protected fun createUser(
        id: Long = 1L,
        name: String = "John",
        email: String = "john@example.com",
        age: Int = 25
    ): User {
        return User(
            id = id,
            name = name,
            email = email,
            age = age,
            createdAt = LocalDateTime.now()
        )
    }
    
    protected fun createUserRequest(
        name: String = "John",
        email: String = "john@example.com"
    ): UserCreateRequest {
        return UserCreateRequest(name, email)
    }
}

class UserServiceTest : TestBase() {
    
    @Test
    fun `Fixture 사용 테스트`() {
        // 기본값으로 생성
        val user = createUser()
        
        // 일부만 커스터마이징
        val customUser = createUser(name = "Jane", age = 30)
        
        val request = createUserRequest(email = "custom@example.com")
    }
}

8.3 Object Mother 패턴

object UserMother {
    fun createDefault() = User(
        id = 1L,
        name = "John Doe",
        email = "john@example.com",
        age = 25,
        role = UserRole.USER
    )
    
    fun createAdmin() = User(
        id = 2L,
        name = "Admin",
        email = "admin@example.com",
        age = 30,
        role = UserRole.ADMIN
    )
    
    fun createMinor() = User(
        id = 3L,
        name = "Minor",
        email = "minor@example.com",
        age = 15,
        role = UserRole.USER
    )
}

class UserServiceTest {
    @Test
    fun test() {
        val user = UserMother.createDefault()
        val admin = UserMother.createAdmin()
    }
}

8.4 Builder 패턴

class UserBuilder {
    private var id: Long? = 1L
    private var name: String = "John"
    private var email: String = "john@example.com"
    private var age: Int = 25
    private var role: UserRole = UserRole.USER
    
    fun id(id: Long) = apply { this.id = id }
    fun name(name: String) = apply { this.name = name }
    fun email(email: String) = apply { this.email = email }
    fun age(age: Int) = apply { this.age = age }
    fun role(role: UserRole) = apply { this.role = role }
    
    fun build() = User(
        id = id,
        name = name,
        email = email,
        age = age,
        role = role
    )
}

// 사용
@Test
fun test() {
    val user = UserBuilder()
        .name("Jane")
        .age(30)
        .role(UserRole.ADMIN)
        .build()
}

8.5 예외 테스트 패턴

@Test
fun `예외 메시지 검증`() {
    whenever(userRepository.findById(any()))
        .thenReturn(Optional.empty())
    
    val exception = assertThrows<NotFoundException> {
        userService.getUser(999L)
    }
    
    assertEquals("사용자를 찾을 수 없습니다: 999", exception.message)
}

@Test
fun `예외 원인 검증`() {
    val cause = RuntimeException("DB 연결 실패")
    whenever(userRepository.findById(any()))
        .thenThrow(cause)
    
    val exception = assertThrows<ServiceException> {
        userService.getUser(1L)
    }
    
    assertEquals(cause, exception.cause)
}

8.6 비동기 테스트

@Test
fun `비동기 작업 테스트`() {
    // Given
    val latch = CountDownLatch(1)
    var result: User? = null
    
    // When
    userService.createUserAsync(request) { user ->
        result = user
        latch.countDown()
    }
    
    // Then
    assertTrue(latch.await(2, TimeUnit.SECONDS))
    assertNotNull(result)
    assertEquals("John", result?.name)
}

8.7 실전 예제: 주문 서비스 테스트

class OrderServiceTest {
    
    private val orderRepository: OrderRepository = mock()
    private val productRepository: ProductRepository = mock()
    private val paymentService: PaymentService = mock()
    private val inventoryService: InventoryService = mock()
    
    private val orderService = OrderService(
        orderRepository,
        productRepository,
        paymentService,
        inventoryService
    )
    
    @Test
    fun `주문 생성 성공 테스트`() {
        // Given
        val request = OrderCreateRequest(
            userId = 1L,
            productId = 100L,
            quantity = 2,
            paymentInfo = PaymentInfo("card", "1234-5678-9012-3456")
        )
        
        val product = Product(100L, "상품명", 10000, stock = 10)
        val payment = Payment(1L, 20000, PaymentStatus.COMPLETED)
        val order = Order(1L, 1L, 100L, 2, payment.id)
        
        whenever(productRepository.findById(100L))
            .thenReturn(Optional.of(product))
        whenever(inventoryService.checkStock(100L, 2))
            .thenReturn(true)
        whenever(paymentService.processPayment(any()))
            .thenReturn(payment)
        whenever(orderRepository.save(any<Order>()))
            .thenReturn(order)
        
        // When
        val result = orderService.createOrder(request)
        
        // Then
        assertNotNull(result)
        assertEquals(1L, result.id)
        
        // 호출 순서 검증
        inOrder(
            productRepository,
            inventoryService,
            paymentService,
            orderRepository
        ) {
            verify(productRepository).findById(100L)
            verify(inventoryService).checkStock(100L, 2)
            verify(paymentService).processPayment(any())
            verify(inventoryService).decreaseStock(100L, 2)
            verify(orderRepository).save(any<Order>())
        }
    }
    
    @Test
    fun `재고 부족 시 주문 실패`() {
        // Given
        val request = OrderCreateRequest(1L, 100L, 100, PaymentInfo("card", "1234"))
        val product = Product(100L, "상품명", 10000, stock = 5)
        
        whenever(productRepository.findById(100L))
            .thenReturn(Optional.of(product))
        whenever(inventoryService.checkStock(100L, 100))
            .thenReturn(false)
        
        // When & Then
        assertThrows<InsufficientStockException> {
            orderService.createOrder(request)
        }
        
        // 결제 시도하지 않음 확인
        verify(paymentService, never()).processPayment(any())
        verify(orderRepository, never()).save(any())
    }
    
    @Test
    fun `결제 실패 시 롤백`() {
        // Given
        val request = OrderCreateRequest(1L, 100L, 2, PaymentInfo("card", "1234"))
        val product = Product(100L, "상품명", 10000, stock = 10)
        
        whenever(productRepository.findById(100L))
            .thenReturn(Optional.of(product))
        whenever(inventoryService.checkStock(100L, 2))
            .thenReturn(true)
        whenever(paymentService.processPayment(any()))
            .thenThrow(PaymentFailedException("결제 실패"))
        
        // When & Then
        assertThrows<PaymentFailedException> {
            orderService.createOrder(request)
        }
        
        // 재고 차감되지 않음 확인
        verify(inventoryService, never()).decreaseStock(any(), any())
        verify(orderRepository, never()).save(any())
    }
}

마치며

핵심 요약

Mockito-Kotlin의 장점

  1. Kotlin 친화적: whenever 대신 when 사용 불가 문제 해결
  2. Null Safety: any() 등이 Kotlin null 타입과 호환
  3. 타입 추론: 제네릭 타입 추론 개선
  4. DSL 스타일: 더 읽기 쉬운 테스트 코드

테스트 작성 원칙

  • ✅ Given-When-Then 구조 유지
  • ✅ 하나의 테스트는 하나의 기능만
  • ✅ 테스트 이름은 명확하게 (한글 가능)
  • ✅ Fixture/Builder 패턴으로 재사용성 높이기
  • ✅ 필요한 것만 Mock, 나머지는 실제 객체

주의사항

  • ⚠️ 과도한 Mock은 테스트 신뢰도 저하
  • ⚠️ 구현이 아닌 행위를 테스트
  • ⚠️ Private 메서드는 테스트하지 않기
  • ⚠️ 테스트 코드도 리팩토링 필요

Mock 라이브러리 선택

Mockito-Kotlin: Java 프로젝트 호환, 안정적
MockK: Kotlin 전용, 더 많은 기능

MockK 간단 비교

// Mockito-Kotlin
val mock = mock<UserRepository>()
whenever(mock.findById(1L)).thenReturn(user)

// MockK
val mock = mockk<UserRepository>()
every { mock.findById(1L) } returns user

다음 학습 방향

이제 Kotlin 프로젝트의 전체 개발 사이클을 다룰 수 있습니다:

  • ✅ 기본 문법과 클래스
  • ✅ Null Safety
  • ✅ 함수형 프로그래밍
  • ✅ Spring Boot 통합
  • ✅ 예외 처리
  • ✅ 테스트 작성

더 나아가기

  • Kotlin Coroutine과 비동기 처리
  • Kotlin DSL 만들기
  • Kotlin Multiplatform
  • Ktor 프레임워크

함께 보면 좋은 글

▶ Kotlin과 Spring Boot 완벽 통합 | Annotations, DI, 트랜잭션
▶ Kotlin 예외 처리 완벽 가이드 | runCatching, Result 타입
▶ Kotlin 제어 구조와 타입 시스템 | when, Sealed Class


참고 자료

 

반응형
Comments