Skip to content

Async Testing ‐ Testing normal functions that launch a coroutine

Devrath edited this page Oct 26, 2023 · 8 revisions

github-header-image (3)

Scenario

  • Most of the time we come across scenarios where the system under test(SUT) is a normal function.
  • But this normal function launches a new co-routine and performs some action in an async way.
  • If the function was a suspend function, We could have used a run-blocking way to test it on the test case so the test case does not finish until the suspend function finishes the execution.
  • This is more common where a view calls a function in a view-model and the view-model launches a co-routine.
  • Since the launched co-routine is independent we will not be able to use a run-blocking function.

Errors Faced

when we do not use the test dispatcher during the unit test

ERROR OBSERVED:->

Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

ERROR REASON:-> The coroutines are to be run on main-dispatcher, But the point to note here is that the main-dispatcher is associated with main-thread-looper, The main thread is associated with an Android device and the this is not available when unit tests are run, So we face this error,

ERROR SOLUTION:-> We can use a test dispatcher that replaces main dispatcher

Using runTest block

  • This block provides all the co-routines to be controlled from the test-dispatcher.
  • This block also helps to skip delays() that co-routine uses Since it's not needed in the test case

advancedUntilIdle

  • This helps to ensure that the test dispatcher finishes before jumping to the next statement's possible assertions.

Code

ProfileViewModel.kt

class ProfileViewModel(
    private val repository: UserRepository,
    savedStateHandle: SavedStateHandle
): ViewModel() {

    private val userId = savedStateHandle.get<String>("userId")

    private val _state = MutableStateFlow(ProfileState())
    val state = _state.asStateFlow()

    fun loadProfile() {
        viewModelScope.launch {
            userId?.let { id ->
                _state.update { it.copy(isLoading = true) }

                val result = repository.getProfile(id)

                _state.update { it.copy(
                    profile = result.getOrNull(),
                    errorMessage = result.exceptionOrNull()?.message,
                    isLoading = false
                ) }
            }
        }
    }
}

UserRepositoryFake.kt

class UserRepositoryFake : UserRepository {

    var profile  = profile()
    var errorToReturn : Exception? = null

    override suspend fun getProfile(userId: String): Result<Profile> {
        return if(errorToReturn != null) {
            Result.failure(errorToReturn!!)
        } else {
            Result.success(profile)
        }
    }
}

MainCoroutineExtension.kt

@OptIn(ExperimentalCoroutinesApi::class)
class MainCoroutineExtension(
    val testDispatcher: TestDispatcher = StandardTestDispatcher()
): BeforeEachCallback, AfterEachCallback {

    override fun beforeEach(context: ExtensionContext?) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun afterEach(context: ExtensionContext?) {
        Dispatchers.resetMain()
    }
}

ProfileViewModelTest.kt

@OptIn(ExperimentalCoroutinesApi::class)
@ExtendWith(MainCoroutineExtension::class)
class ProfileViewModelTest {

    // SUT: Current system under test
    private lateinit var viewModel : ProfileViewModel
    private lateinit var repository : UserRepositoryFake


    @BeforeEach
    fun setUp(){

        repository = UserRepositoryFake()
        viewModel = ProfileViewModel(
            repository = repository,
            savedStateHandle = SavedStateHandle(
                initialState = mapOf(
                    "userId" to repository.profile.user.id
                )
            )
        )
    }



    @Test
    fun `Test loading profile success`() = runTest {
        viewModel.loadProfile()

        advanceUntilIdle()

        assertThat(viewModel.state.value.profile).isEqualTo(repository.profile)
        assertThat(viewModel.state.value.isLoading).isFalse()
    }

    @Test
    fun `Test loading profile error`() = runTest {
        repository.errorToReturn = Exception("Test exception")

        viewModel.loadProfile()

        advanceUntilIdle()

        assertThat(viewModel.state.value.profile).isNull()
        assertThat(viewModel.state.value.errorMessage).isEqualTo("Test exception")
        assertThat(viewModel.state.value.isLoading).isFalse()
    }

}