반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/11   »
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
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[코루틴의 정석] 12장. 코루틴 단위 테스트 본문

Kotlin/코루틴의 정석

[코루틴의 정석] 12장. 코루틴 단위 테스트

어리둥절범고래 2024. 10. 6. 20:40
반응형

12.1. 단위 테스트 기초

 

12.1.1. 단위 테스트란 무엇인가?

 

  • 단위(Unit): 명확히 정의된 역할의 범위를 갖는 코드의 집합으로, 정의된 동작을 실행하는 개별 함수나 클래스 또는 모듈이다.
  • 단위 테스트(Unit Test): 단위에 대한 자동화된 테스트를 작성하고 실행하는 프로세스를 말한다.

객체 지향 프로그래밍에서 테스트 대상이 되는 단위는 주로 객체이며, 테스트 대상이 된 객체의 함수를 호출하고 함수가 호출되면 객체가 예상대로 동작하는지 확인하는 과정을 통해 테스트를 진행할 수 있다.

 


12.1.2. 테스트 환경 설정하기

 

dependencies {
    ...
    // JUnit5 테스트 프레임웍
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
}

// Junit5를 사용하기 위한 옵션 추가
tasks.test {
    useJUnitPlatform()
}

 


12.1.3. 간단한 테스트 만들고 실행하기

 

가변 변수를 입력받아 모두 더해 반환하는 add 연산을 포함한 AddUseCase 클래스를 테스트 대상으로 준비하고, 이에 대한 테스트 클래스를 만든다.

class AddUseCase {
  fun add(vararg args: Int): Int {
    return args.sum()
  }
}

class AddUseCaseTest {
  @Test
  fun `1 더하기 2는 3이다`() {
    val addUseCase: AddUseCase = AddUseCase()
    val result = addUseCase.add(1, 2)
    assertEquals(3, result)
  }
}

개별 테스트는 @Test 어노테이션이 붙은 함수로 작성되며, AddUseCase 객체를 사용해 1과 2의 더하기 연산을 실행한 후 assertEquals를 사용해 결괏값이 3인지 단언(Assert)한다.

 

Assert란 테스트를 검증하는 데 사용하는 개념으로 특정한 조건이 참임을 검증함으로써 코드가 올바로 동작하는지 확인하는 것이다.

 

Run AddUseCaseTest를 했을 때 "Tests passed" 라는 문구가 출력되면 모든 테스트가 통과된 것을 뜻하며, 테스트가 실패하면 "Tests Failed" 라는 문구가 출력된다.

 

 

12.1.4. @BeforeEach 어노테이션을 사용한 테스트 환경 설정

 

@BeforeEach 어노테이션을 사용하면 테스트 메서드가 실행되기 전에 필요한 준비 작업을 수행할 수 있으며, @BeforeEach 함수를 만들면 모든 테스트 실행 전에 공통으로 실행된다.

class AddUseCaseTestBeforeEach {
  lateinit var addUseCase: AddUseCase

  @BeforeEach
  fun setUp() {
    addUseCase = AddUseCase()
  }

  @Test
  fun `1 더하기 2는 3이다`() {
    val result = addUseCase.add(1, 2)
    println(result)
    assertEquals(3, result)
  }

  @Test
  fun `-1 더하기 2는 1이다`() {
    val result = addUseCase.add(-1, 2)
    println(result)
    assertEquals(1, result)
  }
}

AddUseCase의 인스턴스화가 테스트마다 반복되는 것을 @BeforeEach 함수에 작성하므로써 코드의 중복을 제거했다.

 


12.1.5. 테스트 더블을 사용해 의존성 있는 객체 테스트하기

 

다른 객체들과 의존성이 있는 객체를 테스트하는 방법을 확인하기 위해 UserProfileFetcher 객체를 만든다.

UserProfileFetcher 객체는 UserNameRepository에서 유저 이름을 가져오고, UserPhoneNumberRepository에서 유저의 전화번호를 가져와 데이터를 합쳐 UserProfile 타입의 객체를 반환하는 함수인 getUserProfileById를 가진다.

 

  • class UserProfileFetcher
  • interface UserNameRepository
  • interface UserPhoneNumberRepository
  • data class UserProfile
data class UserProfile(val id: String, val name: String, val phoneNumber: String)

interface UserNameRepository {
    fun saveUserName(id: String, name: String)
    fun getNameByUserId(id: String): String
}

interface UserPhoneNumberRepository {
    fun saveUserPhoneNumber(id: String, phoneNumber: String)
    fun getPhoneNumberByUserId(id: String): String
}

class UserProfileFetcher(
    private val userNameRepository: UserNameRepository,
    private val userPhoneNumberRepository: UserPhoneNumberRepository
) {
    fun getUserProfileById(id: String): UserProfile {
        // 유저의 이름을 UserNameRepository로부터 가져옴
        val userName = userNameRepository.getNameByUserId(id)
        // 유저의 전화번호를 UserPhoneNumberRepository로부터 가져옴
        val userPhoneNumber = userPhoneNumberRepository.getPhoneNumberByUserId(id)
        return UserProfile(
            id = id,
            name = userName,
            phoneNumber = userPhoneNumber
        )
    }
}

UserProfileFetcher 객체에 대한 테스트 코드를 작성할 때 UserNameRepository 인터페이스와 UserPhoneNumberRepository 인터페이스에 대한 구현체가 없어서 어려움이 있다.

 


12.1.5.1. 테스트 더블을 통한 객체 모방

 

다른 객체와의 의존성을 가진 객체를 테스트하기 위해서는 테스트 더블이 필요하다.

테스트 더블은 객체에 대한 대체물을 뜻하며, 객체의 행동을 모방하는 객체를 만드는데 사용한다.

테스트 더블

 

테스트 더블의 종류는 여러 가지가 있으며, 12장에서는 Stub과 Fake 정도만 다룬다.

 

  • Dummy: 실제로 사용되지 않으며, 단순히 인자 채우기 용도로 사용됨 메서드 호출 시, 필수적인 인자를 제공할 때 사용
  • Stub: 미리 정해진 고정된 값을 반환하는 객체 단순한 동작을 시뮬레이션하여 테스트할 때 사용
  • Spy: 실제 객체처럼 동작하지만, 메서드 호출 여부와 전달된 인자를 기록 메서드 호출이 제대로 되었는지 확인할 때 사용
  • Mock: 행동을 사전 정의하고, 호출 여부와 인자가 올바른지 검증할 수 있는 객체 특정 동작이 발생했는지 확인하고, 그에 따른 결과를 검증할 때
  • Fake: 실제 객체처럼 동작하지만, 단순화된 내부 로직을 가지는 객체 복잡한 시스템 대신 간단한 동작을 구현해 테스트할 때 사용

 

스텁 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] ?: ""
    }
}

//    private val userNameMap = mapOf<String, String>(
//        "0x1111" to "홍길동",
//        "0x2222" to "조세영"
//    )

미리 정의된 데이터를 반환하기 위한 스텁이기 때문에 반환값이 없는 saveUserName은 구현하지 않는다.

미리 정의된 값들을 userNameMap 파라미터로 주입받아서 반환할 수 있도록 한다.

 

 

페이크 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] ?: ""
  }
}

 

 


12.1.5.2. 테스트 더블을 사용한 테스트

 

StubUserNameRepository와 FakeUserPhoneNumberRepository를 주입하여 UserProfileFetcher를 만들어 테스트를 만들면 아래와 같다.

class UserProfileFetcherTest {
  @Test
  fun `UserNameRepository가 반환하는 이름이 홍길동이면 UserProfileFetcher에서 UserProfile를 가져왔을 때 이름이 홍길동이어야 한다`() {
    // Given
    val userProfileFetcher = UserProfileFetcher(
      userNameRepository = StubUserNameRepository(
        userNameMap = mapOf<String, String>(
          "0x1111" to "홍길동",
          "0x2222" to "조세영"
        )
      ),
      userPhoneNumberRepository = FakeUserPhoneNumberRepository()
    )

    // When
    val userProfile = userProfileFetcher.getUserProfileById("0x1111")

    // Then
    assertEquals("홍길동", userProfile.name)
  }

  @Test
  fun `UserPhoneNumberRepository에 휴대폰 번호가 저장되어 있으면, UserProfile를 가져왔을 때 해당 휴대폰 번호가 반환되어야 한다`() {
    // Given
    val userProfileFetcher = UserProfileFetcher(
      userNameRepository = StubUserNameRepository(
        userNameMap = mapOf<String, String>(
          "0x1111" to "홍길동",
          "0x2222" to "조세영"
        )
      ),
      userPhoneNumberRepository = FakeUserPhoneNumberRepository().apply {
        this.saveUserPhoneNumber("0x1111", "010-xxxx-xxxx")
      }
    )

    // When
    val userProfile = userProfileFetcher.getUserProfileById("0x1111")

    // Then
    assertEquals("010-xxxx-xxxx", userProfile.phoneNumber)
  }
}

 

Given-When-Then

 

Given-When-Then은 테스트 코드 작성법 중 하나로 테스트의 시나리오를 설명함으로써 테스트 코드의 가독성을 높이는데 사용된다.

 

  • Given: 테스트 환경을 설정하는 작업을 한다.
  • When: 동작이나 이벤트를 발생시키고 결과를 얻는다.
  • Then: 테스트 결과를 검증한다.

 



12.2. 코루틴 단위 테스트 시작하기

 

12.2.1. 첫 코루틴 테스트 작성하기

 

add 일시중단 함수를 가진 RepeatAddUseCase를 만든다.

class RepeatAddUseCase {
  suspend fun add(repeatTime: Int): Int = withContext(Dispatchers.Default) {
    var result = 0
    repeat(repeatTime) {
      result += 1
    }
    return@withContext result
  }
}

class RepeatAddUseCaseTest {
  @Test
  fun `100번 더하면 100이 반환된다`() = runBlocking {
    // Given
    val repeatAddUseCase = RepeatAddUseCase()

    // When
    val result = repeatAddUseCase.add(100)

    // Then
    assertEquals(100, result)
  }
}

add 함수가 일반 함수가 아닌 일시 중단 함수이기 때문에 테스트 함수를 코루틴으로 감싸야 한다.

 


12.2.2. runBlocking을 사용한 테스트의 한계

 

runBlockin 함수를 사용한 테스트에서 오랜 시간이 걸리는 일시 중단 함수를 실행하면 문제가 나타난다.

class RepeatAddWithDelayUseCase {
    suspend fun add(repeatTime: Int): Int {
        var result = 0
        repeat(repeatTime) {
            delay(100L)
            result += 1
        }
        return result
    }
}

add 함수에 delay(100)을 추가하여 repeatTime이 늘어날 수록 더 많은 시간이 소요된다.

하나의 테스트를 실행하는 데 긴 시간이 걸린다면 사실상 활용되지 않으며, 테스트를 부담 없이 실행할 수 있도록 만들어야 한다.

 

 

 

12.3. 코루틴 테스트 라이브러리

 

12.3.1. 코루틴 테스트 라이브러리 의존성 설정하기

 

dependencies {
    ...
    // 코루틴 테스트 라이브러리
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2")
}

 


12.3.2. TestCoroutineScheduler 사용해 가상 시간에서 테스트 진행하기

 

코루틴 테스트 라이브러리는 코루틴이 오랜 시간이 걸리는 작업이 포함돼 있으면 가상 시간에서 테스트를 진행할 수 있도록 TestCoroutineScheduler를 제공하며, TestCoroutineScheduler를 사용하면 시간을 자유자재로 다룰 수 있다.

 

12.3.2.1. advanceTimeBy 사용해 가상 시간 흐르게 만들기

 

TestCoroutineScheduler 객체의 advanceTimeBy 함수를 호출하면 함수의 인자로 입력된 값만큼 가상 시간이 밀리초 단위로 흐르게 되며, 가상 시간이 얼마나 흘렀는지 확인하고 싶으면 TestCoroutineScheduler 객체의 currentTime 프로퍼티를 사용하면 된다.

@Test
fun `가상 시간 조절 테스트`() {
    // 테스트 환경 설정
    val testCoroutineScheduler = TestCoroutineScheduler()

    testCoroutineScheduler.advanceTimeBy(5000L) // 가상 시간에서 5초를 흐르게 만듦 : 현재 시간 5초
    assertEquals(5000L, testCoroutineScheduler.currentTime) // 현재 시간이 5초임을 단언
    testCoroutineScheduler.advanceTimeBy(6000L) // 가상 시간에서 6초를 흐르게 만듦 : 현재 시간 11초
    assertEquals(11000L, testCoroutineScheduler.currentTime) // 현재 시간이 11초임을 단언
    testCoroutineScheduler.advanceTimeBy(10000L) // 가상 시간에서 10초를 흐르게 만듦 : 현재 시간 21초
    assertEquals(21000L, testCoroutineScheduler.currentTime) // 현재 시간이 21초임을 단언
}

 


12.3.2.2. TestCoroutineScheduler와 StandardTestDispatcher 사용해 가상 시간 위에서 테스트 진행하기

 

TestCoroutineScheduler 객체는 테스트용 CoroutineDispatcher 객체인 TestDispatcher를 만드는 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) // result 0, 통과
    testCoroutineScheduler.advanceTimeBy(5000L) // 가상 시간에서 5초를 흐르게 만듦 : 현재 시간 5초
    assertEquals(0, result) // result 0, 통과
    testCoroutineScheduler.advanceTimeBy(6000L) // 가상 시간에서 6초를 흐르게 만듦 : 현재 시간 11초
    assertEquals(1, result) // 10초가 지나서 result = 1, 통과
    testCoroutineScheduler.advanceTimeBy(10000L) // 가상 시간에서 10초를 흐르게 만듦 : 현재 시간 21초
    assertEquals(2, result) // 10초가 더 지나서 result = 2, 통과
}

testCoroutineScope는 testCoroutineScheduler에 의해 시간이 관리되기 때문에 이 범위에서 실행되는 코루틴들은 가상 시간이 흐르지 않으면 실행되지 않는다.


추가로, StandardTestDispatcher 함수 구현체는 아래와 같다.

public fun StandardTestDispatcher(
        scheduler: TestCoroutineScheduler? = null,
        name: String? = null
    ): TestDispatcher = StandardTestDispatcherImpl(
        scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name)

StandardTestDispatcher 함수가 호출됐을 때 scheduler 인자로 아무런 값도 전달되지 않으면 먼저 TestMainDispatcher.currentTestScheduler가 있는지 확인하고, 없으면 TestCoroutineScheduler 객체를 생성하기 때문에 굳이 TestCoroutineScheduler를 생성하여 scheduler 인자로 넘겨주지 않아도 된다.

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

 



12.3.2.3. advanceUntilIdle 사용해 모든 코루틴 실행시키기

 

테스트가 제대로 실행되기 위해서는 대상 코드가 모두 실행되고 나서 단언이 실행돼야 한다.

테스트 시 직접 시간을 컨트롤하는 경우는 거의 없으며, TestCoroutineScheduler 객체는 이 객체를 사용하는 모든 디스페처와 연결된 작업이 모두 완료될 때까지 가상 시간을 흐르게 만드는 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)
}

 


12.3.3. TestScope 사용해 가상 시간에서 테스트 진행하기

 

매번 TestDispatcher 객체를 CoroutineScope 함수로 감싸서 사용하는 것은 불편하다.

val testDispatcher: TestDispatcher = StandardTestDispatcher()
val testCoroutineScope = CoroutineScope(context = testDispatcher)

코루틴 테스트 라이브러리는 같은 동작을 하면서 더욱 많은 기능을 제공하는 TestScope 함수를 제공한다.

TestScope 객체는 내부에 TestDispatcher 객체를 가지고 있으며, TestCoroutineScheduler 객체의 함수들과 프로퍼티를 직접 호출할 수 있도록 만든다.

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

 


12.3.4. runTest 사용해 테스트 만들기

 

12.3.4.1. runTest 사용해 TestScope 대체하기

 

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)
}
// 실제로 테스트를 실행하는 데 걸린 시간이 100밀리초 정도밖에 되지 않는다.

 

 

runTest, TestScope, StandardTestDispatcher, TestCoroutineScheduler의 포함관계

 

지금까지 다룬 내용을 정리하면 runTest 함수는 TestScope 함수를 포함하고, TestScope 함수는 StandardTestDispatcher 함수를 포함하고, StandardTestDispatcher 함수는 TestCoroutineScheduler 함수를 포함한다.

 

코루틴 라이브러리 구성 요소의 포함관계

 

12.3.4.2. runTest로 테스트 전체 감싸기

 

runTest 함수로 감싸면 각 부분에서 일시 중단 함수가 호출되더라도 시간을 가상 시간을 통해 빠르게 흐르도록 만들 수 있다.

@Test
fun `runTest로 테스트 감싸기`() = runTest {  // this: TestScope
    // Given
    var result = 0

    // When
    delay(10000L) // 10초간 대기
    result = 1
    delay(10000L) // 10초간 대기
    result = 2

    // Then
    assertEquals(2, result)
}

 

 

12.3.4.3. runTest 함수의 람다식에서 TestScope 사용하기

 

runTest 함수는 람다식에서 TestScope 객체를 수신 객체로 갖기 때문에 this를 통해 TestScope 객체가 사용할 수 있는 함수와 프로퍼티를 모두 사용할 수 있다.

@Test
fun `runTest에서 가상 시간 확인`() = runTest {  // this: TestScope
    delay(10000L) // 10초간 대기
    println("가상 시간: ${this.currentTime}ms") // 가상 시간: 10000ms
    delay(10000L) // 10초간 대기
    println("가상 시간: ${this.currentTime}ms") // 가상 시간: 20000ms
}
/*
// 결과:
가상 시간: 10000ms
가상 시간: 20000ms
*/

advanceTimeBy 함수나 advanceUntilIdle 함수는 TestScope 내부에서 새로 실행된 코루틴에 대해서는 자동으로 시간을 흐르게 하지 않기 때문에, 해당 코루틴이 모두 실행 완료될 때까지 가상 시간을 흐르게 하는 데 사용할 수 있다.

@Test
fun `runTest 내부에서 advanceUntilIdle 사용하기`() = runTest {  // this: TestScope
    var result = 0
    launch {
        delay(1000L)
        result = 1
    }

    println("가상 시간: ${this.currentTime}ms, result = ${result}") // 가상 시간: 0ms, result = 0
    advanceUntilIdle()
    println("가상 시간: ${this.currentTime}ms, result = ${result}") // 가상 시간: 1000ms, result = 1
}

따라서 advanceUntilIdle 함수가 호출되기 전 출력에서는 가상 시간: 0ms, result = 0 이 나온다.

 

만약, runTest 코루틴의 자식 코루틴을 생성하고 해당 코루틴에 대해 join을 호출하면 advanceUntilIdle을 호출하지 않더라도 runTest 코루틴의 가상 시간이 흐른다. join 함수의 호출이 runTest 코루틴을 일시 중단시키기 때문이다.

@Test
fun `runTest 내부에서 join 사용하기`() = runTest {  // this: TestScope
    var result = 0
    launch {
        delay(1000L)
        result = 1
    }.join()

    println("가상 시간: ${this.currentTime}ms, result = ${result}") // 가상 시간: 1000ms, result = 1
}

 

 

 

12.4. 코루틴 단위 테스트 만들어 보기

 

12.4.1. 코루틴 단위 테스트를 위한 코드 준비하기

 

SNS에서 팔로워를 검색하는 FollowerSearcher 클래스를 만든다.

계정은 기업이 만든 계정과 개인이 만든 계정이 있다.

sealed class Follower(
  open val id: String,
  open val name: String
) {
  data class OfficialAccount(
    override val id: String,
    override val name: String
  ) : Follower(id, name)

  data class PersonAccount(
    override val id: String,
    override val name: String
  ) : Follower(id, name)
}

FollowerSearcher는 기업 계정 데이터를 가져오는 OfficialAccountRepository 객체와 개인 계정 데이터를 가져오는 PersonAccountRepository 객체를 주입받는다.

class FollowerSearcher(
  private val officialAccountRepository: OfficialAccountRepository,
  private val personAccountRepository: PersonAccountRepository
) {
  suspend fun searchByName(name: String): List<Follower> = coroutineScope {
    val officialAccountsDeferred = async {
      officialAccountRepository.searchByName(name)
    }
    val personAccountsDeferred = async {
      personAccountRepository.searchByName(name)
    }

    return@coroutineScope listOf(
      *officialAccountsDeferred.await(),
      *personAccountsDeferred.await()
    )
  }
}

interface OfficialAccountRepository {
  suspend fun searchByName(name: String): Array<Follower.OfficialAccount>
}

interface PersonAccountRepository {
  suspend fun searchByName(name: String): Array<Follower.PersonAccount>
}

OfficialAccountRepository 객체와 PersonAccountRepository 객체를 주입 받기 위해서 테스트 더블을 만든다.

class StubOfficialAccountRepository(
  private val users: List<Follower.OfficialAccount>
) : OfficialAccountRepository {
  override suspend fun searchByName(name: String): Array<Follower.OfficialAccount> {
    delay(1000L)
    return users.filter { user ->
      user.name.contains(name)
    }.toTypedArray()
  }
}

class StubPersonAccountRepository(
  private val users: List<Follower.PersonAccount>
) : PersonAccountRepository {
  override suspend fun searchByName(name: String): Array<Follower.PersonAccount> {
    delay(1000L)
    return users.filter { user ->
      user.name.contains(name)
    }.toTypedArray()
  }
}

 

12.4.2. FollowerSearcher 클래스 테스트 작성하기

 

12.4.2.1. @BeforeEach 사용해 테스트 실행 환경 설정하기

 

@BeforeEach를 사용해 테스트때마다 설정해야 할 환경을 설정한다.

class FollowerSearcherTest {

  private lateinit var followerSearcher: FollowerSearcher

  @BeforeEach
  fun setUp() {
    followerSearcher = FollowerSearcher(
      officialAccountRepository = stubOfficialAccountRepository,
      personAccountRepository = stubPersonAccountRepository
    )
  }
  
  companion object {
    private val companyA = Follower.OfficialAccount(id = "0x0000", name = "CompanyA")
    private val companyB = Follower.OfficialAccount(id = "0x0001", name = "CompanyB")
    private val companyC = Follower.OfficialAccount(id = "0x0002", name = "CompanyC")

    private val stubOfficialAccountRepository = StubOfficialAccountRepository(
      users = listOf(companyA, companyB, companyC)
    )

    private val personA = Follower.PersonAccount(id = "0x1000", name = "PersonA")
    private val personB = Follower.PersonAccount(id = "0x1001", name = "PersonB")
    private val personC = Follower.PersonAccount(id = "0x1002", name = "PersonC")

    private val stubPersonAccountRepository = StubPersonAccountRepository(
      users = listOf(personA, personB, personC)
    )
  }
}

 

 

12.4.2.2. 테스트 작성하기

 

FollowerSearcher 객체에 searchByName을 호출했을 때 결과가 반환되는지 확인하는 테스트이다.

runTest를 사용하여 OfficialAccountRepository, PersonAccountRepository 객체의 searchByName 함수가 delay(1000)으로 지연 시간을 만듦에도 테스트가 실행되는 시간은 더 짧다.

@Test
fun `공식 계정과 개인 계정이 합쳐져 반환되는지 테스트`() = runTest {
    // Given
    val searchName = "A"
    val expectedResults = listOf(companyA, personA)

    // When
    val results = followerSearcher.searchByName(searchName)

    // Then
    Assertions.assertEquals(
        expectedResults,
        results
    )
}

 

 


12.5. 코루틴 테스트 심화

 

12.5.1. 함수 내부에서 새로운 코루틴을 실행하는 객체에 대한 테스트

 

일시 중단 함수가 아닌 일반 함수를 구현하고, 내부에서 새로운 코루틴을 실행하는 경우가 있다.

class StringStateHolder {
  private val coroutineScope = CoroutineScope(Dispatchers.IO)

  var stringState = ""
    private set

  fun updateStringWithDelay(string: String) {
    coroutineScope.launch {
      delay(1000L)
      stringState = string
    }
  }
}

이에 대한 테스트는 다음과 같이 작성할 수 있다.

class StringStateHolderTestFail {
  @Test
  fun `updateStringWithDelay(ABC)가 호출되면 문자열이 ABC로 변경된다`() = runTest {
    // Given
    val stringStateHolder = StringStateHolder()

    // When
    stringStateHolder.updateStringWithDelay("ABC")

    // Then
    advanceUntilIdle()
    Assertions.assertEquals("ABC", stringStateHolder.stringState)
  }
}

이 테스트는 실행해보면 실패하며, 실패하는 이유는 StringStateHolder 객체 내부에 있는 CoroutineScope가 새로운 루트 Job을 갖기 때문에 runTest로 생성되는 코루틴과 구조화되지 않기 때문이다.

때문에, assert가 실행될 때 stringStateHolder.stringState 값은 advanceUntilIdle의 영향을 받지 않아 업데이트되기 전이어서 테스트가 실패한다.

 

이를 해결하기 위해서는 StringStateHolder 객체의 CoroutineScope 객체가 TestCoroutineScheduler 객체를 사용할 수 있게 해야 한다.

class StringStateHolder(
  private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
  private val coroutineScope = CoroutineScope(dispatcher)

  var stringState = ""
    private set

  fun updateStringWithDelay(string: String) {
    coroutineScope.launch {
      delay(1000L)
      stringState = string
    }
  }
}
class StringStateHolderTestSuccess {
  @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를 주입하여 사용하게 함으로써 해결할 수 있다.

 


12.5.2. backgroundScope 사용해 테스트 만들기

 

runTest 함수를 호출해 생성되는 코루틴은 메인 스레드를 사용하며, 내부의 모든 코루틴이 실행될 때까지 종료되지 않는다.

이 코루틴 내부에서 while문 같은 무한히 실행되는 작업이 실행된다면 테스트가 계속해서 실행된다.

@OptIn(ExperimentalCoroutinesApi::class)
@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)
    }

/*
// 결과:
After waiting for 10s, the test coroutine is not completing, there were active child jobs: ["coroutine#3":StandaloneCoroutine{Active}@381f03c1]
kotlinx.coroutines.test.UncompletedCoroutinesError: After waiting for 10s, the test coroutine is not completing, there were active child jobs: ["coroutine#3":StandaloneCoroutine{Active}@381f03c1]
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$2$1.invoke(TestBuilders.kt:349)
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$2$1.invoke(TestBuilders.kt:333)
...
*/

 

반응형