1. 단위 테스트
1.1. 단위 테스트란?
단위 테스트는 소프트웨어 개발에서 가장 작은 단위인 모듈을 테스트하는 것을 말합니다.
1.2. 테스트 환경 설정
1.2.1. 의존성 추가하기
코틀린으로 작성한 코루틴을 테스트 하기 위해서 아래 의존성을 build.gradle.kts
파일에 추가해야합니다.
dependencies {
...
// JUnit5 테스트 프레임워크
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
// 코루틴 테스트 라이브러리
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2")
}
// JUnit5을 사용하기 위한 옵션 추가
tasks.test {
useJUnitPlatform()
}
...
테스트 코드 작성을 위해 임시로 인자로 받은 수를 전부 곱하는 연산을 하는 유스케이스를 만들어 보겠습니다.
class MultiplyUseCase {
fun multiply(vararg args: Int): Int = args.reduce { acc, i -> acc * i }
}
그리고, 위 코드를 테스트하는 코드를 test 코드를 작성하여 구현 내용이 의도한 바와 일치하는지 확인합니다.
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class MultiplyUseCaseTest {
@Test
fun `2 곱하기 2는 4이다`() {
val multiplyUseCase: MultiplyUseCase = MultiplyUseCase()
val result = multiplyUseCase.multiply(2, 2)
assertEquals(4, result)
}
}
실행 결과, 2 곱하기 2는 4이다
항목이 통과되는 것을 확인 할 수 있습니다.
하나의 유스케이스를 여러가지 인자로 테스트 하려면 어떻게 해야할까요? 매 테스트마다 유스케이스 객체를 생성하는 로직을 반복적으로
넣어야하는 수고를 @BeforeEach
로 단일화해봅시다.
1.2.2. @BeforeEach
어노테이션을 사용한 테스트 환경 설정
class MultiplyUseCaseTestBeforeEach {
lateinit var multiplyUseCase: MultiplyUseCase
@BeforeEach
fun setUp() {
multiplyUseCase = MultiplyUseCase()
}
@Test
fun `3 곱하기 2는 6이다`() {
val result = multiplyUseCase.multiply(3, 2)
println(result)
assertEquals(6, result)
}
@Test
fun `-3 곱하기 2는 -6이다`() {
val result = multiplyUseCase.multiply(-3, 2)
println(result)
assertEquals(-6, result)
}
}
위 테스트 코드에서 MultiplyUseCase 를 생성하는 부분을 @BeforeEach
로 설정하여 테스트 코드를 작성하였습니다.
보일러 플레이트 코드를 줄이고, 테스트 코드를 더 깔끔하게 작성하는데 도움이 되네요.
이와 반대되는 어노테이션인 @AfterEach
는 테스트가 실행된 후에 실행되는 어노테이션입니다.
주로 테스트가 실행된 후에 리소스를 해제하는 용도로 사용됩니다.
1.3. 테스트 더블(Test Double) 사용하기
테스트 더블이란?
flowchart LR C[테스트] -->|함수 호출| A[객체] A[객체] -->|요청| B[테스트 더블] B -->|응답| A A -->|예상되는 결과 확인\nAssert| C style B fill:#f96,stroke-width:4px
특정 객체의 행동을 모방하는 객체. 다른 객체와의 의존성을 가진 객체를 테스트하기 위해 사용합니다.
테스트 더블의 종류
테스트 더블 중 널리 쓰이는 다섯가지에 대해 간략하게 알아보도록 하겠습니다.
용어 | 설명 | 사용 목적 | 특징 |
---|---|---|---|
Stub | 실제 동작의 대체물로, 미리 정의된 간단한 응답을 반환 | 특정 함수나 메서드의 일부분만 테스트할 때 사용 | 복잡한 로직을 포함하지 않고 고정된 값을 반환 |
Fake | 실제와 유사하게 동작하지만, 실제 구현보다 단순한 방식으로 처리 | 간단한 방식으로 데이터베이스나 네트워크 호출 등의 대체물로 사용 | 더 복잡한 동작을 포함하며, 가짜로 동작하는 실제 구현체 |
Mock | 동작을 기록하고, 호출 여부 및 횟수 등을 검증할 수 있는 객체 | 메서드 호출 여부, 파라미터 등을 확인하는 행위 검증에 사용 | 호출 횟수, 입력 값 검증에 집중 |
Dummy | 단순히 자리를 채우기 위한 객체로, 호출되거나 사용되지 않음 | 메서드 호출 시 필요한 인자를 채우기 위해 사용 | 동작하지 않으며, 단순히 인자 자리를 채우는 용도 |
Spy | 실제 객체처럼 동작하면서, 일부 메서드를 감시하거나 수정 가능 | 실제 객체의 일부 동작을 검증하고 추적하는 데 사용 | 실제 객체를 감시하면서 호출 횟수나 결과를 검증 |
이 중에서 Stub과 Fake를 만들어 실제로 테스트 더블이 어떻게 사용되는지 알아보도록 하겠습니다.
1.3.1. Stub 객체 사용하기
class StubUserNameRepository(
private val userNameMap: Map<String, String> // 데이터 주입
) : UserNameRepository {
override fun saveUserName(id: String, name: String) {
// 구현하지 않는다.
}
override fun getNameByUserId(id: String): String {
return userNameMap[id] ?: ""
}
}
Stub는 특정 동작을 구현하지 않고, 미리 정의된 값을 반환하는 객체입니다.
위 코드는 UserNameRepository
인터페이스를 구현한 것으로, getNameByUserId
함수를 호출하면 userNameMap
에 저장된 값을 반환합니다.
saveUserName
함수는 구현하지 않습니다.
1.3.2. Fake 객체 사용하기
class FakeUserPhoneNumberRepository : UserPhoneNumberRepository {
private val userPhoneNumberMap = mutableMapOf<String, String>()
override fun saveUserPhoneNumber(id: String, phoneNumber: String) {
userPhoneNumberMap[id] = phoneNumber
}
override fun getPhoneNumberByUserId(id: String): String {
return userPhoneNumberMap[id] ?: ""
}
}
Fake는 실제와 유사하게 동작하지만, 실제 구현보다 단순한 방식으로 처리하는 객체입니다.
2. 코루틴 단위 테스트 시작하기
코루틴을 테스트 하기 위해서는 runBlocking
함수를 사용하여 테스트를 진행할 수 있습니다.
2.1. 코루틴 테스트 작성하기
코루틴을 사용한 코드를 테스트하기 위해 우선, 코루틴을 사용한 유스케이스를 작성해보겠습니다.
class RepeatAddUseCase {
suspend fun add(repeatTime: Int): Int = withContext(Dispatchers.Default) {
var result = 0
repeat(repeatTime) {
result += 1
}
return@withContext result
}
}
위 코드는 repeat
함수를 사용하여 repeatTime
만큼 result
변수에 1을 더하는 코드입니다.
이제, 위 코드를 테스트하는 코드를 작성해보겠습니다.
class RepeatAddUseCaseTest {
@Test
fun `100번 더하면 100이 반환된다`() = runBlocking {
// Given
val repeatAddUseCase = RepeatAddUseCase()
// When
val result = repeatAddUseCase.add(100)
// Then
assertEquals(100, result)
}
}
runBlocking
함수를 사용하여 코루틴을 테스트할 수 있습니다.
그런데 만약 코루틴이 일시중단되는 시간이 있다면, runBlocking
함수를 사용하면 테스트가 끝날 때까지 기다려야 합니다.
2.2. runBlocking
을 사용한 테스트의 한계
runBlocking
함수를 사용하면 테스트가 끝날 때까지 기다려야 한다는 단점이 있습니다.
class RepeatAddUseCase {
suspend fun add(repeatTime: Int): Int = withContext(Dispatchers.Default) {
var result = 0
repeat(repeatTime) {
delay(1_000)
result += 1
}
return@withContext result
}
}
위 코드는 repeat
함수를 사용하여 repeatTime
만큼 result
변수에 1을 더하는 코드에 delay
함수를 추가한 코드입니다.
만약 repeatTime이 100이라면, 100초가 걸리는 코드입니다. repeatTime이 커질수록 테스트 수행시간은 길어 질 것입니다.
이를 해결하기 위해 TestCoroutineScheduler
를 사용하여 가상 시간을 사용하여 테스트를 진행할 수 있습니다.
3. 코루틴 테스트 라이브러리
3.1. TestCoroutineScheduler
사용해 가상 시간에서 테스트 진행하기
TestCoroutineScheduler
의 사용하면 가상 시간을 사용하여 테스트를 진행할 수 있습니다.
TestCoroutineScheduler
는 코루틴을 테스트하기 위한 가상 시간을 제공하는 클래스입니다.
TestCoroutineScheduler.advanceTimeBy
를 사용하면, delay
함수를 사용하여 코루틴을 일시 중단할 때 실제 시간이 흐르지 않고 가상 시간만 흐르게 할 수 있습니다.
@Test
fun `가상 시간 조절 테스트`() {
// 테스트 환경 설정
val testCoroutineScheduler = TestCoroutineScheduler()
testCoroutineScheduler.advanceTimeBy(5000L) // 가상 시간에서 5초를 흐르게 만듦 : 현재 시간 5초
assertEquals(5000L, testCoroutineScheduler.currentTime) // 현재 시간이 5초임을 단언
testCoroutineScheduler.advanceTimeBy(6000L) // 가상 시간에서 5초를 흐르게 만듦 : 현재 시간 11초
assertEquals(11000L, testCoroutineScheduler.currentTime) // 현재 시간이 11초임을 단언
testCoroutineScheduler.advanceTimeBy(10000L) // 가상 시간에서 10초를 강제로 흐르게 만듦 : 현재 시간 21초
assertEquals(21000L, testCoroutineScheduler.currentTime) // 현재 시간이 21초임을 단언
}
TestCoroutineScheduler
는 StandardTestDispatcher
에 포함시켜 사용할 수 있습니다.
@Test
fun `가상 시간 위에서 테스트 진행`() {
// 테스트 환경 설정
val testCoroutineScheduler: TestCoroutineScheduler = TestCoroutineScheduler()
val testDispatcher: TestDispatcher = StandardTestDispatcher(scheduler = testCoroutineScheduler)
val testCoroutineScope = CoroutineScope(context = testDispatcher)
// Given
var result = 0
// When
testCoroutineScope.launch {
delay(10000L) // 10초간 대기
result = 1
delay(10000L) // 10초간 대기
result = 2
println(Thread.currentThread().name)
}
// Then
assertEquals(0, result)
testCoroutineScheduler.advanceTimeBy(5000L) // 가상 시간에서 5초를 흐르게 만듦 : 현재 시간 5초
assertEquals(0, result)
testCoroutineScheduler.advanceTimeBy(6000L) // 가상 시간에서 5초를 흐르게 만듦 : 현재 시간 11초
assertEquals(1, result)
testCoroutineScheduler.advanceTimeBy(10000L) // 가상 시간에서 10초를 흐르게 만듦 : 현재 시간 21초
assertEquals(2, result)
}
testCoroutineScheduler.advanceTimeBy
를 통해 가상 시간을 인자에 따라 흐르게 만들 수 있습니다.
즉, 시간이 전혀 흐르지 않은 상태에서 result
값은 0, 5초가 흐른 상태에서 result
값은 0, 11초가 흐른 상태에서 result
값은 1, 21초가 흐른 상태에서 result
값은 2가 됩니다.
advanceUntilIdle
함수를 사용하면, 테스트 코루틴이 모두 실행될 때까지 가상 시간을 흐르게 만들 수 있습니다.
@Test
fun `advanceUntilIdle의 동작 살펴보기`() {
// 테스트 환경 설정
val testCoroutineScheduler: TestCoroutineScheduler = TestCoroutineScheduler()
val testDispatcher: TestDispatcher = StandardTestDispatcher(scheduler = testCoroutineScheduler)
val testCoroutineScope = CoroutineScope(context = testDispatcher)
// Given
var result = 0
// When
testCoroutineScope.launch {
delay(10_000L) // 10초간 대기
result = 1
delay(10_000L) // 10초간 대기
result = 2
}
testCoroutineScheduler.advanceUntilIdle() // testCoroutineScope 내부의 코루틴이 모두 실행되게 만듦
// Then
assertEquals(2, result)
}
3.2. TestCoroutineScheduler를
포함하는 StandardTestDispatcher
StandardTestDispatcher
는 TestCoroutineScheduler
가 인자로 주어지지 않는 경우, TestCoroutineScheduler
를 생성하여 사용합니다.
따라서 위 코드를 아래와 같이 변경하여 사용할 수 있습니다.
@Test
fun `StandardTestDispatcher 사용하기`() {
// 테스트 환경 설정
val testDispatcher: TestDispatcher = StandardTestDispatcher()
val testCoroutineScope = CoroutineScope(context = testDispatcher)
// Given
var result = 0
// When
testCoroutineScope.launch {
delay(10_000L) // 10초간 대기
result = 1
delay(10_000L) // 10초간 대기
result = 2
}
testDispatcher.scheduler.advanceUntilIdle() // testCoroutineScope 내부의 코루틴이 모두 실행되게 만듦
assertEquals(2, result)
}
testDispatcher.scheduler
를 사용하면, TestCoroutineScheduler
를 참조할 수 있어, 기존 코드와 동일하게 사용할 수 있습니다.
3.3. TestScope
사용해 가상 시간에서 테스트 진행하기
매번 TestDispatcher 객체를 CoroutineScope 함수로 감싸서 사용하는 것은 불편합니다.
TestScope
를 사용하면, TestDispatcher
객체를 생성하고 CoroutineScope 함수로 감싸는 작업을 줄일 수 있습니다.
@Test
fun `TestScope 사용하기`() {
// 테스트 환경 설정
val testCoroutineScope: TestScope = TestScope()
// Given
var result = 0
// When
testCoroutineScope.launch {
delay(10000L) // 10초간 대기
result = 1
delay(10000L) // 10초간 대기
result = 2
}
testCoroutineScope.advanceUntilIdle() // testCoroutineScope 내부의 코루틴이 모두 실행되게 만듦
assertEquals(2, result)
}
위 코드가 이전 코드와 동일하게 동작할 수 있는 이유는 TestScope
가 StandardTestDispatcher
를 사용하기 때문입니다.
TestScope
생성 코드 내부를 살펴보면, 인자로 context 가 주어지지 않았을 때, StandardTestDispatcher
를 사용하도록 설정되어 있습니다.
3.4. runTest
사용해 테스트 만들기
runTest
함수는 TestScope
객체를 사용해 코루틴을 실행시키고,
그 코루틴 내부에서 일시 중단 함수가 실행되더라도 작업이 곧바로 실행 완료 될수 있도록 가상시간을 흐르게 만드는 기능을 가진 코루틴 빌더입니다.
@Test
fun `runTest 사용하기`() {
// Given
var result = 0
// When
runTest { // this: TestScope
delay(10000L) // 10초간 대기
result = 1
delay(10000L) // 10초간 대기
result = 2
}
// Then
assertEquals(2, result)
}
따라서 이전 코드를 위처럼 변경하여 사용할 수 있습니다.
5. 코루틴 테스트 심화
앞서처럼 일시 중단 함수 내부에서 새로운 코루틴을 생성하는 경우에는 쉽게 테스트 할 수 있습니다.
하지만, 일시 중단 함수가 아닌 함수 내부에서 새로운 코루틴을 실행하는 경우가 있습니다.
5.1. 함수 내부에서 새로운 코루틴을 실행하는 객체에 대한 테스트
아래 코드는 객체 안에서 코루틴을 새로 생성하여 연산에 사용하고 있습니다.
class StringStateHolder {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
var stringState = ""
private set
fun updateStringWithDelay(string: String) {
coroutineScope.launch {
delay(1000L)
stringState = string
}
}
}
아래 테스트 코드는 언뜻 보기에 성공할 것으로 보이지만, 실패합니다.
@Test
fun `updateStringWithDelay("ABC")가 호출되면 문자열이 ABC로 변경된다`() = runTest {
// Given
val stringStateHolder = StringStateHolder()
// When
stringStateHolder.updateStringWithDelay("ABC")
// Then
advanceUntilIdle()
Assertions.assertEquals("ABC", stringStateHolder.stringState)
}
StringStateHolder
객체가 생성될 때, 새로운 Job 객체가 생성되기 때문에 테스트 코드가 실행되는 코루틴과는
별개의 작업 트리를 갖게 되기 때문입니다. 즉, runTest
함수가 생성한 코루틴의 구조가 StringStateHolder
내에서
생성된 코루틴에 의해 깨지므로 테스트가 실패합니다.
이를 해결하기 위해서는 StringStateHolder
객체가 생성될 때,
TestCoroutineScheduler
객체를 사용할 수 있게 해야 합니다.
class StringStateHolder(
dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val coroutineScope = CoroutineScope(dispatcher)
// ...
StringStateHolder
객체의 생성자에 CoroutineDispatcher
를 인자를 추가하여,
기본적으로는 Dispatchers.IO
가 사용되지만, 테스트에는 별도의 TestCoroutineScheduler
를 사용할 수 있도록 변경합니다.
@Test
fun `updateStringWithDelay("ABC")가 호출되면 문자열이 ABC로 변경된다`() {
// Given
val testDispatcher = StandardTestDispatcher()
val stringStateHolder = StringStateHolder(
dispatcher = testDispatcher
)
// When
stringStateHolder.updateStringWithDelay("ABC")
// Then
testDispatcher.scheduler.advanceUntilIdle()
Assertions.assertEquals("ABC", stringStateHolder.stringState)
}
StringStateHolder
의 생성자 형태를 변경하고, 테스트 코드에서 StandardTestDispatcher
를 사용하여 StringStateHolder
객체를 생성하면 테스트가 성공합니다.
5.2. backgroundScope
를 사용해 테스트 만들기
runTest
함수를 사용해 테스트를 진행할 경우, runTest
함수를 호출해 생성되는 코루틴은 메인스레드를 사용하는데
내부의 모든 코루틴이 실행될 때까지 종료되지 않습니다.
따라서 runTest
코루틴 내부에서 launch
함수가 호출돼 코루틴이 생성되고, 이 코루틴 내부에서 while 문같은 무한히 실행되는 작업이 실행된다면
테스트는 끝나지 않을 것입니다.
@Test
fun `끝나지 않아 실패하는 테스트`() = runTest {
var result = 0
launch {
while (true) {
delay(1000L)
result += 1
}
}
advanceTimeBy(1500L)
Assertions.assertEquals(1, result)
advanceTimeBy(1000L)
Assertions.assertEquals(2, result)
}
이렇게 무한히 실행되는 작업을 테스트하기 위해서는 runTest
람다식의 수신 객체인 TestScope
가 제공하는
backgroundScope
를 사용해야 합니다.backgroundScope
는 runTest
코루틴의 모든 코드가 실행되면 자동으로 취소되며,
이를 통해 테스트가 무한히 실행되는 것을 방지할 수 있습니다.
@Test
fun `backgroundScope를 사용하는 테스트`() = runTest {
var result = 0
backgroundScope.launch { // backgroundScope를 사용해 코루틴을 실행
while (true) {
delay(1000L)
result += 1
}
}
advanceTimeBy(1500L)
Assertions.assertEquals(1, result)
advanceTimeBy(1000L)
Assertions.assertEquals(2, result)
}