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초임을 단언
  }

TestCoroutineSchedulerStandardTestDispatcher 에 포함시켜 사용할 수 있습니다.

 
@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

StandardTestDispatcherTestCoroutineScheduler가 인자로 주어지지 않는 경우, 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)
  }

위 코드가 이전 코드와 동일하게 동작할 수 있는 이유는 TestScopeStandardTestDispatcher를 사용하기 때문입니다.

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를 사용해야 합니다.backgroundScoperunTest 코루틴의 모든 코드가 실행되면 자동으로 취소되며, 이를 통해 테스트가 무한히 실행되는 것을 방지할 수 있습니다.

  @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)
    }