【问题标题】:Kotlin Flow: emitAll is never collectedKotlin Flow:从不收集 emitAll
【发布时间】:2021-10-28 03:41:52
【问题描述】:

我正在尝试为 networkBoundResource 的 kotlin 版本编写一个 UnitTest,可以在 serveral sourcesseveral features 上找到它

这是我对以下问题的标记-cmets 版本。

inline fun <ResultType, RequestType> networkBoundResource(
    ...
    coroutineDispatcher: CoroutineDispatcher
) = flow {

    emit(Resource.loading(null)) // emit works!

    val data = queryDatabase().firstOrNull()

    val flow = if (shouldFetch(data)) {
        
        emit(Resource.loading(data)) // emit works!

        try {
            saveFetchResult(fetch())
            query().map { Resource.success(it) }
            
        } catch (throwable: Throwable) {
            onFetchFailed(throwable)
            query().map { Resource.error(throwable.toString(), it) }
            
        }
    } else {
        query().map { Resource.success(it) }
    }

    emitAll(flow) // emitAll does not work!

}.catch { exception ->
    emit(Resource.error("An error occurred while fetching data! $exception", null))

}.flowOn(coroutineDispatcher)

这是我对此代码的单元测试之一。对代码进行了一些编辑以专注于我的问题:


@get:Rule
val testCoroutineRule = TestCoroutineRule()

private val coroutineDispatcher = TestCoroutineDispatcher()

@Test
fun networkBoundResource_noCachedData_shouldMakeNetworkCallAndStoreUserInDatabase() = testCoroutineRule.runBlockingTest {
    ...

    // When getAuthToken is called
    val result = networkBoundResource(..., coroutineDispatcher).toList()

    result.forEach {
        println(it)
    }    
}

问题是println(it) 只打印Resource.loading(null) 排放量。但是如果你看一下flow {} 块的最后一行,你会发现应该有另一个val flow 的发射。但是这种排放永远不会到达我的单元测试中。为什么?

【问题讨论】:

  • 你确定是emitAll的问题吗?代码是否达到了这一点?此外,这可能是您的 query 流在测试上下文中没有发出任何内容的问题。
  • 是的,我确定。如果我在emitAll 前面放置一个println,它就会被打印出来。此外,查询只是 UnitTest 上下文中的测试虚拟对象。
  • 它对Flow&lt;T&gt; api 的使用相当杂乱-具有副作用(保存到数据库)和FlowCollector&lt;T&gt;.() 范围内的嵌套流和回调onFetchFailed(throwable)(这有什么意义?)当Flow&lt;T&gt; api 包含所有内容时出现错误。在测试它会产生异常或意外的行为时,我并不感到惊讶。喜欢编写自己的代码,而不是复制/粘贴其他人。
  • @MarkKeen 我明白你的观点并在一定程度上同意。再说一遍,这或多或少只是 Google 推荐的 NetworkBoundResourceofficial implementation 的一个 kotlin 端口。不过,我只是从 Flow 开始……你愿意写一个更好的实现的伪代码版本吗?
  • @Akhha8 我认为您将toList() 误认为first()。两者都是终端操作员,但toList() 不应该给出第一个发射的项目,而是所有发射项目的列表。因此也是Resource.success。但是,我也用collect { println(it) } 尝试了你的方法,但得到了相同的结果:只收集了 2 个加载排放。没有收到成功或错误发射。

标签: android unit-testing kotlin kotlin-coroutines kotlin-flow


【解决方案1】:

正如@MarkKeen 建议的那样,我现在创建了自己的实现,并且效果很好。与 SO 上的代码相比,这个版本现在注入了 coroutineDispatcher 以便于测试,它让流程负责错误处理,它不包含嵌套流程并且更容易阅读和理解。将更新的数据存储到数据库中仍然存在副作用,但我现在太累了,无法解决这个问题。

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.*

inline fun <ResultType, RequestType> networkBoundResource(
    crossinline query: () -> Flow<ResultType?>,
    crossinline fetch: suspend () -> RequestType,
    crossinline saveFetchResult: suspend (RequestType) -> Unit,
    crossinline shouldFetch: (ResultType?) -> Boolean = { true },
    coroutineDispatcher: CoroutineDispatcher
) = flow<Resource<ResultType>> {
    
    // check for data in database
    val data = query().firstOrNull()
    
    if (data != null) {
        // data is not null -> update loading status
        emit(Resource.loading(data))
    }
    
    if (shouldFetch(data)) {
        // Need to fetch data -> call backend
        val fetchResult = fetch()
        // got data from backend, store it in database
        saveFetchResult(fetchResult)        
    }

    // load updated data from database (must not return null anymore)
    val updatedData = query().first()

    // emit updated data
    emit(Resource.success(updatedData))    
    
}.onStart { 
    emit(Resource.loading(null))
    
}.catch { exception ->
    emit(Resource.error("An error occurred while fetching data! $exception", null))
    
}.flowOn(coroutineDispatcher)

这个内联乐趣的一个可能的单元测试,用于AuthRepsitory

@ExperimentalCoroutinesApi
class AuthRepositoryTest {

    companion object {
        const val FAKE_ID_TOKEN = "FAkE_ID_TOKEN"
    }

    @get:Rule
    val testCoroutineRule = TestCoroutineRule()

    private val coroutineDispatcher = TestCoroutineDispatcher()

    private val userDaoFake = spyk<UserDaoFake>()

    private val mockApiService = mockk<MyApi>()

    private val sut = AuthRepository(
        userDaoFake, mockApiService, coroutineDispatcher
    )

    @Before
    fun beforeEachTest() {
        userDaoFake.clear()
    }

    @Test
    fun getAuthToken_noCachedData_shouldMakeNetworkCallAndStoreUserInDatabase() = testCoroutineRule.runBlockingTest {
        // Given an empty database
        coEvery { mockApiService.getUser(any()) } returns NetworkResponse.Success(UserFakes.getNetworkUser(), null, HttpURLConnection.HTTP_OK)

        // When getAuthToken is called
        val result = sut.getAuthToken(FAKE_ID_TOKEN).toList()

        coVerifyOrder {
            // Then first try to fetch data from the DB
            userDaoFake.get()

            // Then fetch the User from the API
            mockApiService.getUser(FAKE_ID_TOKEN)

            // Then insert the user into the DB
            userDaoFake.insert(any())

            // Finally return the inserted user from the DB
            userDaoFake.get()
        }
        
        assertThat(result).containsExactly(
            Resource.loading(null),
            Resource.success(UserFakes.getAppUser())
        ).inOrder()
    }
}

【讨论】:

  • “但我现在太累了,无法解决这个问题。” - 每个开发者...
【解决方案2】:

我不太确定完整的行为,但本质上你想获得资源,并且当前流量都集中在 FlowCollector&lt;T&gt; 中,这使得推理和测试变得更加困难。

我以前从未使用或看过 Google 代码,老实说,我只是看了一眼。我的主要收获是它的封装很差,并且似乎打破了关注点的分离——它管理资源状态,并处理所有 io 工作一类。我希望有 2 个不同的类来分隔该逻辑并允许更轻松的测试。

作为简单的伪代码我会做这样的事情:

class ResourceRepository {

    suspend fun get(r : Request) : Resource {
        // abstract implementation details network request and io 
        // - this function should only fulfill the request 
        // can now be mocked for testing
        delay(3_000)
        return Resource.success(Any())
    }
}

data class Request(val a : String)

sealed class Resource {

    companion object {
        val loading : Resource get() = Loading
        fun success(a : Any) : Resource = Success(a)
        fun error(t: Throwable) : Resource = Error(t)
    }

    object Loading : Resource()

    data class Success(val a : Any) : Resource()

    data class Error(val t : Throwable) : Resource()
}

fun resourceFromRequest(r : Request) : Flow<Resource> =
    flow { emit(resourceRepository.get(r)) }
        .onStart { emit(Resource.loading) }
        .catch { emit(Resource.error(it)) }

这使您可以大大简化resourceFromRequest() 函数的实际测试,因为您只需要模拟存储库和一种方法。这使您可以抽象和处理其他地方的网络和 io 工作,而这些工作又可以单独进行测试。

【讨论】:

  • 谢谢,这实际上与现有代码非常接近。主要区别在于数据不仅从网络中获取,而且还缓存在本地数据库中。包括检查数据是否过时。但我真的很感激我的努力,我相信其他人也会从中受益!我听从了你的建议,自己重写了这个东西,现在它正在按照我需要的方式工作。包括流和注入调度程序的错误处理,以实现更好的测试。请参阅下面的答案
猜你喜欢
  • 1970-01-01
  • 2022-12-15
  • 1970-01-01
  • 2023-01-02
  • 2022-06-10
  • 1970-01-01
  • 2016-01-21
  • 2022-01-02
  • 1970-01-01
相关资源
最近更新 更多