I was trying to run a unit test of my kmp code on android(local) with TestScope
by injecting it into the view model.
Here’s the code:
class FrankfurterRepository(engine: HttpClientEngine) {
private val BASE_URL = "https://api.frankfurter.app/latest"
private val httpClient = HttpClient(engine) {
install(ContentNegotiation) {
json()
}
defaultRequest {
url
}
}
suspend fun getExchangeRates(exchangeRateQuery: ExchangeRateQuery): ExchangeRate {
val response: ExchangeRate = httpClient.get(BASE_URL) {
parameter("amount", exchangeRateQuery.amount)
parameter("from", exchangeRateQuery.from)
parameter("to", exchangeRateQuery.to)
}.body()
return response
}
}
view model:
class FrankfurterViewModel(
private val frankfurterRepository: FrankfurterRepository,
coroutineScopeProvider: CoroutineScopeProvider
): ViewModel() {
private val scope = coroutineScopeProvider() ?: viewModelScope
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun onRate(exchangeRateQuery: ExchangeRateQuery) {
_uiState.update { it.copy(query = exchangeRateQuery) }
scope.launch {
delay(100)
getExchangeRate(exchangeRateQuery)
}
}
suspend fun getExchangeRate(exchangeRateQuery: ExchangeRateQuery) {
updateResult(CustomResult.InProgress)
try {
val rate = frankfurterRepository.getExchangeRates(exchangeRateQuery)
_uiState.update { it.copy(exchangeRate = rate, result = CustomResult.Success) }
} catch (e: Exception) {
updateResult(CustomResult.Error(e.message.toString()))
}
}
private fun updateResult(result: CustomResult) =
_uiState.update { it.copy(result = result) }
data class UiState(
val exchangeRate: ExchangeRate = ExchangeRate(),
val query: ExchangeRateQuery = ExchangeRateQuery(
amount = 1f,
from = CurrencyEnum.USD.name,
to = CurrencyEnum.EUR.name
),
val result: CustomResult = CustomResult.Idle
)
}
class CoroutineScopeProvider(private val scope: CoroutineScope? = null) {
operator fun invoke() = scope
}
test:
open class BaseTestClass {
val testScope = TestScope()
val exception = Exception("exception")
val snackbarScope = TestScope()
val coroutineScopeProvider = CoroutineScopeProvider(testScope)
}
@OptIn(ExperimentalCoroutinesApi::class)
class UnitTests: BaseTestClass() {
private lateinit var viewModel: FrankfurterViewModel
private fun mockViewModel() {
val exchangeRate = ExchangeRate(
amount = 1.12f,
base = "EUR",
date = "2023-07-17",
rates = mapOf(CurrencyEnum.USD.name to 0.85f)
)
val responseContent = Json.encodeToString(ExchangeRate.serializer(), exchangeRate)
val mockEngine = MockEngine { request ->
respond(
content = ByteReadChannel(responseContent),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
val repository = FrankfurterRepository(mockEngine)
viewModel = FrankfurterViewModel(repository, coroutineScopeProvider)
}
@Test
fun fetchCurrency_success() = testScope.runTest {
mockViewModel()
viewModel.onRate(viewModel.uiState.value.query)
advanceUntilIdle()
println(viewModel.uiState.value.exchangeRate)
assertEquals(CustomResult.Success, viewModel.uiState.value.result)
}
In this example, despite using advanceUntilIdle
, the assertion states:
Expected :Success
Actual :InProgress
Also, I tried testing out the delay
function:
@Test
fun fetchCurrency_success() = testScope.runTest {
delay(1000)
}
Even though the delay is equal to 1 second, the test takes just about 300ms to complete
Interestingly, if i change implementation of onRate
to
fun onRate(exchangeRateQuery: ExchangeRateQuery): Job {
_uiState.update { it.copy(query = exchangeRateQuery) }
return scope.launch {
delay(100)
getExchangeRate(exchangeRateQuery)
}
}
and join the job in test:
@Test
fun fetchCurrency_success() = testScope.runTest {
mockViewModel()
val job = viewModel.onRate(viewModel.uiState.value.query)
job.join()
println(viewModel.uiState.value.exchangeRate)
assertEquals(CustomResult.Success, viewModel.uiState.value.result)
}
the assertion succeeds