You will be fine

<Coroutine> 7. 코루틴 단위 테스트

by BFine
반응형

참고 & 출처 : https://www.yes24.com/Product/Goods/125014350

 

코틀린 코루틴의 정석 - 예스24

많은 개발자들이 어렵게 느끼는 비동기 프로그래밍을 다양한 시각적 자료와 설명을 통해 누구나 쉽게 이해할 수 있도록 쓰인 책이다. 안드로이드, 스프링 등 코틀린을 사용하는 개발자들 중 코

www.yes24.com

가.  단위 테스트

 a. 무엇인가?

  -  단위 테스트에 대한 범위에 대해 다양한 시각이 있는데 이 책에서 잘 설명해 준 것 같아서 읽어보면 되게 좋을것 같다!

  -  단위란 명확히 정의된 역할의 범위를 갖는 코드집합으로 기능을 담는 코드 블록을 나타낸다. 즉 개별 함수나 클래스 또는 모듈이 모두 단위가 될수 있다.

      => 즉 단위테스는 특정 기능이 제대로 제대로 동작하는지 확인하는 테스트를 작성하는 것을 뜻 한다. 

 

 b. 객체 지향 프로그래밍에서의 단위 테스트

  -  객체 지향 프로그래밍은  책임을 객체에 할당하고 객체 간의 유연한 협력관계를 구축하는 것을 의미한다.

      => 소프트웨어의 기능을 담는 역할은 객체가 하고 있기 때문에 단위 테스트는 주로 특정 하나의 객체가 된다.

  -  물론 특정 하나의 객체만으로 단위 테스트가 안될 수도 있기 때문에 적당한 의존성으로 테스트하는 것도 단위테스트가 될수 있다.

 

 c. 결과 확인은 어떻게?

  -  단순히 객체 함수의 결과값이 제대로 반환 되는지 확인만 해도 되지만 상황에 따라 객체가 가진 상태가 잘변화하는지

      또 의존성을 가진 다른 객체와 상호작용하는지 등도 단위테스트 결과 확인할 때 포함해야한다. 

 

 d. 테스트 더블 

  -  테스트 더블은 객체에 대한 대체물을 뜻하며 객체의 행동을 모방하는 객체를 만드는데 사용한다. 즉 의존성에 대한 처리를 직접 한다고 보면 된다.

  -  스텁 : 미리 정의된 데이터를 반환하는 모방 객체를 의미한다. 즉 상태에 대한 검증을 위한 객체이다. 

  -  목 :  특정 동작을 시뮬레이션 하는 모방 객체를 의미한다. 즉 행위에 대한 검증을 위한 객체이다.

  -  페이크 : 실제 객체와 비슷하게 동작하도록 구현된 모방 객체를 의미한다. 즉 실제 의존성을 단순화해서 사용하는 가짜 객체이다.

  -  스파이 : 호출이 발생했을때 해당 호출에 대한 정보를 기록하는 모방 객채를 의미한다. 이는 목과 유사하다.

 

나.  코루틴 단위테스트 

 a. 일시중단 함수 테스트 작성 예시 

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class RepeatAddUseCase {

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

  -  일반적인 테스트코드 작성하는 방법으로 하게되면 컴파일 오류가 발생한다. 그 이유는 일시중단 함수를 호출했기 때문이다.

  -  IDE에서 시키는데로 테스트 메서드에 suspend를 붙여보면 컴파일 오류는 발생하지 않았지만 실행하면 JUnit에서 테스트를 찾지 못하는것을 볼 수 있다.

      => 이유는 JUnit이 suspend 메서드 시그니처를 지원하지 않기 때문이다. ( suspend 는 단순 키워드여서 그런걸까? )

  -  runBlocking으로 하면 잘 실행되는 것을 볼 수 있다. 

 

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

  -  runBlocking을 이용해서 테스트 하는 경우 시간이 오래걸릴수도 있는데 이런 문제를 해결하기 위한 테스트 라이브러리가 존재한다.

@Test
fun testDispatcher() {
    val testCoroutineScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(scheduler = testCoroutineScheduler)

    // given
    var result = 0

    // when
    CoroutineScope(context = testDispatcher).launch {
        delay(10000L)
        result = 1
        delay(10000L)
        result = 2
    }

    // then
    assertEquals(0, result)
    testCoroutineScheduler.advanceTimeBy(11000L)
    assertEquals(1, result)
    testCoroutineScheduler.advanceTimeBy(10000L)
    assertEquals(2, result)
}
// 실행시간 51ms

  -  TestCoroutineScheduler를 사용하면 가상 시간을 사용해 코루틴의 동작이 원하는 시간까지 단번에 진행될 수 있도록 설정 할 수 있다. 

      => 이와 함께 StandardTestDispatcher 를 사용하면  테스트를 위한 코루틴 디스패처를 만들어서 사용할수 있다.

@Test
fun testDispatcher() {
    val testCoroutineScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(scheduler = testCoroutineScheduler)

    // given
    var result = 0

    // when
    CoroutineScope(context = testDispatcher).launch {
        delay(10000L)
        result = 1
        delay(10000L)
        result = 2
    }

    // then
    testCoroutineScheduler.advanceUntilIdle()
    assertEquals(2, result)
}
// 실행시간 50ms

  -  advanceUtilIdle은 모든 디스패처와 연결된 작업이 완료될 때까지 가상 시간을 흐르게 만드는 기능을 한다.

@Test
fun testDispatcher() {
    val testDispatcher = StandardTestDispatcher()

    // given
    var result = 0

    // when
    CoroutineScope(context = testDispatcher).launch {
        delay(10000L)
        result = 1
        delay(10000L)
        result = 2
    }

    // then
    testDispatcher.scheduler.advanceUntilIdle()
    assertEquals(2, result)
}
// 실행시간 56ms

  -  StandardTestDispatcher 함수에는 기본적으로 TestCoroutineScheduler 객체를 생성하는 부분이 포함되어있어 직접 생성할 필요없다.

@Test
fun testDispatcher() {
    val testScope = TestScope()

    // given
    var result = 0

    // when
    testScope.launch {
        delay(10000L)
        result = 1
        delay(10000L)
        result = 2
    }

    // then
    testScope.advanceUntilIdle()
    assertEquals(2, result)
}
// 실행시간 59ms

  -  TestScope를 사용하면 내부에 TestDispathcer 객체를 가지고 있기 때문에 훨씬 더 간결하게 사용할수 있다.

@Test
fun testDispatcher() {
    // given
    var result = 0

    // when
    runTest {
        delay(10000L)
        result = 1
        delay(10000L)
        result = 2
    }

    // then
    assertEquals(2, result)
}
// 실행시간 55ms
@Test
fun testDispatcher() = runTest {
    // given
    var result = 0

    // when
    delay(10000L)
    result = 1
    delay(10000L)
    result = 2


    // then
    assertEquals(2, result)
}
// 실행시간 55ms

  -  runTest는 내부에 TestScope 객체를 가지고 있고 이를 이용해 코루틴을 실행시키고 advanceUtilIdle 처럼 작업완료까지 가상시간을 흐르게 한다.

  -  runBlocking 으로 만들어지는 코루틴과 유사하게 동작하지만 지연되는 부분을 건너뛰는 코루틴이 만들어진다.

  -  runTest 블록 내부에서 advanceTimeBy 나 advanceUtilIdle 함수 등을 사용할 수 있다.

 

 c. 단위 테스트 만들기 

data class Hotel(
    private val name: String,
    private val address: String,
    private val ownerName: String,
    private val totalPrice: Long,
    private val discountPrice: Long
) {

    val info: Info = Info(name = this.name, address = this.address, ownerName = this.ownerName)
    val price: Price = Price(price = this.totalPrice, discountPrice = this.discountPrice )

    data class Info(val name: String, val address: String, val ownerName: String)
    data class Price(val price: Long, val discountPrice: Long)
}
data class HotelInfoDto(val name: String, val address: String, val ownerName: String)

data class PriceInfoDto(val totalPrice:Long, val discountPrice:Long)
import com.example.dto.HotelInfoDto
import com.example.dto.PriceInfoDto
import kotlinx.coroutines.delay

class HotelApi {

    suspend fun getPriceInfo() : PriceInfoDto {
        delay(1000L)
        return PriceInfoDto(totalPrice = 100_000L, discountPrice = 10_000L)
    }

    suspend fun getHotelInfo(name:String) : HotelInfoDto {
        delay(3000L)
        return HotelInfoDto(name = name, address = "레몬시" , ownerName = "김레몬")
    }
}
import com.example.models.Hotel

class HotelService(
    private val hotelApi: HotelApi
) {

    suspend fun searchHotel(requestHotelName: String): Hotel {
        val (name, address, ownerName)  = hotelApi.getHotelInfo(name = requestHotelName)
        val (totalPrice, discountPrice) = hotelApi.getPriceInfo()

        return Hotel(
            name = name,
            address = address,
            ownerName = ownerName,
            totalPrice = totalPrice,
            discountPrice = discountPrice
        )
    }
}
import com.example.dto.HotelInfoDto
import com.example.dto.PriceInfoDto
import kotlinx.coroutines.delay

class HotelApi {

    suspend fun getPriceInfo() : PriceInfoDto {
        delay(1000L)
        return PriceInfoDto(totalPrice = 100_000L, discountPrice = 10_000L)
    }

    suspend fun getHotelInfo(name:String) : HotelInfoDto {
        delay(3000L)
        return HotelInfoDto(name = name, address = "레몬시" , ownerName = "김레몬")
    }
}
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

@ExperimentalCoroutinesApi
internal class HotelServiceTest{

    @Test
    fun searchHotel() = runTest {
        // given
        val requestHotelName = "레몬"

        // when
        val hotelService = HotelService(HotelApi())
        val searchHotel = hotelService.searchHotel(requestHotelName = requestHotelName)

        // then
        val (name, address ,ownerName) = searchHotel.info
        val (price, discountPrice) = searchHotel.price

        assertEquals("레몬", name)
        assertEquals("레몬시", address)
        assertEquals("김레몬", ownerName)
        assertEquals(100_000L, price)
        assertEquals(10_000L, discountPrice)
    }
}

  -  호텔에 대한 정보와 가격을 각각 조회해서 하나로 합쳐주는 것을 만들어보고 runTest를 이용해 예시를 만들어보았는데 빠르게 실행되는 것을 볼 수 있었다.

 

다.  코루틴 테스트 심화 

 a. 일시중단 함수 내부에 코루틴을 생성하고 있다면?

import com.example.models.Hotel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

class HotelService(
    private val hotelApi: HotelApi
) {

    suspend fun searchHotel(requestHotelName: String): Hotel {
        val scope = CoroutineScope(context = Dispatchers.IO)
        val infoDeferred = scope.async {
            hotelApi.getHotelInfo(name = requestHotelName)
        }
        val priceInfoDeferred = scope.async {
            hotelApi.getPriceInfo()
        }
        val (name, address, ownerName)  = infoDeferred.await()
        val (totalPrice, discountPrice) = priceInfoDeferred.await()

        return Hotel(
            name = name,
            address = address,
            ownerName = ownerName,
            totalPrice = totalPrice,
            discountPrice = discountPrice
        )
    }
}
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

@ExperimentalCoroutinesApi
internal class HotelServiceTest{

    @Test
    fun searchHotel() = runTest {
        // given
        val requestHotelName = "레몬리조트"

        // when
        val hotelService = HotelService(HotelApi())
        val searchHotel = hotelService.searchHotel(requestHotelName = requestHotelName)

        // then
        val (name, address ,ownerName) = searchHotel.info
        val (price, discountPrice) = searchHotel.price

        assertEquals("레몬리조트", name)
        assertEquals("레몬시", address)
        assertEquals("김레몬", ownerName)
        assertEquals(100_000L, price)
        assertEquals(10_000L, discountPrice)
    }
}
// 실행시간: 3sec 89ms

  -  비동기처리를 위해서 별도의 CoroutineScope로 코루틴을 생성하였고 테스트를 실행해보면 가상시간이 아닌 실제 delay까지 발생한 시간이 걸렸다.

      => runTest의 코루틴과 새로운 CoroutineScope 생성한 코루틴은 구조화되지 않기 때문에 TestCoroutineScheduler 영향을 받지않는다.

import com.example.models.Hotel
import kotlinx.coroutines.*

class HotelService(
    private val hotelApi: HotelApi
) {

    suspend fun searchHotel(
        requestHotelName: String,
        dispatcher: CoroutineDispatcher = Dispatchers.IO
    ): Hotel {
        val scope = CoroutineScope(context = dispatcher)
        val infoDeferred = scope.async {
            hotelApi.getHotelInfo(name = requestHotelName)
        }
        val priceInfoDeferred = scope.async {
            hotelApi.getPriceInfo()
        }
        val (name, address, ownerName)  = infoDeferred.await()
        val (totalPrice, discountPrice) = priceInfoDeferred.await()

        return Hotel(
            name = name,
            address = address,
            ownerName = ownerName,
            totalPrice = totalPrice,
            discountPrice = discountPrice
        )
    }
}
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

@ExperimentalStdlibApi
@ExperimentalCoroutinesApi
internal class HotelServiceTest{

    @Test
    fun searchHotel() = runTest {
        // given
        val testDispatcher = this@runTest.coroutineContext[CoroutineDispatcher.Key]!!
        val requestHotelName = "레몬"

        // when
        val hotelService = HotelService(HotelApi())
        val searchHotel = hotelService.searchHotel(
            requestHotelName = requestHotelName,
            dispatcher = testDispatcher
        )

        // then
        val (name, address ,ownerName) = searchHotel.info
        val (price, discountPrice) = searchHotel.price

        assertEquals("레몬", name)
        assertEquals("레몬시", address)
        assertEquals("김레몬", ownerName)
        assertEquals(100_000L, price)
        assertEquals(10_000L, discountPrice)
    }
}
// 실행시간: 63ms

  -  이부분은 함수 작성할때부터 dispacther를 매개변수로 받을 수 있도록 바꿔주는것이 좋다(마치 LocalDateTime.now() 사용해야하는 경우와 비슷하다)   

  -  이후에 runTest의 dispatcher를 사용하게 되면 아까와 다르게 가상시간으로 빠르게 처리되는 것을 볼 수 있다.

 

 b. backgroundScope

  -  runTest 함수 내부적으로 모든 테스트가 통과한 이후에도 테스트가 종료되지 않는 경우 10초 뒤에 UncompletedCoroutinesError 예외를 발생시킨다.

  -  무한반복되는 작업에 대해서는 backgroundScope를 사용하여 처리할 수 있다.

반응형

블로그의 정보

57개월 BackEnd

BFine

활동하기