Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Unit Testing Kotlin Channels & Flows - Android Summit

Mohit S
October 09, 2020

Unit Testing Kotlin Channels & Flows - Android Summit

Unit testing Channels and Flows can be a challenge as they are fairly new. In this talk, I will share with you how to implement and test practical examples from my experience. These examples are testing delays, retries, and errors. I'll also share testing more complex examples such as polling. For each use case, we'll look at how to use features in the coroutines library such as runBlockingTest and TestCoroutineDispatcher. From my journey of using and testing Flows in production, I'll share the challenges I experienced.

Mohit S

October 09, 2020
Tweet

More Decks by Mohit S

Other Decks in Technology

Transcript

  1. Unit Testing Channels & Flows • Run Blocking Test •

    Flow Test Cases • Flow Assertions with Turbine • View Model Testing • Testing in Coroutines Lib
  2. Flow Test Cases • Collection • Run on Dispatcher •

    Errors • Retries Fail • Retry Succeeds
  3. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } }
  4. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } }
  5. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } }
  6. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } Flow of Users
  7. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } Filter users
  8. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } Map user name
  9. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } How do we test this Flow?
  10. Kotlin/kotlinx.coroutines Code ! Issues Pull Requests kotlinx-coroutines-core 2,093 commits 4c28f942

    13 hours ago Kotlinx-coroutines-debug Kotlinx-coroutines-test reactive js benchmarks
  11. Kotlin/kotlinx.coroutines Code ! Issues Pull Requests kotlinx-coroutines-core 2,093 commits 4c28f942

    13 hours ago Kotlinx-coroutines-debug Kotlinx-coroutines-test reactive js benchmarks Testing Module
  12. Kotlin/kotlinx.coroutines Code ! Issues Pull Requests README.md Module Kotlin-coroutines-test Test

    utilities for kotlinx.coroutines dependencies { testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.9' }
  13. @Test fun `should get users`() = runBlockingTest { } Test

    Scope + Test Dispatcher What is executed in this coroutine?
  14. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Our test to run
  15. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Test Coroutine Scope
  16. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Wrap your test in Deferred
  17. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Run your test!
  18. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Cancel all Jobs in test
  19. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Fail on active coroutines
  20. fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =

    scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Setup Run Test Cleanup
  21. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } How do we mock service?
  22. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } Mock or Create fake
  23. nhaarman/mockito-kotlin Code ! Issues Pull Requests Mockito-Kotlin A small library

    that provides helper functions to work with Mockito in Kotlin. maven central 2.2.0
  24. val apiService = mock<ApiService> { on { getUsers() } doReturn

    users.asFlow() } @Test fun `should get users`() = runBlockingTest { } Mock API Service
  25. val apiService = mock<ApiService> { on { getUsers() } doReturn

    users.asFlow() } @Test fun `should get users`() = runBlockingTest { } Return a Flow of users
  26. val apiService = mock<ApiService> { on { getUsers() } doReturn

    users.asFlow() } @Test fun `should get users`() = runBlockingTest { } val users = listOf( User(1, "User 1", "NJ"), User(2, "User 2", "NYC"), User(3, "User 3", "CA"), User(4, "User 4", "NJ"), User(5, "User 5", "NYC") )
  27. val apiService = mock<ApiService> { on { getUsers() } doReturn

    users.asFlow() } val usersRepository = UsersRepository(apiService) fun `should get users`() = runBlockingTest { } Inject mock into Repo
  28. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().toList() assertEquals(users, listOf("User 2", "User 5")) } Collect Flow into List
  29. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().toList() assertEquals(users, listOf("User 2", "User 5")) } Assert on returned user names
  30. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().toList() assertEquals(users, listOf("User 2", "User 5")) } Is there a better way to collect from Flows in test?
  31. cashApp/turbine Code ! Issues Pull Requests Turbine Small testing library

    for kotlinx.coroutines Flow. testImplementation 'app.cash.turbine:turbine:0.2.1'
  32. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } Library has test extension on Flow
  33. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } Read from item from Flow
  34. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } Assertion
  35. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } Use expectItem to fetch each item
  36. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } Verify no more emission by Flow
  37. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } What if flow emitted more items than 2?
  38. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } UserRepoTests.kt Run: UserRepoTest should get users app.cash.turbine.AssertionError: Expected complete but found Item(User 6) One more item in Flow
  39. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectItem() shouldBe "User 6” expectComplete() } } UserRepoTests.kt Run: UserRepoTest should get users Add expect item
  40. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } What if we didn’t expect complete?
  41. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" } } UserRepoTests.kt Run: UserRepoTest should get users app.cash.turbine.AssertionError: Unconsumed events found: - Complete Need to assert flow finished
  42. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } UserRepoTests.kt Run: should get users UserRepoTest
  43. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } UserRepoTests.kt Run: should get users UserRepoTest How does this work?
  44. Channel (Unlimited) sealed class Event<out T> { object Complete data

    class Error(""...) data class Item(val value: T) } Event How Turbine Works
  45. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } API to verify emissions How Turbine Works
  46. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify duration of emission How Turbine Works
  47. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify no emissions How Turbine Works
  48. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify item emitted How Turbine Works
  49. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify error occurred How Turbine Works
  50. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify all items emitted How Turbine Works
  51. interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun

    expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } How Turbine Works
  52. How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {

    val events = Channel<Event<T">>(UNLIMITED) } Channel of events
  53. How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {

    val events = Channel<Event<T">>(UNLIMITED) launch { } }
  54. How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {

    val events = Channel<Event<T">>(UNLIMITED) launch { collect { item "-> events.send(Event.Item(item)) } } Store emissions
  55. How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {

    val events = Channel<Event<T">>(UNLIMITED) launch { try { collect { ""... } } catch { events.send(Event.Error) } } Send error to Channel
  56. How Turbine Works suspend fun <T> Flow<T>.test( validate: suspend FlowTurbine<T>.()

    "-> Unit ) { coroutineScope { val events = Channel<Event<T">>(UNLIMITED) launch { try { collect { ""... } } catch { events.send(Event.Error) Lambda block
  57. How Turbine Works suspend fun <T> Flow<T>.test( validate: suspend FlowTurbine<T>.()

    "-> Unit ) { coroutineScope { val events = Channel<Event<T">>(UNLIMITED) launch { try { collect { ""... } } catch { events.send(Event.Error) API
  58. @Test fun `should get users`() = runBlockingTest { val users

    = usersRepository.getUsers().test { expectItem() shouldBe "User 2" expectItem() shouldBe "User 5" expectComplete() } } Read emissions with API
  59. Flow Test Cases • Collection • Run on Dispatcher •

    Errors • Retries Fail • Retry Succeeds
  60. class UsersRepository( val apiService: ApiService, val dispatcher: CoroutineDispatcher ) {

    fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC" } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) } Inject Dispatcher
  61. class UsersRepository( val apiService: ApiService, val dispatcher: CoroutineDispatcher ) {

    fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC" } .map { Result.success(it.name) } .flowOn(dispatcher) } Use Dispatcher
  62. class UsersRepository( val apiService: ApiService, val dispatcher: CoroutineDispatcher ) {

    fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC" } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) }
  63. @Test fun `should get users`() = runBlockingTest { usersRepository.getUsers().test {

    expectError() } } fun TestCoroutineDispatcher.runBlockingTest(""...)
  64. val dispatcher = TestCoroutineDispatcher() val usersRepository = UsersRepository(""..., dispatcher) @Test

    fun `should get users`() = dispatcher.runBlockingTest { usersRepository.getUsers().test { expectError() } }
  65. val dispatcher = TestCoroutineDispatcher() val usersRepository = UsersRepository(""..., dispatcher) @Test

    fun `should get users`() = dispatcher.runBlockingTest { usersRepository.getUsers().test { expectError() } }
  66. val dispatcher = TestCoroutineDispatcher() val usersRepository = UsersRepository(""..., dispatcher) @Test

    fun `should get users`() = dispatcher.runBlockingTest { usersRepository.getUsers().test { expectError() } }
  67. Flow Test Cases • Collection • Run on Dispatcher •

    Errors • Retries Fail • Retry Succeeds
  68. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> { return

    apiService.getUsers() .filter { it.location "== "NYC" } .map { it.name } } } Test Error?
  69. @Test fun `should handle error`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw Exception() } usersRepository.getUsers().test { expectError() } } Throw exception
  70. @Test fun `should handle error`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw Exception() } usersRepository.getUsers().test { expectError() } } Verify error is thrown
  71. @Test fun `should handle error`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw Exception() } usersRepository.getUsers().test { expectError() } } UserRepoTests.kt Run: should handle error UserRepoTest
  72. @Test fun `should handle error`() = runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw Exception() } usersRepository.getUsers().test { expectError() } } UserRepoTests.kt Run: should handle error UserRepoTest Try Catch
  73. class UsersRepository(val apiService: ApiService) { fun getUsers(): Flow<String> = apiService.getUsers()

    .filter { it.location "== "NYC" } .map { it.name } } Better way to handle errors?
  74. inline class Result { fun <T> success(value: T) fun <T>

    failure(exception: Throwable) } Union to represent success or failure
  75. fun getUsers(): Flow<Result<String">> = return apiService.getUsers() .filter { it.location "==

    "NYC" } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } Flow emits Results
  76. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } Success
  77. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } Emit Result Failure
  78. @Test fun `should handle error`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw Exception() } usersRepository.getUsers().test { expectItem().isFailure.shouldBeTrue() expectComplete() } } Verify Result is failure
  79. @Test fun `should handle error`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw Exception() } usersRepository.getUsers().test { expectItem().isFailure expectComplete() } } Verify no more emissions
  80. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } Exception
  81. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } Emit Result item
  82. Flow Test Cases • Collection • Run on Dispatcher •

    Errors • Retries Fail • Retry Succeeds
  83. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Exception
  84. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Catch Exception
  85. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Retry?
  86. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Retry 2-times
  87. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Delay for each retry
  88. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Error
  89. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Retry
  90. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Delay 2 seconds
  91. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Try running Flow again
  92. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Retry fails, go to catch
  93. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher)
  94. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) How do we test retries?
  95. @Test fun `should retry twice`() = dispatcher.runBlockingTest { usersRepository.getUsers().test {

    advanceTimeBy(2000) advanceTimeBy(2000) expectItem().isFailure.shouldBeTrue() expectComplete() } }
  96. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { advanceTimeBy(2000) advanceTimeBy(2000) expectItem().isFailure.shouldBeTrue() expectComplete() } } Throw Exception
  97. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { expectItem().isFailure.shouldBeTrue() expectComplete() } } Read from Flow
  98. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { expectItem().isFailure.shouldBeTrue() expectComplete() } } Verify Result is Failure
  99. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { expectItem().isFailure.shouldBeTrue() expectComplete() } }
  100. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { expectItem().isFailure.shouldBeTrue() expectComplete() } } UserRepoTests.kt Run: UserRepoTest should retry twice kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1000 ms ! ! Timeout Exception
  101. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Error
  102. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Retry
  103. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Delay 2 seconds
  104. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Advance virtual time forward
  105. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { advanceTimeBy(2000) expectItem().isFailure.shouldBeTrue() expectComplete() } } Advance virtual time on dispatcher
  106. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { advanceTimeBy(2000) expectItem().isFailure.shouldBeTrue() expectComplete() } } Advance time by 2 seconds
  107. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Retry
  108. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Delay for before 2nd retry
  109. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { advanceTimeBy(2000) advanceTimeBy(2000) expectItem().isFailure.shouldBeTrue() expectComplete() } } Advance time forward for 2nd retry
  110. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) 2nd Retry
  111. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) All Retries failed
  112. @Test fun `should retry twice`() = dispatcher.runBlockingTest { whenever(apiService.getUsers()) doReturn

    flow { throw IOException() } usersRepository.getUsers().test { advanceTimeBy(2000) advanceTimeBy(2000) expectItem().isFailure.shouldBeTrue() expectComplete() } }
  113. Flow Test Cases • Collection • Run on Dispatcher •

    Errors • Retries Fail • Retry Succeeds
  114. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) 2nd Retry Succeeds
  115. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Apply Filter, Map
  116. var throwError = false whenever(apiService.getUsers()) doReturn flow { if (throwError)

    { throw IOException() } else { emit(User(1, "User 1", "NYC")) } } Flag to control sending error
  117. var throwError = false whenever(apiService.getUsers()) doReturn flow { if (throwError)

    { throw IOException() } else { emit(User(1, "User 1", "NYC")) } } Mock API Service to return Flow
  118. var throwError = false whenever(apiService.getUsers()) doReturn flow { if (throwError)

    { throw IOException() } else { emit(User(1, "User 1", "NYC")) } } Throw Error on exception
  119. var throwError = false whenever(apiService.getUsers()) doReturn flow { if (throwError)

    { throw IOException() } else { emit(User(1, "User 1", "NYC")) } } Emit User
  120. @Test fun `should retry succeed`() = dispatcher.runBlockingTest { usersRepository.getUsers().test {

    advanceTimeBy(2000) throwError = false advanceTimeBy(2000) expectItem().isSuccess.shouldBeTrue() expectComplete() } } Retry
  121. @Test fun `should retry succeed`() = dispatcher.runBlockingTest { usersRepository.getUsers().test {

    advanceTimeBy(2000) throwError = false advanceTimeBy(2000) expectItem().isSuccess.shouldBeTrue() expectComplete() } } Flow will return data
  122. @Test fun `should retry succeed`() = dispatcher.runBlockingTest { usersRepository.getUsers().test {

    advanceTimeBy(2000) throwError = false advanceTimeBy(2000) expectItem().isSuccess.shouldBeTrue() expectComplete() } } 2nd Retry
  123. @Test fun `should retry succeed`() = dispatcher.runBlockingTest { usersRepository.getUsers().test {

    advanceTimeBy(2000) throwError = false advanceTimeBy(2000) expectItem().isSuccess.shouldBeTrue() expectComplete() } } Verify Success
  124. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) 2nd Retry
  125. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher)
  126. fun getUsers(): Flow<Result<String">> = apiService.getUsers() .filter { it.location "== "NYC"

    } .map { Result.success(it.name) } .retry(2) { delay(2000) true } .catch { emit(Result.failure(it)) } .flowOn(dispatcher) Operators
  127. @Test fun `should retry succeed`() = dispatcher.runBlockingTest { usersRepository.getUsers().test {

    advanceTimeBy(2000) throwError = false advanceTimeBy(2000) expectItem().isSuccess.shouldBeTrue() expectComplete() } }
  128. Flow Test Cases • Collection • Run on Dispatcher •

    Errors • Retries Fail • Retry Succeeds
  129. class UserViewModel(): ViewModel() { val states = MutableStateFlow(State()) fun getData()

    { usersRepository.getUsers() .onEach { states.value = State(data = it.get()) } .launchIn(viewModelScope) } } data class State(val data: String = "") Jetpack View Model
  130. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow(State())

    fun getData() { usersRepository.getUsers() .onEach { states.value = State(data = it.get()) } .launchIn(viewModelScope) } } data class State(val data: String = "") Inject Repo
  131. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow("")

    fun getData() { usersRepository.getUsers() .onEach { states.value = State(data = it.get()) } .launchIn(viewModelScope) } } State Flow
  132. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow("")

    fun getData() { usersRepository.getUsers() .onEach { states.value = State(data = it.get()) } .launchIn(viewModelScope) } } Flow of user names
  133. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow("")

    fun getData() { usersRepository.getUsers() .onEach { states.value = it } .launchIn(viewModelScope) } } Update State flow for each emission
  134. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow("")

    fun getData() { usersRepository.getUsers() .onEach { states.value = it } .launchIn(viewModelScope) } } Launch coroutine in view module sopce
  135. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow("")

    fun getData() { usersRepository.getUsers() .onEach { states.value = it } .launchIn(viewModelScope) } }
  136. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf(Result.success("User")) viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } }
  137. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf(Result.success("User")) viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } } Mock flow
  138. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf("User") viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } } Emit Successful response
  139. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf("User") viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } } Run test
  140. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf("User") viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } } Verify emission
  141. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf(Result.success("User")) viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } } ViewModelTest.kt Run: ViewModelTest should get data Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
  142. val dispatcher = TestCoroutineDispatcher() @Before fun setUp() { Dispatchers.setMain(dispatcher) }

    @After fun tearDown() { Dispatchers.resetMain() } Set Main Dispatcher
  143. val dispatcher = TestCoroutineDispatcher() @Before fun setUp() { Dispatchers.setMain(dispatcher) }

    @After fun tearDown() { Dispatchers.resetMain() } Reset Main Dispatcher
  144. class CoroutineTestRule(testDispatcher) : TestWatcher() { override fun starting(description) { Dispatchers.setMain(testDispatcher)

    } override fun finished(description) { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } }
  145. class CoroutineTestRule(testDispatcher) : TestWatcher() { override fun starting(description) { Dispatchers.setMain(testDispatcher)

    } override fun finished(description) { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } }
  146. class CoroutineTestRule(testDispatcher) : TestWatcher() { override fun starting(description) { Dispatchers.setMain(testDispatcher)

    } override fun finished(description) { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } }
  147. @get:Rule var testRule = CoroutineTestRule() @Test fun `should get data`()

    = runBlockingTest { whenever(repo.getUsers()) doReturn flowOf("User") viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } }
  148. @get:Rule var testRule = CoroutineTestRule() @Test fun `should get data`()

    = runBlockingTest { whenever(repo.getUsers()) doReturn flowOf(Result.success("User")) viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User" } } ViewModelTest.kt Run: ViewModelTest should get data
  149. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf( "User 1", "User 2" ) viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User 1" expectItem().data shouldBe "User 2" expectComplete() } Return 2 results
  150. @Test fun `should get data`() = runBlockingTest { whenever(repo.getUsers()) doReturn

    flowOf( "User 1", "User 2" ) viewModel.getData() viewModel.states.test { expectItem().data shouldBe "User 1" expectItem().data shouldBe "User 2" expectComplete() } Return 2 results
  151. @Test fun `should get data`() = runBlockingTest { viewModel.getData() viewModel.states.test

    { expectItem().data shouldBe "User 1" expectItem().data shouldBe "User 2" expectComplete() } Verification
  152. @Test fun `should get data`() = runBlockingTest { viewModel.getData() viewModel.states.test

    { expectItem().data shouldBe "User 1" expectItem().data shouldBe "User 2" expectComplete() } ViewModelTest.kt Run: ViewModelTest should get data java.lang.AssertionError: Expected <User 1>, actual <User 2> are not the same instance.
  153. @Test fun `should get data`() = runBlockingTest { viewModel.getData() viewModel.states.test

    { expectItem().data shouldBe "User 1" expectItem().data shouldBe "User 2" expectComplete() } ViewModelTest.kt Run: ViewModelTest should get data java.lang.AssertionError: Expected <User 1>, actual <User 2> are not the same instance. Assertion failed
  154. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val states = MutableStateFlow(State())

    fun getData() { usersRepository.getUsers() .onEach { states.value = State(it) } .launchIn(viewModelScope) } } Conflated
  155. class UserViewModel(val usersRepository: UsersRepository): ViewModel() { val sharedFlow = MutableSharedFlow<String>()

    fun getData() { usersRepository.getUsers() .onEach { states.emit(it) } .launchIn(viewModelScope) } }
  156. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) }
  157. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Launch coroutine
  158. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Child coroutine Job
  159. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Cancel the job
  160. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Verify order of execution Verify completion
  161. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) }
  162. expect open class TestBase { fun error(message, cause) fun expect(index:

    Int) fun finish(index: Int) fun runTest( block: suspend CoroutineScope.() "-> Unit ) }
  163. actual fun runTest( block: suspend CoroutineScope.() "-> Unit ) {

    runBlocking( block = block, context = CoroutineExceptionHandler { ""... }) }
  164. expect open class TestBase { fun error(message, cause) fun expect(index:

    Int) fun finish(index: Int) fun runTest( block: suspend CoroutineScope.() "-> Unit ) } How does this work?
  165. private var actionIndex = AtomicInteger() actual fun expect(index: Int) {

    val wasIndex = actionIndex.incrementAndGet() check(index "== wasIndex) { “Expecting action index $index but it is actually $wasIndex" } }
  166. private var actionIndex = AtomicInteger() actual fun expect(index: Int) {

    val wasIndex = actionIndex.incrementAndGet() check(index "== wasIndex) { “Expecting action index $index but it is actually $wasIndex" } }
  167. private var actionIndex = AtomicInteger() actual fun expect(index: Int) {

    val wasIndex = actionIndex.incrementAndGet() check(index "== wasIndex) { “Expecting action index $index but it is actually $wasIndex" } }
  168. expect open class TestBase { fun error(message, cause) fun expect(index:

    Int) fun finish(index: Int) fun runTest( block: suspend CoroutineScope.() "-> Unit ) } How does this work?
  169. private var finished = AtomicBoolean() actual fun finish(index: Int) {

    expect(index) check(!finished.getAndSet(true)) { "Should call 'finish(""...)' at most once" } }
  170. private var finished = AtomicBoolean() actual fun finish(index: Int) {

    expect(index) check(!finished.getAndSet(true)) { "Should call 'finish(""...)' at most once" } }
  171. private var finished = AtomicBoolean() actual fun finish(index: Int) {

    expect(index) check(!finished.getAndSet(true)) { "Should call 'finish(""...)' at most once" } }
  172. @Test fun testCancelJobImpl() = runTest { val parent = launch

    { expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) }
  173. Resources • Unit Testing Delays, Errors & Retries with Kotlin

    Flows https:"//codingwithmohit.com/coroutines/unit-testing-delays- errors-retries-with-kotlin-flows/ • Unit Testing Channels & Flows in Practice https:"//www.youtube.com/watch?v=h8bLIUi6HWU