<Coroutine> 7. 코루틴 단위 테스트
by BFine참고 & 출처 : https://www.yes24.com/Product/Goods/125014350
가. 단위 테스트
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를 사용하여 처리할 수 있다.
'공부 > Coroutine' 카테고리의 다른 글
<Coroutine> 9. Hot & Cold Data Source (0) | 2024.08.24 |
---|---|
<Coroutine> 8. 채널(Channel), 셀렉트(Select) (0) | 2024.08.15 |
<Coroutine> 6. 일시 중단 함수와 코루틴 멀티스레드 (0) | 2024.07.07 |
<Coroutine> 5. 코루틴의 예외처리 (0) | 2024.06.30 |
<Coroutine> 4. 구조화된 코루틴 (2) | 2024.06.30 |
블로그의 정보
57개월 BackEnd
BFine