Kim-Baek 개발자 이야기

Kotlin 테스트 코드 완벽 가이드 | JUnit5 + MockK로 안전한 코드 만들기 본문

개발/java basic

Kotlin 테스트 코드 완벽 가이드 | JUnit5 + MockK로 안전한 코드 만들기

김백개발자 2026. 1. 4. 21:40
반응형

이 글을 읽으면: Mockito 대신 Kotlin 친화적인 MockK로 테스트를 작성하는 방법을 배울 수 있습니다. Controller부터 Service, Repository까지 실전 테스트 패턴을 완벽하게 마스터하세요.


📌 목차

  1. 들어가며 - 왜 테스트 코드가 필요할까?
  2. 테스트 환경 설정 - JUnit5 + MockK
  3. JUnit5 기본 - 테스트 작성법
  4. MockK - Kotlin 친화적 목 프레임워크
  5. Controller 테스트 - MockMvc
  6. Service 테스트 - 비즈니스 로직
  7. Repository 테스트 - 데이터 계층
  8. 통합 테스트 - 전체 플로우
  9. 마무리 - 다음 편 예고

들어가며 - 왜 테스트 코드가 필요할까?

실제 프로젝트에서 겪은 사고

2024년 12월, 금요일 오후 5시

상황: 주문 시스템 배포 후 30분
알림: "결제가 안 돼요!" (고객센터 폭주)

원인 파악:
- 할인 로직 수정 중 버그 발생
- "10% 할인"이 "90% 할인"으로 계산됨
- 단순한 부호 실수 (- 대신 +)

if (discountRate > 0) {
    finalPrice = price + (price * discountRate)  // ❌
}

손실:
- 매출 손실 2천만원
- 고객 신뢰 하락
- 긴급 야근

교훈: "테스트 코드가 있었다면..."

테스트 코드의 가치

비유: 안전벨트

운전할 때마다 사고나는 건 아니지만,
사고 날 때 생명을 구함

테스트 코드:
- 매번 버그 나는 건 아니지만
- 버그 날 때 회사를 구함

실제 효과

지표 테스트 없음 테스트 있음

버그 발견 배포 후 (고객이) 개발 중 (개발자가)
수정 비용 100배 1배
리팩토링 두려움 자신감
배포 시간 수동 검증 2시간 자동 테스트 5분

테스트 피라미드

        /\
       /  \  E2E (10%)
      /    \  - 전체 시나리오
     /------\
    /        \ Integration (20%)
   /          \ - API 테스트
  /------------\
 /              \ Unit (70%)
/________________\ - 단위 테스트

아래로 갈수록:
- 빠름
- 많이 작성
- 세밀한 검증

테스트 환경 설정 - JUnit5 + MockK

build.gradle.kts 설정

dependencies {
    // Spring Boot Test
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.mockito")  // Mockito 제외
    }
    
    // MockK - Kotlin 전용 Mock 라이브러리
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("com.ninja-squad:springmockk:4.0.2")  // Spring + MockK
    
    // Kotest (선택사항 - 더 Kotlin스러운 Assertion)
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest:kotest-assertions-core:5.8.0")
}

tasks.withType<Test> {
    useJUnitPlatform()  // JUnit5 사용
}

Mockito vs MockK 비교

Mockito의 문제점

// ❌ Mockito - final 클래스/메서드 목킹 불가
class UserService {  // Kotlin은 기본 final
    fun findUser(id: Long): User {  // 이것도 final
        // ...
    }
}

// Mockito로 목킹 시도
@Mock
private lateinit var userService: UserService  // 💥 에러!

// 해결: open으로 변경해야 함 (Kotlin스럽지 않음)
open class UserService {
    open fun findUser(id: Long): User { }
}

MockK의 해결책

// ✅ MockK - final도 OK!
class UserService {
    fun findUser(id: Long): User { }
}

// 바로 목킹 가능
private val userService = mockk<UserService>()

JUnit5 기본 - 테스트 작성법

기본 테스트 구조

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

class CalculatorTest {
    
    @Test
    fun `1 더하기 1은 2다`() {  // 한글 함수명 가능!
        // Given (준비)
        val calculator = Calculator()
        
        // When (실행)
        val result = calculator.add(1, 1)
        
        // Then (검증)
        assertEquals(2, result)
    }
}

왜 한글 함수명을 쓸까?

테스트 실패 시 읽기 쉬움:

❌ testAddition() failed
   → 뭘 테스트한 거지?

✅ "1 더하기 1은 2다" failed
   → 바로 이해됨!

Lifecycle 어노테이션

import org.junit.jupiter.api.*

class UserServiceTest {
    
    companion object {
        @JvmStatic
        @BeforeAll
        fun beforeAll() {
            println("전체 테스트 시작 전 한 번")
        }
        
        @JvmStatic
        @AfterAll
        fun afterAll() {
            println("전체 테스트 끝난 후 한 번")
        }
    }
    
    @BeforeEach
    fun beforeEach() {
        println("각 테스트 전에 실행")
    }
    
    @AfterEach
    fun afterEach() {
        println("각 테스트 후에 실행")
    }
    
    @Test
    fun test1() {
        println("테스트 1")
    }
    
    @Test
    fun test2() {
        println("테스트 2")
    }
}
// 출력:
// 전체 테스트 시작 전 한 번
// 각 테스트 전에 실행
// 테스트 1
// 각 테스트 후에 실행
// 각 테스트 전에 실행
// 테스트 2
// 각 테스트 후에 실행
// 전체 테스트 끝난 후 한 번

Assertions - 검증

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

class AssertionTest {
    
    @Test
    fun `다양한 검증`() {
        // 값 비교
        assertEquals(2, 1 + 1)
        assertNotEquals(3, 1 + 1)
        
        // 참/거짓
        assertTrue(5 > 3)
        assertFalse(5 < 3)
        
        // Null 체크
        assertNull(null)
        assertNotNull("Not null")
        
        // 예외 검증
        assertThrows<IllegalArgumentException> {
            throw IllegalArgumentException("에러!")
        }
        
        // 여러 개 동시 검증
        assertAll(
            { assertEquals(2, 1 + 1) },
            { assertEquals(4, 2 + 2) },
            { assertEquals(6, 3 + 3) }
        )
    }
}

Kotest Assertions (더 읽기 쉬운)

import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain

class KotestTest {
    
    @Test
    fun `Kotest 스타일 검증`() {
        // 값 비교
        (1 + 1) shouldBe 2
        (1 + 1) shouldNotBe 3
        
        // 문자열
        "Hello World" shouldContain "World"
        
        // 컬렉션
        val list = listOf(1, 2, 3)
        list shouldBe listOf(1, 2, 3)
    }
}

MockK - Kotlin 친화적 목 프레임워크

기본 사용법

import io.mockk.*

class UserServiceTest {
    
    @Test
    fun `목 객체 기본`() {
        // Mock 생성
        val userRepository = mockk<UserRepository>()
        
        // 동작 정의 (Stubbing)
        every { userRepository.findById(1L) } returns User(1L, "규철")
        
        // 사용
        val user = userRepository.findById(1L)
        
        // 검증
        user.name shouldBe "규철"
        
        // 호출 여부 확인
        verify { userRepository.findById(1L) }
    }
}

다양한 Stubbing

class MockKExampleTest {
    
    @Test
    fun `다양한 스터빙`() {
        val repository = mockk<UserRepository>()
        
        // 1. 특정 값 반환
        every { repository.findById(1L) } returns User(1L, "규철")
        
        // 2. 여러 번 호출 시 다른 값
        every { repository.findById(2L) } returnsMany listOf(
            User(2L, "영희"),
            User(2L, "철수")
        )
        
        // 3. 예외 던지기
        every { repository.findById(-1L) } throws IllegalArgumentException("Invalid ID")
        
        // 4. 조건부 반환
        every { repository.findById(any()) } answers {
            val id = firstArg<Long>()
            if (id > 0) User(id, "User$id") else null
        }
        
        // 검증
        repository.findById(1L)!!.name shouldBe "규철"
        repository.findById(2L)!!.name shouldBe "영희"
        repository.findById(2L)!!.name shouldBe "철수"
    }
}

Argument Matchers

class MatcherTest {
    
    @Test
    fun `인자 매칭`() {
        val service = mockk<EmailService>()
        
        // any() - 아무 값이나
        every { service.send(any(), any()) } returns true
        
        // eq() - 정확히 일치
        every { service.send(eq("user@example.com"), any()) } returns true
        
        // 커스텀 매처
        every { 
            service.send(
                match { it.contains("@") },  // 이메일 형식
                any()
            ) 
        } returns true
        
        // 검증
        verify { service.send("user@example.com", "Welcome") }
    }
}

Relaxed Mock - 자동 기본값

class RelaxedMockTest {
    
    @Test
    fun `relaxed mock`() {
        // relaxed = true → 미리 정의 안 해도 기본값 반환
        val repository = mockk<UserRepository>(relaxed = true)
        
        // 스터빙 안 했는데도 동작
        val user = repository.findById(1L)  // null 반환 (기본값)
        
        user shouldBe null
        
        // 필요한 것만 정의
        every { repository.findById(100L) } returns User(100L, "VIP")
        
        repository.findById(100L)!!.name shouldBe "VIP"
    }
}

Controller 테스트 - MockMvc

기본 설정

@WebMvcTest(UserController::class)  // Controller만 로드
class UserControllerTest {
    
    @Autowired
    private lateinit var mockMvc: MockMvc
    
    @MockkBean  // Spring Context의 Bean을 MockK로
    private lateinit var userService: UserService
    
    @Test
    fun `사용자 조회 - 성공`() {
        // Given
        val userId = 1L
        val user = User(userId, "규철", "user@example.com", 30)
        
        every { userService.findById(userId) } returns user
        
        // When & Then
        mockMvc.get("/api/users/{id}", userId)
            .andExpect {
                status { isOk() }
                jsonPath("$.id") { value(1) }
                jsonPath("$.name") { value("규철") }
                jsonPath("$.email") { value("user@example.com") }
            }
        
        verify { userService.findById(userId) }
    }
}

POST 요청 테스트

@Test
fun `사용자 생성 - 성공`() {
    // Given
    val request = CreateUserRequest(
        name = "규철",
        email = "user@example.com",
        age = 30
    )
    
    val createdUser = User(1L, request.name, request.email, request.age)
    
    every { userService.create(any()) } returns createdUser
    
    // When & Then
    mockMvc.post("/api/users") {
        contentType = MediaType.APPLICATION_JSON
        content = objectMapper.writeValueAsString(request)
    }.andExpect {
        status { isCreated() }
        jsonPath("$.id") { value(1) }
        jsonPath("$.name") { value("규철") }
    }
}

Validation 테스트

@Test
fun `사용자 생성 - 검증 실패`() {
    // Given - 잘못된 요청 (이메일 형식 오류)
    val invalidRequest = """
        {
            "name": "규철",
            "email": "invalid-email",
            "age": 10
        }
    """.trimIndent()
    
    // When & Then
    mockMvc.post("/api/users") {
        contentType = MediaType.APPLICATION_JSON
        content = invalidRequest
    }.andExpect {
        status { isBadRequest() }
        jsonPath("$.errors.email") { exists() }
        jsonPath("$.errors.age") { exists() }
    }
    
    // 서비스 호출 안 되었는지 확인
    verify(exactly = 0) { userService.create(any()) }
}

예외 처리 테스트

@Test
fun `사용자 조회 - 없는 사용자`() {
    // Given
    val userId = 999L
    every { userService.findById(userId) } throws UserNotFoundException(userId)
    
    // When & Then
    mockMvc.get("/api/users/{id}", userId)
        .andExpect {
            status { isNotFound() }
            jsonPath("$.status") { value(404) }
            jsonPath("$.message") { value("사용자를 찾을 수 없습니다: $userId") }
        }
}

Service 테스트 - 비즈니스 로직

단순 조회 테스트

class UserServiceTest {
    
    private val userRepository = mockk<UserRepository>()
    private val userService = UserService(userRepository)
    
    @Test
    fun `사용자 조회 - 성공`() {
        // Given
        val userId = 1L
        val user = User(userId, "규철", "user@example.com", 30)
        
        every { userRepository.findById(userId) } returns Optional.of(user)
        
        // When
        val result = userService.findById(userId)
        
        // Then
        result shouldBe user
        verify { userRepository.findById(userId) }
    }
    
    @Test
    fun `사용자 조회 - 없는 사용자`() {
        // Given
        val userId = 999L
        every { userRepository.findById(userId) } returns Optional.empty()
        
        // When & Then
        shouldThrow<UserNotFoundException> {
            userService.findById(userId)
        }
    }
}

복잡한 비즈니스 로직 테스트

class OrderServiceTest {
    
    private val orderRepository = mockk<OrderRepository>()
    private val userRepository = mockk<UserRepository>()
    private val productRepository = mockk<ProductRepository>()
    
    private val orderService = OrderService(
        orderRepository,
        userRepository,
        productRepository
    )
    
    @Test
    fun `주문 생성 - 성공`() {
        // Given
        val userId = 1L
        val productId = 1L
        val quantity = 2
        
        val user = User(userId, "규철", "user@example.com", 30)
        val product = Product(productId, "노트북", 1000000, 10)
        
        every { userRepository.findById(userId) } returns Optional.of(user)
        every { productRepository.findById(productId) } returns Optional.of(product)
        every { orderRepository.save(any()) } answers { firstArg() }
        
        val request = CreateOrderRequest(userId, productId, quantity)
        
        // When
        val order = orderService.createOrder(request)
        
        // Then
        order.amount shouldBe 2000000  // 1,000,000 * 2
        product.stock shouldBe 8       // 10 - 2
        
        verify { orderRepository.save(any()) }
    }
    
    @Test
    fun `주문 생성 - 재고 부족`() {
        // Given
        val userId = 1L
        val productId = 1L
        val quantity = 100  // 재고보다 많이 주문
        
        val user = User(userId, "규철", "user@example.com", 30)
        val product = Product(productId, "노트북", 1000000, 10)  // 재고 10개
        
        every { userRepository.findById(userId) } returns Optional.of(user)
        every { productRepository.findById(productId) } returns Optional.of(product)
        
        val request = CreateOrderRequest(userId, productId, quantity)
        
        // When & Then
        shouldThrow<InsufficientStockException> {
            orderService.createOrder(request)
        }
        
        // 저장 호출 안 되었는지 확인
        verify(exactly = 0) { orderRepository.save(any()) }
    }
}

트랜잭션 테스트

@SpringBootTest
@Transactional  // 각 테스트 후 롤백
class UserServiceIntegrationTest {
    
    @Autowired
    private lateinit var userService: UserService
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @Test
    fun `사용자 생성 후 조회`() {
        // Given
        val request = CreateUserRequest("규철", "user@example.com", 30)
        
        // When
        val created = userService.create(request)
        val found = userService.findById(created.id)
        
        // Then
        found.name shouldBe "규철"
        found.email shouldBe "user@example.com"
    }
}

Repository 테스트 - 데이터 계층

DataJpaTest 사용

@DataJpaTest  // JPA 관련 빈만 로드
class UserRepositoryTest {
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @Test
    fun `사용자 저장 및 조회`() {
        // Given
        val user = User(
            name = "규철",
            email = "user@example.com",
            age = 30
        )
        
        // When
        val saved = userRepository.save(user)
        val found = userRepository.findById(saved.id)
        
        // Then
        found.isPresent shouldBe true
        found.get().name shouldBe "규철"
    }
    
    @Test
    fun `이메일로 사용자 조회`() {
        // Given
        userRepository.save(User(name = "규철", email = "user1@example.com", age = 30))
        userRepository.save(User(name = "영희", email = "user2@example.com", age = 25))
        
        // When
        val user = userRepository.findByEmail("user1@example.com")
        
        // Then
        user shouldNotBe null
        user!!.name shouldBe "규철"
    }
}

쿼리 메서드 테스트

@Test
fun `나이 범위로 사용자 조회`() {
    // Given
    userRepository.saveAll(listOf(
        User(name = "규철", email = "user1@example.com", age = 30),
        User(name = "영희", email = "user2@example.com", age = 25),
        User(name = "철수", email = "user3@example.com", age = 35),
        User(name = "민수", email = "user4@example.com", age = 20)
    ))
    
    // When
    val users = userRepository.findByAgeGreaterThanEqual(25)
    
    // Then
    users.size shouldBe 3
    users.map { it.name } shouldContainAll listOf("규철", "영희", "철수")
}

@Query 테스트

@Test
fun `이름 검색`() {
    // Given
    userRepository.saveAll(listOf(
        User(name = "김규철", email = "user1@example.com", age = 30),
        User(name = "이규철", email = "user2@example.com", age = 25),
        User(name = "박영희", email = "user3@example.com", age = 35)
    ))
    
    // When
    val users = userRepository.searchByName("규철")
    
    // Then
    users.size shouldBe 2
    users.all { it.name.contains("규철") } shouldBe true
}

통합 테스트 - 전체 플로우

@SpringBootTest

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class UserApiIntegrationTest {
    
    @Autowired
    private lateinit var mockMvc: MockMvc
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @BeforeEach
    fun setup() {
        userRepository.deleteAll()
    }
    
    @Test
    fun `사용자 CRUD 전체 플로우`() {
        // 1. 생성
        val createRequest = """
            {
                "name": "규철",
                "email": "user@example.com",
                "age": 30
            }
        """.trimIndent()
        
        val createResponse = mockMvc.post("/api/users") {
            contentType = MediaType.APPLICATION_JSON
            content = createRequest
        }.andExpect {
            status { isCreated() }
        }.andReturn()
        
        val userId = JsonPath.read<Int>(
            createResponse.response.contentAsString,
            "$.id"
        ).toLong()
        
        // 2. 조회
        mockMvc.get("/api/users/{id}", userId)
            .andExpect {
                status { isOk() }
                jsonPath("$.name") { value("규철") }
            }
        
        // 3. 수정
        val updateRequest = """
            {
                "name": "김규철"
            }
        """.trimIndent()
        
        mockMvc.put("/api/users/{id}", userId) {
            contentType = MediaType.APPLICATION_JSON
            content = updateRequest
        }.andExpect {
            status { isOk() }
            jsonPath("$.name") { value("김규철") }
        }
        
        // 4. 삭제
        mockMvc.delete("/api/users/{id}", userId)
            .andExpect {
                status { isNoContent() }
            }
        
        // 5. 삭제 확인
        mockMvc.get("/api/users/{id}", userId)
            .andExpect {
                status { isNotFound() }
            }
    }
}

TestContainers (실제 DB 사용)

dependencies {
    testImplementation("org.testcontainers:testcontainers:1.19.3")
    testImplementation("org.testcontainers:postgresql:1.19.3")
}
@SpringBootTest
@Testcontainers
class DatabaseIntegrationTest {
    
    companion object {
        @Container
        val postgres = PostgreSQLContainer<Nothing>("postgres:15").apply {
            withDatabaseName("testdb")
            withUsername("test")
            withPassword("test")
        }
        
        @JvmStatic
        @DynamicPropertySource
        fun properties(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", postgres::getJdbcUrl)
            registry.add("spring.datasource.username", postgres::getUsername)
            registry.add("spring.datasource.password", postgres::getPassword)
        }
    }
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @Test
    fun `실제 PostgreSQL로 테스트`() {
        // Given
        val user = User(name = "규철", email = "user@example.com", age = 30)
        
        // When
        val saved = userRepository.save(user)
        
        // Then
        saved.id shouldNotBe 0
    }
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • JUnit5 - 기본 테스트 작성법
  • MockK - Kotlin 친화적 Mock 라이브러리
  • Controller 테스트 - MockMvc로 API 테스트
  • Service 테스트 - 비즈니스 로직 검증
  • Repository 테스트 - 데이터 계층 테스트
  • 통합 테스트 - 전체 플로우 검증

다음 편에서 배울 것 📚

18편: Kotlin DSL 만들기 | 타입 안전한 빌더 패턴

  • DSL이란?
  • @DslMarker로 타입 안전성
  • 수신 객체 지정 람다 활용
  • HTML, SQL, 설정 DSL 만들기
  • 실전 DSL 디자인 패턴

핵심 정리

테스트 작성 원칙

1. AAA 패턴
   - Arrange (준비)
   - Act (실행)
   - Assert (검증)

2. 독립성
   - 각 테스트는 독립적으로 실행 가능
   - 순서에 의존하지 않음

3. 반복성
   - 몇 번을 실행해도 같은 결과
   - 외부 환경에 의존하지 않음

Mockito vs MockK

Mockito MockK

Kotlin 지원 불편함 완벽
final 클래스 불가능 가능
확장 함수 불가능 가능
DSL Java 스타일 Kotlin 스타일

💬 댓글로 알려주세요!

  • 테스트 코드 작성하고 계신가요?
  • MockK vs Mockito 중 뭘 선호하시나요?
  • 이 글이 도움이 되셨나요?

 

반응형
Comments