インクリメンタルサーチなどでthrottole処理やdebounce処理が必要になる場合があります。(throttleとdebounceの違いは調べてみてください)

まずはdebounce処理をViewModelで再現します。インクリメンタルサーチで文字列が入力される度にfun search(query: String)が実行されると仮定します。

@HiltViewModel
class RulesViewModel @Inject constructor(
    private val dispatcher: CoroutineDispatcher,
    private val searchUseCase: SearchUseCase
) : ViewModel() {
    private val _results = MutableStateFlow<List<Result>>(listOf())
    val results = _results.asStateFlow()

    fun search(query: String) {
        viewModelScope.launch(context = dispatcher, start = CoroutineStart.LAZY) {
            _results.emit(searchUseCase.search(query))
        }
    }
}

このまま文字列を連続で入力するとfun search()が何度も連続で呼ばれてレスポンスが前後してしまったり、サーバ負荷が高くなったりしてしまうのでdebounce処理に変更します。

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val dispatcher: CoroutineDispatcher,
    private val searchUseCase: SearchUseCase
) : ViewModel() {
    private val _results = MutableStateFlow<List<Result>>(listOf())
    val results = _results.asStateFlow()

    private val debounceJob: Job? = null
    fun search(query: String) {
        debounceJob?.cancel()
        debounceJob = viewModelScope.launch(context = dispatcher, start = CoroutineStart.LAZY) {
            delay(500)
            _results.emit(searchUseCase.search(query))
        }
        debounceJob?.start()
    }
}

このように実装すると文字列入力後500ms以上経過すると検索が実行され、連続で文字列入力した場合は既存のJobがキャンセルされるので実行されなくなります。

またこれをテストコードにする場合は下記のようにします。

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class SearchViewModelSpec {
    val dispatcher = UnconfinedTestDispatcher()

    private val firstResults = listOf(Result())
    private val secondResults = listOf(Result(), Result())
    private val firstQuery = "firstQuery"
    private val secondQuery = "secondQuery"

    @MockK
    lateinit var searchUseCase: SearchUseCase

    private lateinit var viewModel: SearchViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(dispatcher)
        TestTaskExecutor.onBefore()
        MockKAnnotations.init(this, relaxed = true, relaxUnitFun = true)
        viewModel = SearchViewModel(
            Dispatchers.Main,
            searchUseCase
        )
    }

    @Test
    fun search_once() = runTest {
        coEvery {
            searchUseCase.execute(firstQuery)
        } returns flow { emit(firstResults) }
        
        viewModel.search(firstQuery)
        delay(600)
        
        viewModel.results.first().also { response ->
            assertThat(response).isEqualTo(firstResults)
        }

        coVerify(exactly = 1) { searchUseCase.search(firstQuery) }
        confirmVerified(searchUseCase)
    }

    @Test
    fun search_twice() = runTest {
        coEvery {
            searchUseCase.execute(firstQuery)
        } returns flow { emit(firstResults) }
        coEvery {
            searchUseCase.execute(secondQuery)
        } returns flow { emit(secondResults) }
        
        viewModel.search(firstQuery)
        viewModel.search(secondQuery)
        delay(600)
        
        viewModel.results.first().also { response ->
            assertThat(response).isEqualTo(secondResults)
        }

        coVerify(exactly = 0) { searchUseCase.search(firstQuery) }
        coVerify(exactly = 1) { searchUseCase.search(secondQuery) }
        confirmVerified(searchUseCase)
    }

    @After
    open fun tearDown() {
        Dispatchers.resetMain()
        TestTaskExecutor.onAfter()
        unmockkAll()
    }
}

coVerify(exactly = 0) { searchUseCase.search(firstQuery) } で最初の検索が実行されていないことでdebounce処理がちゃんと動いていることが確認できます。