使用 LiveData 的 JUnit5 测试不执行订阅者的回调

JUnit5 test with LiveData doesn't execute subscriber's callback

提问人:Falling Into Infinity 提问时间:6/3/2019 最后编辑:CommunityFalling Into Infinity 更新时间:6/14/2019 访问量:2421

问:

背景:

我有一个简单的应用程序,它使用 rests API 调用获取电影列表。项目结构如下:

Activity -> ViewModel -> Repository -> ApiService (Retrofit Interface)
  1. 活动订阅 LiveData 并侦听事件更改

  2. ViewModel 承载活动观察到的 MediatorLiveData。最初,ViewModelMediatorLiveData 中设置一个值。Resource.loading(..)

  3. 然后,ViewModel 调用存储库以从 ApiService 获取电影列表

  4. ApiService 返回 LiveDataResource.success(..)Resource.error(..)

  5. 然后,ViewModelMediatorLiveDataApiServiceLiveData 结果合并

我的疑问:

在单元测试中,只有第一个发出是由 ViewModel 中的 MediatorLiveData 发出的。MediatorLiveData 从不从存储库发出任何数据。Resource.loading(..)

视图模型.class

private var discoverMovieLiveData: MediatorLiveData<Resource<DiscoverMovieResponse>> = MediatorLiveData()

fun observeDiscoverMovie(): LiveData<Resource<DiscoverMovieResponse>> {
        return discoverMovieLiveData
    }

fun fetchDiscoverMovies(page: Int) {

        discoverMovieLiveData.value = Resource.loading(null) // this emit get observed immediately 

        val source = movieRepository.fetchDiscoverMovies(page)
        discoverMovieLiveData.addSource(source) {
            discoverMovieLiveData.value = it // never gets called
            discoverMovieLiveData.removeSource(source)
        }
    } 

存储库.class

fun fetchDiscoverMovies(page: Int): LiveData<Resource<DiscoverMovieResponse>> {
        return LiveDataReactiveStreams.fromPublisher(
            apiService.fetchDiscoverMovies(page)
                .subscribeOn(Schedulers.io())
                .map { d ->
                    Resource.success(d) // never gets called in unit test
                }
                .onErrorReturn { e ->
                    Resource.error(ApiErrorHandler.getErrorByThrowable(e), null) // // never gets called in unit test
                }
        )
    }

单元测试

@Test
fun loadMovieListFromNetwork() {
        val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10)
        val call: Flowable<DiscoverMovieResponse> = successCall(mockResponse) // wraps the retrofit result inside a Flowable<DiscoverMovieResponse>
        whenever(apiService.fetchDiscoverMovies(1)).thenReturn(call)

        viewModel.fetchDiscoverMovies(1)

        verify(apiService).fetchDiscoverMovies(1)
        verifyNoMoreInteractions(apiService)

        val liveData = viewModel.observeDiscoverMovie()
        val observer: Observer<Resource<DiscoverMovieResponse>> = mock()
        liveData.observeForever(observer)

        verify(observer).onChanged(
            Resource.success(mockResponse) // TEST FAILS HERE AND GETS "Resource.loading(null)" 
        )
    }

Resource 是一个通用的包装类,用于包装不同场景的数据,例如加载、成功、错误。

class Resource<out T>(val status: Status, val data: T?, val message: String?) {
.......
}

编辑:#1

出于测试目的,我在存储库中更新了我的 rx 线程以在主线程上运行它。这最终会导致 Looper not mocked 异常。

fun fetchDiscoverMovies(page: Int): LiveData<Resource<DiscoverMovieResponse>> {
            return LiveDataReactiveStreams.fromPublisher(
                apiService.fetchDiscoverMovies(page)
                    .subscribeOn(AndroidSchedulers.mainThread())
                    .map {...}
                    .onErrorReturn {...}
            )
        }

在测试课上,

@ExtendWith(InstantExecutorExtension::class)
class MainViewModelTest {

    companion object {
        @ClassRule
        @JvmField
        val schedulers = RxImmediateSchedulerRule()
    }

    @Test
        fun loadMovieListFromNetwork() {
        .....  
       }
}

}

RxImmediateSchedulerRule.class

class RxImmediateSchedulerRule : TestRule {

    private val immediate = object : Scheduler() {
        override fun createWorker(): Worker {
            return ExecutorScheduler.ExecutorWorker(Executor { it.run() })
        }
    }

    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            @Throws(Throwable::class)
            override fun evaluate() {
                RxJavaPlugins.setInitIoSchedulerHandler { immediate }
                RxJavaPlugins.setInitComputationSchedulerHandler { immediate }
                RxJavaPlugins.setInitNewThreadSchedulerHandler { immediate }
                RxJavaPlugins.setInitSingleSchedulerHandler { immediate }
                RxAndroidPlugins.setInitMainThreadSchedulerHandler { immediate }

                try {
                    base.evaluate()
                } finally {
                    RxJavaPlugins.reset()
                    RxAndroidPlugins.reset()
                }
            }
        }
    }

}

即时执行器扩展.class

class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {

    override fun beforeEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) {
                runnable.run()
            }

            override fun postToMainThread(runnable: Runnable) {
                runnable.run()
            }

            override fun isMainThread(): Boolean {
                return true
            }
        })
    }

    override fun afterEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(null)
    }

}
Android junit mockito android-livedata android-viewmodel

评论

0赞 second 6/12/2019
你能添加代码吗?与其使用 ArgumentMatcher,不如使用 ArgumentCaptor 并比较 Resource.loading 返回的实际内容。liveData.observeForever
0赞 Sanlok Lee 6/13/2019
在存储库中,在 io 调度器上订阅,这意味着 Flowable 不会在同一线程上同步发送通知。因此,请检查您是否正确配置了测试调度程序。apiService.fetchDiscoverMovies()
0赞 Falling Into Infinity 6/13/2019
@SanlokLee 良好的观察力。我更改了线程,但无法让 LiveData 与 Rx 一起使用。请查看我更新的问题。
0赞 Falling Into Infinity 6/13/2019
@second observeForever 是 androidx.lifecycle 中的内置方法
0赞 azizbekian 6/13/2019
你能在github上发布一个带有该设置的最小项目吗?我去看看。

答:

4赞 azizbekian 6/14/2019 #1

您指定的方式不适用于 JUnit5。如果在方法中放置断点,则会看到它未被执行。RxImmediateSchedulerRuleapply()

相反,应创建如下所示的扩展:


    class TestSchedulerExtension : BeforeTestExecutionCallback, AfterTestExecutionCallback {

        override fun beforeTestExecution(context: ExtensionContext?) {
            RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
            RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() }
            RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
            RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
        }

        override fun afterTestExecution(context: ExtensionContext?) {
            RxJavaPlugins.reset()
            RxAndroidPlugins.reset()
        }

    }

然后在测试类的注解中应用如下:TestSchedulerExtension


    @ExtendWith(value = [InstantExecutorExtension::class, TestSchedulerExtension::class])
    class MainViewModelTest {

        private val apiService: ApiService = mock()
        private lateinit var movieRepository: MovieRepository
        private lateinit var viewModel: MainViewModel

        @BeforeEach
        fun init() {
            movieRepository = MovieRepository(apiService)
            viewModel = MainViewModel(movieRepository)
        }

        @Test
        fun loadMovieListFromNetwork() {
            val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10, 0, "", false)
            val call: Flowable = Flowable.just(mockResponse)
            whenever(apiService.fetchDiscoverMovies(1)).thenReturn(call)

            viewModel.fetchDiscoverMovies(1)

            assertEquals(Resource.success(mockResponse), LiveDataTestUtil.getValue(viewModel.discoverMovieLiveData))
        }

    }

现在测试将通过。现在您已经测试了,该观察者已使用预期值进行调度。


从另一个角度来看:这是单元测试吗?当然不是,因为在这个测试中,我们正在与 2 个单元进行交互:和 .这更符合“集成测试”的术语。如果你模拟了,那么这将是一个有效的单元测试:MainViewModelMovieRepositoryMoviesRepository


@ExtendWith(value = [InstantExecutorExtension::class, TestSchedulerExtension::class])
class MainViewModelTest {

    private val movieRepository: MovieRepository = mock()
    private val viewModel = MainViewModel(movieRepository)

    @Test
    fun loadMovieListFromNetwork() {
        val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10, 0, "", false)
        val liveData =
            MutableLiveData>().apply { value = Resource.success(mockResponse) }
        whenever(movieRepository.fetchDiscoverMovies(1)).thenReturn(liveData)

        viewModel.fetchDiscoverMovies(1)

        assertEquals(Resource.success(mockResponse), getValue(viewModel.discoverMovieLiveData))
    }

}

请注意,应声明为 along with,以便能够模拟它。或者,您可以考虑使用 kotlin-allopen 插件。MovieRepositoryopenfetchDiscoverMovies()

评论

0赞 Falling Into Infinity 6/14/2019
多谢。在这一点上,我很想知道 1.在单元测试中,如果我们模拟 “movieRepository”,则不会与 apiService 进行交互。可以吗?2. 在编写交互测试的同时编写单元测试的最佳实践是什么?我应该为每个编写 2 个测试函数吗?还是应该将它们放在不同的目录中?
0赞 azizbekian 6/14/2019
1. 单元测试是指对单元进行测试。在您的例子中,您要在类中测试的单元,这意味着任何其他依赖项都应该存根。在你的情况下,甚至不知道类的存在,所以与 .交互是错误的(如果你用单元测试来覆盖)。ViewModelViewModelApiServiceApiServiceApiServiceViewModel
0赞 azizbekian 6/14/2019
2. 我不确定你说的“互动”是什么意思。也许你的意思是“整合”。集成测试意味着同时测试多个组件。例如,执行操作会导致 中的值保存在 中,然后验证是否确实保留了该值。在您的案例中,有一个 API 层,这意味着您可以查看改造模拟库。fooSharedPrefsSharedPrefs
0赞 Falling Into Infinity 6/15/2019
现在一切都清楚了。我是否应该将单元测试与集成类一起放在 /test 文件夹中?有什么约定吗?
1赞 Nelson 9/5/2020
这个答案很棒,但是,我试过了,它对我不起作用。我添加了:在方法中,它现在正在工作。@azizbekian您指定的链接(zaglab.io)已过期,但是,您的回答有很大帮助,谢谢。RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }beforeTestExecution(context: ExtensionContext?)
4赞 mitch 6/14/2019 #2

我认为你需要做的就是改变

val call: Flowable<DiscoverMovieResponse> = successCall(mockResponse)

val call: Flowable<DiscoverMovieResponse> = Flowable.just(mockResponse)

并利用架构组件 google 示例中的 LiveDataUtil。因此,您需要将其复制/粘贴到您的项目中。

因此,在一天结束时,您的新测试将如下所示(假设所有关联和模拟都正确设置在测试类的顶部)。此外,您正在使用 InstantExecutorExtension,就像上面向您展示的 azizbekian 一样。

@Test
fun loadMovieListFromNetwork() {
    val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10)
    val call: Flowable<DiscoverMovieResponse> = Flowable.just(mockResponse)
    whenever(apiService.fetchDiscoverMovies(1)).thenReturn(call)

    viewModel.fetchDiscoverMovies(1)

    assertEquals(Resource.success(mockResponse), LiveDataTestUtil.getValue(viewModel.discoverMovieLiveData))
}

如果该测试通过,则意味着您能够成功观察网络请求的结果并返回成功的响应。