티스토리 뷰

728x90

 

 

그동안 Flow의 특성을 고려하지 않거나 가독성이 좋지 않은 상태로 "돌아가는 테스트 코드"를 작성했었다.

그러다보니 내가 짠 테스트인데도 지금 다시 보니 어떤 코드인지 제대로 이해하지 못하는 경우가 꽤나 있었다..

 

새로운 사이드 프로젝트를 시작하며, 가독성이 좋고 유지보수할 수 있는 테스트를 작성하려고 노력 중이다.

그 과정에서 Turbine 라이브러리를 얕게 학습했는데, 오늘은 이와 관련된 포스팅을 하려고 한다.

 

Turbine 라이브러리가 무엇인지 간단히 알아보고,

Turbine를 사용한 Flow와 StateFlow, SharedFlow 테스트 방법을 각각 소개한다.

Turbine를 사용하지 않았을 때의 코드와 비교하여 Turbine의 이점을 알아보려고 한다.

 

 

Turbine 라이브러리란?

https://github.com/cashapp/turbine

 

GitHub - cashapp/turbine: A testing library for kotlinx.coroutines Flow

A testing library for kotlinx.coroutines Flow. Contribute to cashapp/turbine development by creating an account on GitHub.

github.com

Flow를 편리하게 테스트할 수 있는 서드파티 라이브러리다.

안드로이드 공식 문서에서도 소개하고 있을 정도로, 많은 사람들이 사용하고 있다.

 

flowOf("one", "two").test {
    assertEquals("one", awaitItem())
    assertEquals ("two", awaitItem())  
    awaitComplete()
}

Turbine 사용법을 가장 간략하게 표현한 코드다.

가장 먼저 test() 함수가 무엇인지 알아보자.

 

test 함수

public suspend fun <T> Flow<T>.test(
  timeout: Duration? = null,
  name: String? = null,
  validate: suspend TurbineTestContext<T>.() -> Unit,
)

Flow의 확장 함수 형태인 test()는 Flow의 값을 편리하게 꺼내오고, 어설션할 수 있는 스코프를 제공한다.

매개변수 validate 람다의 리시버 TurbineTestContext를 통해 awaitItem()을 호출할 수 있는 것이다.

 

awaitItem, awaitComplete, awaitError 함수

interface ReceiveTurbine<T> {
    suspend fun awaitItem(): T
    
    suspend fun awaitComplete()

    suspend fun awaitError(): Throwable
}

TurbineTestContext는 ReceiveTurbine를 구현하고 있기 때문에, 위와 같은 ReceiveTurbine 함수들을 호출할 수 있다.

위 세개의 함수는 test()validate 블록 내부에서 사용하는 주요 함수들이다.

 

Turbine에서는 Flow가 값을 방출했을 때, Flow가 완료되었을 때, 에러가 발생했을 때를 모두 하나의 Event로 본다.

즉, 위 세개의 함수를 통해 기대하는 Event가 발생할 때까지 기다리다가, Event가 발생했다면 Event를 반환하는 것이다.

만약 timeout 동안 Event가 발생하지 않았다면, 예외를 던진다.

 

awaitXXX 함수의 timeout

app.cash.turbine.TurbineAssertionError: No value produced in 3s

test 함수의 timeout의 기본값은 3초이다.

만약 Flow의 test 블록 내에서 awaitItem()를 호출했는데, 3초 동안 아무런 값이 방출되지 않는다면 위와 같은 오류가 발생한다.

 

flowOf("one", "two").test(timeout = Duration.parse("PT10S")) {
    assertEquals("one", awaitItem())
    assertEquals ("two", awaitItem())  
    awaitComplete()
}

timeout 매개변수에 Duration을 지정하여 timeout을 커스텀할 수도 있다.

Turbine의 기본적인 함수들을 알아봤으니, Flow를 테스트하는 방법에 대해 알아보자.

 

 

Flow 테스트하기

Flow 단일 값 테스트 - Turbine 사용 X

@Test
fun flowTest1() = runTest {
    // given
    val repository = FakeRepository()
    val useCase = UseCase(repository)

    // when
    val actual = useCase()

    // then
    assertThat(actual.first()).isTrue
}

먼저 Turbine 없이 Flow 값 하나만을 테스트하려는 경우다.

이 경우 first()를 사용해 Flow의 가장 첫 번째 항목을 가져올 수 있다.

 

Flow 단일 값 테스트 - Turbine 사용 O

@Test
fun flowTest1WithTurbine() = runTest {
    // given
    val repository = FakeRepository()
    val useCase = UseCase(repository)

    // when
    val actual = useCase()

    // then
    actual.test {
        assertThat(awaitItem()).isTrue
        awaitComplete()
    }
}

Turbine를 사용하여 이전 코드를 테스트한 코드다.

간단한 테스트인 경우에는 Turbine를 사용했을 때 이점이 두드러지지는 않는다.

awaitItem()으로 값을 받아오고, awaitComplete()로 Flow가 끝났다는 이벤트를 받는다.

 

// 두 번째 값을 가져옴
flow.drop(1).first()

// 처음 5개의 값을 가져옴
flow.take(5).toList()

// 값이 하나만 방출된 후 Flow가 닫혔는지 확인
flow.single()

// 연산이 무한하지 않은 Flow에서 값의 개수
flow.count()
flow.count { it == 3 }

first()가 아니어도, Flow의 여러 연산들을 통해 Flow의 값을 가져올 수 있다.

 

순차적으로 Flow 여러 값 테스트 - Turbine 사용 X

toList()를 사용해 Flow 값을 한 번에 가져오는 경우, 전체 값을 가져올 때까지 함수가 중단된다는 단점이 있다.

toList()를 사용하지 않고도, 순차적으로 Flow에 값을 방출하고 Flow의 값을 확인할 수 있다.

 

@Test
fun flowTest2() = runTest {
    // given
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch {
        repository.flow().toList(values)
    }

    // when & then
    dataSource.emit(1)
    assertEquals(10, values[0])

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size)
}

Turbine를 사용하지 않는다면, 위 코드처럼 List에 Flow값을 하나씩 저장시키고, List 값을 확인해야 한다.

 

*

테스트의 무한 루프를 방지하기 위해 backgroundScope를 사용했다.

기본적으로 runTest는 자식 코루틴이 끝날 때까지 기다린다.

Repository의 Flow는 완료되지 않기 때문에, backgroundScope를 사용하지 않으면 toList()는 영원히 끝나지 않고 테스트도 무한루프를 돌 것이다.

반면 backgroundScope로 생성된 코루틴은 테스트가 끝나면 자동으로 취소된다.

 

순차적으로 Flow 여러 값 테스트 - Turbine 사용 O

@Test
fun flowTest2WithTurbine() = runTest {
    // given
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)
    
    // when & then
    repository.scores().test {
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem()

        dataSource.emit(3)
        assertEquals(30, awaitItem())
        awaitComplete()
    }
}

Turbine를 사용한 코드는 위와 같다.
Flow를 collect하거나 최종 연산자를 호출하지 않아도, test()를 호출하면 내부적으로 Flow를 collect한다.

List 없이 awaitItem()를 호출하여 Flow의 값을 순차적으로 꺼내온다.

List를 생성하고 List 인덱스에 접근하는 코드들이 사라지게 되어 가독성이 좋다.

 

 

StateFlow 테스트하기

StateFlow 테스트 - Turbine 사용 X

@Test
fun stateFlowTest() = runTest {
    // given
    val fakeRepository = FakeRepository()
    val viewModel = ViewModel(fakeRepository)

    // when & then
    assertEquals(0, viewModel.score.value)

    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

StateFlow는 Flow와 달리 Hot Flow이기 때문에 누군가 구독하지 않아도 값을 방출한다.

또한 항상 하나의 값을 가지고 있다.

즉, Flow의 first(), drop() 등을 사용할 수 있지만, value를 통해 현재의 값을 가져올 수도 있다.

 

@Test
fun stateFlowTest() = runTest {
    // given
    val fakeRepository = FakeRepository()
    val viewModel = ViewModel(fakeRepository)

    backgroundScope.launch {
        viewModel.score.collect {}
    }

    // ...
}

StateFlow는 Hot Flow지만, Flow.stateIn()을 통해 Flow를 StateFlow로 변환한 경우에는 꼭 collect하는 코드가 필요하다.

collect하지 않는다면, Flow가 활성화되지 않아 StateFlow 값도 업데이트되지 않는다.

 

StateFlow 테스트 - Turbine 사용 O

fun stateFlowTestWithTurbine() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = ViewModel(fakeRepository)

    viewModel.score.test {
        assertEquals(0, awaitItem())

        fakeRepository.emit(1)
        assertEquals(1, awaitItem())

        fakeRepository.emit(2)
        fakeRepository.emit(3)
        assertEquals(3, awaitItem())
    }
}

반면에 Turbine를 사용하는 경우, stateIn()을 사용하고 있더라도 collect하지 않아도 된다.

test() 내부적으로 StateFlow를 collect해주고 있기 때문이다.

 

 

SharedFlow 테스트하기

SharedFlow 테스트 - Turbine 사용 X

@Test
fun sharedFlowTest() = runTest {
    // given
    val fakeRepository = FakeRepository()
    val viewModel = ViewModel(fakeRepository)

    // when
    launch {
        viewModel.initialize()
    }

    // then
    assertThat(viewModel.event.first()).isEqualTo(3)
}

SharedFlow는 StateFlow와 동일하게 Hot Flow이지만, 값을 가지고 있지 않는다.

일회성으로 방출하는 값을 확인하려면, 새로운 코루틴에서 값을 방출하는 함수를 호출해야 한다.

 

만약 동일한 코루틴에서 함수를 호출한다면, 값을 어설션하는 것 보다 값을 방출하는 것이 더 빠를 수 있다.

이 경우 SharedFlow의 값을 계속 기다리면서 테스트가 무한 루프를 돌 것이다.

 

SharedFlow 테스트 - Turbine 사용 O

@Test
fun sharedFlowTestWithTurbine() = runTest {
    // given
    val fakeRepository = FakeRepository()
    val viewModel = ViewModel(fakeRepository)

    // when
    launch {
        viewModel.initialize()
    }

    // then
    viewModel.event.test {
        assertThat(awaitItem()).isEqualTo(3)
    }
}

SharedFlow의 경우 Turbine를 사용했을 때 큰 차이점은 없다.

기존 테스트들과 동일하게 test()awaitItem()을 사용한다.

 

@Test
fun sharedFlowTestWithTurbine() = runTest {
    // given
    val fakeRepository = FakeRepository()
    val viewModel = ViewModel(fakeRepository)

    // when, then
    viewModel.event.test {
    	viewModel.initialize()
        assertThat(awaitItem()).isEqualTo(3)
    }
}

또는 test() 블록 내부에서 when문을 작성해 준다.

이 경우 SharedFlow의 값이 방출된 이후에 어설션을 하더라도, 테스트가 성공한다.

 

 

마무리

Turbine가 내부적으로 어떻게 동작하길래 test()awaitItem()만으로 테스트를 성공할 수 있는지는 아직 모르겠다.

Flow를 깊이 이해하지 않아도 테스트할 수 있다는 것이 Turbine의 큰 장점일 수도 있겠다.

반면 Turbine 사용 없이 테스트를 작성할 때는, Flow의 특성을 아주 잘 이해해야 올바르게 테스트할 수 있다는 느낌을 받았다.

 

프로젝트의 테스트 코드 퀄리티를 위해 Turbine를 알아봤지만, 아직 실제 코드에 적용하지는 않았다.

프로젝트가 MVP 단계인 만큼, 복잡한 테스트가 필요하지 않을 거라 판단했기 때문이다.

Turbine는 Flow를 편리하게 테스트할 수 있도록 도와주지만,

무조건적인 외부 라이브러리 사용보다는 상황에 따라 선택하는 것을 선호한다.

 

Turbine의 test 함수 블록 내에서 사용할 수 있는 유용한 함수들을 정리하고 이 글을 마무리 하려고 한다.

특히 Flow의 완료 시점을 알지 못할 때, awaitComplete() 대신 cancelAndIgnoreRemainingEvents()를 사용한다. 

  • cancelAndConsumeRemainingEvents(): Flow를 취소하고 남은 이벤트를 모두 소비한다. (소비한 결과를 List 형태로 반환)
  • cancelAndIgnoreRemainingEvents(): Flow를 취소하고 남은 이벤트를 모두 무시한다.
  • expectMostRecentItem(): 가장 최근의 Flow 값을 가져온다.
  • cancel(): Flow를 취소한다.

 

 

 

참고

https://jaeryo2357.tistory.com/115

https://developer.android.com/kotlin/flow/test?hl=ko

https://medium.com/@vh.dev/testing-stateflow-and-sharedflow-with-ease-a-guide-to-seamless-testing-using-the-turbine-library-e0fb4ed2de66

728x90
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/04   »
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
글 보관함