Kotlin 테스팅 | JUnit, MockK, 테스트 작성법
이 글의 핵심
JUnit 5로 기본 테스트와 예외 테스트를 쓰고, MockK로 의존성을 가짜로 바꿔 호출을 검증하는 흐름을 사용자 서비스 예제로 따라갑니다. useJUnitPlatform()을 빠뜨려 테스트가 하나도 실행되지 않는 설정 실수, 목이 돌려준 값을 그대로 확인해 아무것도 검증하지 못하는 테스트, 시간·ID처럼 테스트하기 어려운 의존성을 다루는 방법도 함께 짚습니다.
들어가며
JUnit·Kotest 등과 함께 쓰면 given/when/then 스타일을 짧게 쓰기 좋습니다. 테스트는 프로덕션 코드와 같은 멀티플랫폼 모듈에 둘 수도 있습니다.
이 글은 JVM에서 가장 흔한 조합인 JUnit 5 + MockK를 다룹니다. 테스트 코드도 결국 유지보수해야 하는 코드이므로, 문법과 함께 무엇을 검증해야 의미 있는 테스트인지, 테스트가 통과했는데도 버그를 놓치는 흔한 패턴이 무엇인지를 예제마다 짚습니다.
JUnit 5 설정과 assert, 예외 테스트
프로젝트 설정
// build.gradle.kts
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.9.0")
testImplementation("org.jetbrains.kotlin:kotlin-test")
}
의존성만 추가하고 끝내면 테스트가 하나도 실행되지 않을 수 있습니다. Gradle의 test 태스크는 기본적으로 JUnit 4 방식으로 테스트를 찾기 때문에, JUnit 5(Jupiter) 테스트를 돌리려면 tasks.test { useJUnitPlatform() }을 함께 적어야 합니다. 이 설정이 빠지면 Gradle 버전에 따라 ./gradlew test가 오류 없이 “BUILD SUCCESSFUL”로 끝나는데 실제로는 0개의 테스트가 실행된 상태가 되어, 실패해야 할 테스트가 있는데도 CI가 초록불을 띄웁니다. 새 프로젝트에서 테스트를 일부러 한 번 실패시켜 보고 정말 실행되는지 확인하는 습관이 이런 문제를 잡아 줍니다.
kotlin-test는 JUnit, TestNG 등 여러 프레임워크 위에서 같은 assertEquals API를 쓰게 해 주는 얇은 계층입니다. Kotlin Gradle 플러그인을 쓴다면 testImplementation(kotlin("test"))로 추가하면 JUnit 5용 구현이 자동으로 선택됩니다. 버전은 최신 JUnit 5 BOM(platform("org.junit:junit-bom:..."))으로 관리하면 Jupiter 모듈 사이 버전이 어긋나는 문제를 피할 수 있습니다.
기본 테스트
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class CalculatorTest {
@Test
fun `덧셈 테스트`() {
val result = add(2, 3)
assertEquals(5, result)
}
@Test
fun `뺄셈 테스트`() {
val result = subtract(10, 3)
assertEquals(7, result)
}
}
fun add(a: Int, b: Int) = a + b
fun subtract(a: Int, b: Int) = a - b
백틱으로 감싼 함수 이름은 Kotlin 문법상 공백이나 한글을 포함할 수 있어서, 테스트 보고서에 “덧셈 테스트”처럼 읽기 좋은 이름이 그대로 나옵니다. 이름에는 “무엇을 테스트하는가”보다 “어떤 상황에서 어떤 결과가 나와야 하는가”를 쓰는 것이 좋습니다. `음수를 더하면 절댓값이 작아진다`처럼 쓰면 테스트가 실패했을 때 이름만으로 무엇이 깨졌는지 알 수 있습니다. 참고로 Android의 계측 테스트(androidTest)에서는 런타임 제약으로 공백이 든 함수 이름을 쓸 수 없는 경우가 있어, 로컬 단위 테스트에서만 쓰는 팀이 많습니다.
assertEquals(expected, actual)는 기댓값이 먼저입니다. 순서를 바꿔도 통과 여부는 같지만, 실패 메시지가 “expected: <3> but was: <5>“처럼 거꾸로 나와 디버깅할 때 혼란을 줍니다. JUnit 5에서 테스트 클래스는 기본적으로 테스트 메서드마다 새 인스턴스가 만들어지므로, 한 테스트에서 바꾼 프로퍼티가 다음 테스트에 남지 않습니다.
Assert 메서드
import kotlin.test.*
class AssertTest {
@Test
fun `다양한 Assert`() {
assertEquals(5, 2 + 3)
assertNotEquals(4, 2 + 3)
assertTrue(5 > 3)
assertFalse(5 < 3)
assertNull(null)
assertNotNull("text")
}
}
assertTrue(5 > 3)처럼 조건 전체를 불리언으로 검사하면 실패했을 때 “expected true”라는 정보밖에 남지 않습니다. 값을 비교하는 테스트라면 assertEquals나 전용 단언을 써야 실패 메시지에 실제 값이 나옵니다. kotlin.test의 assertNotNull(x)는 null이 아닌 값을 반환하므로 val user = assertNotNull(result) 뒤에 스마트 캐스트 없이 user.name을 쓸 수 있다는 것도 편리한 점입니다. 단언이 많아지면 AssertJ나 Kotest assertions의 result shouldBe 5 같은 표현력 높은 라이브러리를 쓰기도 합니다.
한 테스트에 단언이 여러 개 있으면 첫 실패에서 멈춰 나머지 결과를 알 수 없습니다. 서로 독립적인 속성을 한꺼번에 확인하고 싶다면 JUnit 5의 assertAll { ... }로 묶어 모든 실패를 한 번에 보고받을 수 있습니다.
예외 테스트
import org.junit.jupiter.api.assertThrows
class ExceptionTest {
@Test
fun `0으로 나누기 예외`() {
assertThrows<ArithmeticException> {
divide(10, 0)
}
}
}
fun divide(a: Int, b: Int): Int {
if (b == 0) throw ArithmeticException("0으로 나눌 수 없음")
return a / b
}
assertThrows<ArithmeticException> { ... }는 블록 안에서 해당 타입(또는 하위 타입)의 예외가 나야 통과하고, 예외가 안 나거나 다른 예외가 나면 실패합니다. 반환값이 잡힌 예외 객체라서 val e = assertThrows<...> { ... } 후 assertEquals("0으로 나눌 수 없음", e.message)처럼 메시지까지 검증할 수 있습니다. try { divide(10, 0); fail() } catch (e: ArithmeticException) {} 같은 수동 패턴보다 의도가 분명하고, fail()을 빠뜨려 예외가 안 나도 통과하는 실수도 없습니다.
주의할 점은 예외 타입이 너무 넓으면 테스트가 의미를 잃는다는 것입니다. assertThrows<Exception>으로 두면 코드의 다른 곳에서 난 NullPointerException도 통과시킵니다. 예외 테스트는 가능한 한 구체적인 타입으로 쓰세요. kotlin.test에도 같은 이름의 assertFailsWith<ArithmeticException> { }가 있어서, JUnit에 묶이지 않는 코드를 원하면 그쪽을 씁니다.
MockK로 의존성 흉내 내기
프로젝트 설정
// build.gradle.kts
dependencies {
testImplementation("io.mockk:mockk:1.13.5")
}
기본 Mock
import io.mockk.*
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
interface UserRepository {
fun findById(id: String): User?
}
data class User(val id: String, val name: String)
class UserServiceTest {
@Test
fun `사용자 조회 테스트`() {
val repository = mockk<UserRepository>()
every { repository.findById("1") } returns User("1", "홍길동")
val service = UserService(repository)
val user = service.getUser("1")
assertEquals("홍길동", user.name)
verify { repository.findById("1") }
}
}
class UserService(private val repository: UserRepository) {
fun getUser(id: String): User {
return repository.findById(id)
?: throw NoSuchElementException("User not found")
}
}
목(mock)을 쓰는 이유는 UserService만 따로 떼어 테스트하기 위해서입니다. 실제 UserRepository가 DB에 접속한다면 테스트마다 DB를 준비해야 하고 느리며, 실패했을 때 서비스 로직 문제인지 DB 문제인지 구분하기 어렵습니다. mockk<UserRepository>()는 인터페이스를 흉내 내는 가짜 객체를 만들고, every { ... } returns ...는 “이 호출이 오면 이 값을 돌려줘라”는 규칙(stub)을 정합니다. UserService가 생성자로 저장소를 받도록 설계되어 있어서 가짜를 끼워 넣을 수 있다는 점이 중요합니다. 서비스 안에서 UserRepositoryImpl()을 직접 만들었다면 이 테스트는 불가능합니다.
기본 mock은 엄격(strict)합니다. every로 정하지 않은 호출이 오면 MockKException: no answer found for UserRepository(#1).findById(2)처럼 실패하므로, 예상하지 못한 호출이 있다는 것을 바로 알 수 있습니다. 인자도 정확히 일치해야 해서 findById("1")로 stub했는데 서비스가 findById(" 1")을 호출하면 같은 오류가 납니다. 인자와 무관하게 응답하려면 findById(any())를 쓰고, 조건을 걸려면 match { it.startsWith("1") }을 씁니다.
@Test
fun `호출 검증`() {
val repository = mockk<UserRepository>(relaxed = true)
val service = UserService(repository)
service.getUser("1")
verify(exactly = 1) { repository.findById("1") }
verify(atLeast = 1) { repository.findById(any()) }
}
relaxed = true는 stub하지 않은 호출에도 예외 대신 기본값을 돌려주는 느슨한 mock입니다. 다만 이 예제처럼 반환 타입이 User?인 함수는 relaxed mock이 무엇을 돌려주느냐에 따라 getUser가 NoSuchElementException을 던질 수 있어서, 결과가 로직에 영향을 주는 호출은 relaxed에 기대지 말고 every로 명시하는 편이 안전합니다. 반환값이 없는(Unit) 함수만 느슨하게 하고 싶다면 mockk(relaxUnitFun = true)가 범위를 좁힌 대안입니다.
verify는 “이 호출이 실제로 일어났는가”를 검사합니다. exactly = 1은 정확히 한 번, atLeast = 1은 한 번 이상이고, 호출되지 않았어야 한다면 verify(exactly = 0)나 confirmVerified(repository)로 다른 호출이 없었음을 확인합니다. 하지만 verify를 과하게 쓰면 구현 방식에 묶인 테스트가 됩니다. 서비스가 캐시를 도입해 findById 호출 횟수가 바뀌면, 결과는 똑같이 맞는데도 테스트가 깨집니다. 반환값으로 확인할 수 있는 것은 결과를 검증하고, verify는 이메일 발송이나 저장처럼 호출 자체가 요구사항인 부수 효과에 쓰는 것이 좋은 기준입니다.
@BeforeEach·@AfterEach 테스트 라이프사이클
Before/After
import org.junit.jupiter.api.*
class LifecycleTest {
@BeforeEach
fun setup() {
println("테스트 시작 전")
}
@AfterEach
fun teardown() {
println("테스트 종료 후")
}
@Test
fun `테스트 1`() {
println("테스트 1 실행")
}
@Test
fun `테스트 2`() {
println("테스트 2 실행")
}
}
@BeforeEach는 테스트 메서드마다 직전에, @AfterEach는 직후에 실행됩니다. 출력은 “테스트 시작 전 → 테스트 1 실행 → 테스트 종료 후 → 테스트 시작 전 → 테스트 2 실행 → …” 순서가 되지만, 테스트 메서드 사이의 실행 순서는 보장되지 않습니다. 테스트 1이 만든 데이터를 테스트 2가 쓰는 식으로 순서에 의존하면, 단독 실행이나 병렬 실행에서 깨지는 불안정한 테스트가 됩니다. 각 테스트가 필요한 상태를 @BeforeEach에서 새로 준비하는 것이 원칙입니다.
클래스 전체에서 한 번만 실행할 @BeforeAll은 JUnit이 정적 메서드를 요구하므로, Kotlin에서는 companion object 안에 두고 @JvmStatic을 붙여야 합니다. 이를 빠뜨리면 “@BeforeAll method … must be static” 오류가 납니다. 또는 클래스에 @TestInstance(TestInstance.Lifecycle.PER_CLASS)를 붙이면 인스턴스를 하나만 만들어 일반 메서드에도 @BeforeAll을 쓸 수 있는데, 대신 테스트 사이에 인스턴스 상태가 공유된다는 점을 감수해야 합니다.
UserService를 MockK로 테스트하기
data class User(val id: String, val name: String, val email: String)
interface UserRepository {
fun findById(id: String): User?
fun save(user: User): User
fun findAll(): List<User>
}
class UserService(private val repository: UserRepository) {
fun getUser(id: String): User {
return repository.findById(id)
?: throw NoSuchElementException("User not found")
}
fun createUser(name: String, email: String): User {
if (name.isBlank()) {
throw IllegalArgumentException("이름은 필수입니다")
}
if (!email.contains("@")) {
throw IllegalArgumentException("이메일 형식이 잘못되었습니다")
}
val user = User(
id = generateId(),
name = name,
email = email
)
return repository.save(user)
}
fun getAllUsers(): List<User> {
return repository.findAll()
}
private fun generateId() = System.currentTimeMillis().toString()
}
class UserServiceTest {
private lateinit var repository: UserRepository
private lateinit var service: UserService
@BeforeEach
fun setup() {
repository = mockk()
service = UserService(repository)
}
@Test
fun `사용자 조회 성공`() {
val expected = User("1", "홍길동", "[email protected]")
every { repository.findById("1") } returns expected
val result = service.getUser("1")
assertEquals(expected, result)
verify { repository.findById("1") }
}
@Test
fun `사용자 조회 실패`() {
every { repository.findById("999") } returns null
assertThrows<NoSuchElementException> {
service.getUser("999")
}
}
@Test
fun `사용자 생성 성공`() {
val user = User("1", "홍길동", "[email protected]")
every { repository.save(any()) } returns user
val result = service.createUser("홍길동", "[email protected]")
assertEquals("홍길동", result.name)
verify { repository.save(any()) }
}
@Test
fun `이름 없이 생성 실패`() {
assertThrows<IllegalArgumentException> {
service.createUser("", "[email protected]")
}
}
@Test
fun `잘못된 이메일로 생성 실패`() {
assertThrows<IllegalArgumentException> {
service.createUser("홍길동", "invalid-email")
}
}
@Test
fun `모든 사용자 조회`() {
val users = listOf(
User("1", "홍길동", "[email protected]"),
User("2", "김철수", "[email protected]")
)
every { repository.findAll() } returns users
val result = service.getAllUsers()
assertEquals(2, result.size)
verify { repository.findAll() }
}
}
이 예제는 앞 절의 User, UserRepository, UserService를 확장한 버전이라 같은 패키지에 두면 “Redeclaration” 오류가 납니다. 파일로 실행하려면 앞 절 코드와 분리하고, 테스트 파일 상단에 import io.mockk.*, import org.junit.jupiter.api.*, import kotlin.test.assertEquals를 추가해야 합니다.
@BeforeEach에서 mock과 서비스를 매번 새로 만드는 이유는 앞 절에서 말한 테스트 격리 때문입니다. MockK는 stub과 호출 기록을 mock 객체에 저장하므로, 여러 테스트가 같은 mock을 공유하면 앞 테스트의 every와 호출 기록이 뒤 테스트의 verify에 섞입니다. 예외 테스트 두 개(이름 없이 생성 실패, 잘못된 이메일로 생성 실패)는 every 없이도 통과하는데, 검증이 저장소 호출보다 먼저 일어나 strict mock이 호출되지 않기 때문입니다. 반대로 검증 순서가 바뀌어 save가 먼저 호출되면 이 테스트들은 MockKException으로 실패해 버그를 알려 줍니다.
사용자 생성 성공 테스트에는 흔한 약점이 있습니다. repository.save(any())가 미리 만든 user를 그대로 돌려주도록 stub했기 때문에, result.name이 “홍길동”인 것은 서비스가 아니라 mock이 정한 값입니다. 서비스가 이름을 빈 문자열로 저장하는 버그가 있어도 이 테스트는 통과합니다. 처음 mock을 쓸 때 가장 많이 만드는 “아무것도 검증하지 않는 테스트”입니다. 서비스가 저장소에 무엇을 넘겼는지를 확인해야 하며, MockK의 slot으로 인자를 잡을 수 있습니다. val saved = slot<User>(), every { repository.save(capture(saved)) } answers { saved.captured }로 stub한 뒤 assertEquals("홍길동", saved.captured.name), assertEquals("[email protected]", saved.captured.email)을 검사하면 서비스가 만든 객체 자체를 검증하게 됩니다.
generateId()가 System.currentTimeMillis()를 쓰는 것도 테스트를 어렵게 합니다. 실행할 때마다 id가 달라서 id 값을 검증할 수 없고, 같은 밀리초에 두 사용자를 만들면 id가 겹칩니다. 시간이나 무작위 값처럼 통제할 수 없는 의존성은 IdGenerator 인터페이스나 java.time.Clock을 생성자로 주입받게 바꾸면, 테스트에서 고정된 값을 넣어 결과를 정확히 검증할 수 있습니다. 테스트 작성이 어렵게 느껴지는 지점이 곧 설계를 개선할 지점인 경우가 많습니다.
Kotlin 테스트 요약
- JUnit: 테스트 프레임워크
- MockK: Kotlin 전용 Mock 라이브러리
- every: Mock 동작 정의
- verify: 호출 검증
- assertThrows: 예외 테스트
테스트를 나누는 기준
- 단위 테스트: 함수/클래스 단위로 작성
- Mock 사용: 외부 의존성 제거
- 명확한 이름: 백틱으로 한글 테스트명 사용
- Given-When-Then: 테스트 구조화
다음 단계
같이 보면 좋은 글
- C++ Google Test | gtest 설치부터 TEST·EXPECT_EQ
- C++ Google Mock
- C++ 테스트 전략 세우기
- C++ 크로스 플랫폼 테스트
- Kotlin 시작하기: Android 공식 언어의 특징과 개발 환경 설정
- Swift 함수와 클로저 | 함수 정의, 클로저, 고차 함수
- Kotlin Spring Boot | REST API 서버 만들기
- 투 포인터: O(n²) 탐색을 O(n)으로 줄이는 조건과 대표 문제
자주 묻는 질문 (FAQ)
Q. MockK에서 relaxed = true는 언제 쓰나요?
A. 기본 mock은 every { }로 동작을 정하지 않은 함수가 호출되면 MockKException을 던지지만, relaxed mock은 기본값(0, 빈 문자열, Unit 등)을 반환합니다. 저장소의 save처럼 반환값이 중요하지 않고 호출 여부만 verify하고 싶은 의존성에 쓰면 테스트 준비 코드가 줄어듭니다. 반환값이 결과에 영향을 주는 함수까지 relaxed로 두면 잘못된 기본값 때문에 테스트가 우연히 통과할 수 있으니, 그런 함수는 명시적으로 stub하는 편이 안전합니다.