Notice
Recent Posts
Recent Comments
Link
반응형
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 자바
- 스프링핵심원리
- 스프링 핵심원리
- 김영한
- 이펙티브자바
- 티스토리챌린지
- Kotlin
- java
- kubernetes
- 이차전지관련주
- 엘라스틱서치
- 자바스크립트
- 스프링부트
- Effective Java
- 스프링
- k8s
- effectivejava
- springboot
- Sort
- 클린아키텍처
- Effective Java 3
- 오블완
- ElasticSearch
- 카카오
- 이펙티브 자바
- Spring
- 알고리즘
- 예제로 배우는 스프링 입문
- 알고리즘정렬
- JavaScript
Archives
- Today
- Total
Kim-Baek 개발자 이야기
Kotlin 테스트 코드 완벽 가이드 | JUnit5 + MockK로 안전한 코드 만들기 본문
반응형
이 글을 읽으면: Mockito 대신 Kotlin 친화적인 MockK로 테스트를 작성하는 방법을 배울 수 있습니다. Controller부터 Service, Repository까지 실전 테스트 패턴을 완벽하게 마스터하세요.
📌 목차
- 들어가며 - 왜 테스트 코드가 필요할까?
- 테스트 환경 설정 - JUnit5 + MockK
- JUnit5 기본 - 테스트 작성법
- MockK - Kotlin 친화적 목 프레임워크
- Controller 테스트 - MockMvc
- Service 테스트 - 비즈니스 로직
- Repository 테스트 - 데이터 계층
- 통합 테스트 - 전체 플로우
- 마무리 - 다음 편 예고
들어가며 - 왜 테스트 코드가 필요할까?

실제 프로젝트에서 겪은 사고
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 중 뭘 선호하시나요?
- 이 글이 도움이 되셨나요?
반응형
'개발 > java basic' 카테고리의 다른 글
| Kotlin DSL 만들기 | 타입 안전한 빌더 패턴 완벽 가이드 (0) | 2026.01.05 |
|---|---|
| Spring Boot와 Kotlin | 실전 백엔드 개발 완벽 가이드 (0) | 2026.01.02 |
| Kotlin 코루틴 심화 | Flow, Channel로 데이터 스트림 다루기 (0) | 2026.01.01 |
| Kotlin 코루틴 기초 | launch, async, suspend로 비동기 정복하기 (0) | 2025.12.29 |
| Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기 (0) | 2025.12.28 |
Comments
