Kiểm thử coroutine của Kotlin trên Android

Sử dụng bộ sưu tập để sắp xếp ngăn nắp các trang Lưu và phân loại nội dung dựa trên lựa chọn ưu tiên của bạn.

Mã kiểm thử đơn vị sử dụng coroutine yêu cầu cần được chú trọng hơn, vì quá trình thực thi có thể không đồng bộ và xảy ra trên nhiều luồng. Hướng dẫn này trình bày cách kiểm thử hàm tạm ngưng, các cấu trúc kiểm thử bạn cần phải làm quen và cách làm cho mã sử dụng coroutine có thể kiểm thử được.

Các API được dùng trong hướng dẫn này đều thuộc thư viện kotlinx.coroutines.test. Hãy nhớ thêm cấu phần phần mềm làm phần phụ thuộc kiểm thử cho dự án để có quyền truy cập vào các API này.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

Gọi hàm tạm ngưng trong kiểm thử

Để gọi các hàm tạm ngưng trong kiểm thử, bạn cần phải ở trong coroutine. Vì bản thân hàm kiểm thử JUnit không phải là hàm tạm ngưng, nên bạn cần gọi một trình tạo coroutine bên trong kiểm thử để bắt đầu một coroutine mới.

runTest là một trình tạo coroutine được thiết kế để kiểm thử. Dùng phương thức này để gói bất kỳ kiểm thử nào bao gồm coroutine. Vui lòng lưu ý coroutine không chỉ bắt đầu trực tiếp trong phần nội dung kiểm thử mà còn do các đối tượng dùng trong kiểm thử.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

Nói chung, bạn phải gọi lệnh runTest cho mỗi hoạt động kiểm thử và nên dùng một nội dung biểu thức.

Gói mã kiểm thử trong runTest sẽ hoạt động để kiểm thử các hàm tạm ngưng cơ bản và tự động bỏ qua mọi độ trễ trong coroutine, giúp kiểm thử ở trên hoàn tất nhanh hơn một giây.

Tuy nhiên, cần xem xét một vài yếu tố , tùy thuộc vào tình huống xảy ra trong mã đang được kiểm thử:

  • Khi mã tạo coroutine mới ngoài coroutine kiểm thử cấp cao nhất mà runTest tạo, bạn sẽ cần kiểm soát cách các coroutine mới được lên lịch biểu bằng cách chọn TestDispatcher thích hợp.
  • Nếu mã của bạn chuyển quá trình thực thi coroutine sang bộ điều phối khác (ví dụ: bằng cách sử dụng withContext), thì runTest thường vẫn hoạt động nhưng độ trễ sẽ không bị bỏ qua nữa và các kiểm thử sẽ khó dự đoán hơn khi mã chạy trên nhiều chuỗi. Vì lý do này, bạn nên chèn bộ điều phối trong các kiểm thử để thay thế bộ điều phối thực.

Bộ điều phối kiểm thử

TestDispatchers là các phương thức triển khai CoroutineDispatcher cho mục đích kiểm thử. Bạn cần phải sử dụng TestDispatchers nếu các coroutine mới được tạo trong quá trình kiểm thử để có thể dự đoán các coroutine mới.

Có hai cách triển khai TestDispatcher: StandardTestDispatcherUnconfinedTestDispatcher hai chức năng này thực hiện lịch biểu khác nhau đối với những coroutine mới bắt đầu. Cả hai đều sử dụng TestCoroutineScheduler để kiểm soát thời gian ảo và quản lý các coroutine đang chạy trong một kiểm thử.

Chỉ nên có một thực thể trình lập lịch biểu trong kiểm thử, dùng chung giữa tất cả TestDispatchers. Xem phần Chèn bộ điều phối kiểm thử để tìm hiểu về cách chia sẻ trình lập lịch biểu.

Để bắt đầu coroutine kiểm thử cấp cao nhất, runTest sẽ tạo một TestScope, là hoạt động triển khai CoroutineScope và sẽ luôn sử dụng TestDispatcher. Nếu không được chỉ định, TestScope sẽ tạo một StandardTestDispatcher theo mặc định và sử dụng giá trị này để chạy coroutine kiểm thử cấp cao nhất.

runTest theo dõi các coroutine đã được đưa vào hàng đợi trên trình lập lịch biểu được sử dụng bởi bộ điều phối của TestScope và sẽ không trả về nếu nội dung đó có tác vụ đang chờ xử lý trên trình lập lịch biểu đó.

Bộ điều phối kiểm thử tiêu chuẩn

Khi bạn bắt đầu coroutine mới trên StandardTestDispatcher, chúng sẽ được đưa vào hàng đợi trên trình lập lịch biểu cơ bản để chạy bất cứ khi nào luồng kiểm thử sẵn sàng sử dụng. Để cho phép các coroutine mới này chạy, bạn cần phải tạo chuỗi kiểm thử (giải phóng nó để các coroutine khác sử dụng). Hành vi xếp hàng này cho phép bạn kiểm soát chính xác cách các coroutine mới chạy trong quá trình kiểm thử, và cũng giống như việc lập lịch biểu của các coroutine trong mã sản xuất.

Nếu luồng kiểm thử không bao giờ được tạo ra trong quá trình thực thi coroutine kiểm thử cấp cao nhất, thì mọi coroutine mới sẽ chỉ chạy sau khi coroutine kiểm thử hoàn tất (nhưng trước khi runTest trả về):

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Có một số cách tạo coroutine kiểm thử để các coroutine trong danh sách chờ chạy. Tất cả các lệnh gọi này cho phép coroutine khác chạy trên luồng kiểm thử trước khi quay lại:

  • advanceUntilIdle: Chạy tất cả các coroutine khác trên trình lập lịch biểu cho đến khi không còn gì trong hàng đợi. Đây là một lựa chọn mặc định tốt để chạy tất cả coroutine đang chờ xử lý và nó sẽ hoạt động trong hầu hết các trường hợp kiểm thử.
  • advanceTimeBy: Tăng thời gian ảo theo thông số cho trước và chạy mọi coroutine được lên lịch biểu trước khoảng đó trong thời gian ảo.
  • runCurrent: Chạy coroutine được lên lịch biểu vào thời gian ảo hiện tại.

Để khắc phục kiểm thử trước đó, bạn có thể dùng advanceUntilIdle để cho phép hai coroutine đang chờ xử lý thực hiện nhiệm vụ của nó trước khi tiếp tục xác nhận:

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

Bộ điều phối kiểm thử tự do

Khi coroutine mới được bắt đầu trên UnconfinedTestDispatcher, chúng sẽ bắt đầu một cách mượt mà trên luồng hiện tại. Điều này có nghĩa là các biến sẽ bắt đầu chạy ngay lập tức không cần chờ trình tạo coroutine trả về. Trong nhiều trường hợp, hành vi điều phối này dẫn đến mã kiểm thử đơn giản hơn, vì bạn không cần phải tạo luồng kiểm thử theo cách thủ công để cho phép coroutine mới chạy.

Tuy nhiên, hành vi này khác với những gì bạn sẽ thấy trong phiên bản chính thức với bộ điều phối không kiểm thử. Hãy ưu tiên sử dụng StandardTestDispatcher nếu kiểm thử của bạn tập trung vào tính đồng thời.

Để sử dụng bộ điều phối này cho coroutine kiểm thử cấp cao nhất trong runTest thay vì bộ mặc định, hãy tạo một thực thể và chuyển nội dung đó dưới dạng thông số. Việc này giúp các coroutine mới được tạo trong runTest thực thi một cách nghiêm ngặt vì chúng kế thừa bộ điều phối từ TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

Ở ví dụ này, các lệnh gọi phát hành sẽ bắt đầu coroutine mới một cách mượt mà trên UnconfinedTestDispatcher, tức là mỗi lệnh gọi để chạy sẽ chỉ trả về sau khi đăng ký hoàn tất.

Hãy nhớ UnconfinedTestDispatcher chạy coroutine mới ngay lập tức, nhưng không có nghĩa là điều này khiến hoạt động chạy hoàn tất nhanh chóng. Nếu coroutine mới tạm ngưng, các coroutine khác sẽ tiếp tục thực thi.

Ví dụ: coroutine mới được chạy trong kiểm thử này sẽ đăng ký Alice, nhưng sau đó coroutine sẽ tạm ngưng khi delay được gọi. Việc này cho phép coroutine cấp cao nhất tiếp tục xác nhận và kiểm thử sẽ không thành công khi Bob chưa được đăng ký:

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

Chèn bộ điều phối kiểm thử

Mã đang được kiểm thử có thể sử dụng bộ điều phối để chuyển đổi luồng (sử dụng withContext) hoặc để bắt đầu coroutine mới. Khi mã được thực thi trên nhiều luồng song song, các kiểm thử có thể trở nên không ổn định. Bạn có thể sẽ gặp khó khăn khi cần xác nhận đúng thời điểm hoặc để hoàn thành các thao tác nếu những thao tác đó đang chạy trên các luồng trong nền bạn không kiểm soát được.

Trong kiểm thử, hãy thay thế những bộ điều phối này bằng các thực thể của TestDispatchers. Việc này có một số lợi ích:

  • Mã này sẽ chạy trên một luồng kiểm thử đơn, làm cho các kiểm thử có tính quyết định hơn
  • Bạn có thể kiểm soát cách các coroutine mới được lập lịch biểu và thực thi
  • Bộ điều phối kiểm thử sử dụng trình lập lịch biểu cho thời gian ảo, giúp tự động bỏ qua thời gian chờ và cho phép bạn tự lên lịch trước

Việc sử dụng tính năng chèn phần phụ thuộc để cung cấp bộ điều phối cho các lớp giúp bạn dễ dàng thay thế bộ điều phối thực trong kiểm thử.

Theo mặc định, TestDispatchers sẽ tạo một trình lập lịch biểu mới khi chúng được tạo bản sao. Bên trong runTest, bạn có thể truy cập vào thuộc tính testScheduler của TestScope và chuyển thuộc tính đó vào bất kỳ TestDispatchers nào mới được tạo. Thao tác này sẽ chia sẻ sự hiểu biết của nó về thời gian ảo và các phương thức như advanceUntilIdle sẽ chạy coroutine trên tất cả các bộ điều phối kiểm thử để hoàn tất.

Ở ví dụ sau, bạn có thể thấy lớp Repository tạo một coroutine mới bằng cách sử dụng bộ điều phối IO trong phương thức initialize của nó và chuyển phương thức gọi sang bộ điều phối IO trong phương thức fetchData:

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

Trong kiểm thử, bạn có thể chèn TestDispatcher triển khai để thay thế bộ điều phối IO.

Trong ví dụ bên dưới, chúng ta chèn StandardTestDispatcher vào kho lưu trữ và sử dụng advanceUntilIdle để đảm bảo coroutine mới bắt đầu trong initialize hoàn tất trước khi tiếp tục.

fetchData cũng sẽ được hưởng lợi từ việc chạy trên TestDispatcher, vì nó sẽ chạy trên luồng kiểm thử và bỏ qua độ trễ trong quá trình kiểm thử.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

Bạn có thể nâng cao các coroutine mới bắt đầu trong TestDispatcher theo cách thủ công như minh họa ở trên bằng initialize. Tuy nhiên, cần lưu ý điều này là không thể hoặc không mong muốn trong mã sản xuất. Thay vào đó, phương thức này phải được thiết kế lại để tạm ngưng (để thực thi theo tuần tự) hoặc trả về một giá trị Deferred (để thực thi đồng thời).

Ví dụ: bạn có thể dùng async để bắt đầu một coroutine mới và tạo một Deferred:

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

Cách này giúp bạn await có thể hoàn tất mã này một cách an toàn trong cả mã kiểm thử và sản xuất:

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest sẽ đợi các coroutine đang chờ hoàn tất trước khi quay lại nếu coroutine trên TestDispatcher mà hệ thống này dùng chung trình lập lịch biểu. Nó cũng sẽ đợi các coroutine là phần tử con của coroutine kiểm thử cấp cao nhất, ngay cả khi chúng thuộc bộ điều phối khác (tối đa thời gian chờ chỉ định bởi thông số dispatchTimeoutMs, là 60 giây theo mặc định).

Đặt bộ điều phối Chính

Trong các kiểm thử đơn vị cục bộ, bộ điều phối Main gói luồng giao diện người dùng Android sẽ không khả dụng, vì những kiểm thử này được thực thi trên một máy JVM cục bộ chứ không phải một thiết bị Android. Nếu mã của bạn đang được kiểm thử tham chiếu chuỗi chính, thì mã sẽ gửi một ngoại lệ trong quá trình kiểm thử đơn vị.

Trong một số trường hợp, bạn có thể chèn bộ điều phối Main theo cách chèn các bộ điều phối khác, như được mô tả trong phần trước, cho phép bạn thay thế nó bằng TestDispatcher trong các kiểm thử. Tuy nhiên, một số API như viewModelScope sẽ dùng bộ điều phối Main được mã hóa cứng.

Dưới đây là ví dụ về cách triển khai ViewModel sử dụng viewModelScope để chạy một coroutine tải dữ liệu:

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

Để thay thế bộ điều phối Main bằng TestDispatcher trong mọi trường hợp, hãy sử dụng các hàm Dispatchers.setMainDispatchers.resetMain.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

NếuMain đã được thay thế bằngTestDispatcher, bất kỳTestDispatchers mới tạo sẽ tự động sử dụng trình lập lịch biểu từ Main bộ điều phối, bao gồmStandardTestDispatcher được tạo bởirunTest nếu không có bộ điều phối nào khác được chuyển cho nó.

Nhờ vậy, bạn có thể dễ dàng đảm bảo chỉ có một trình lập lịch biểu được sử dụng trong quá trình kiểm thử. Để chế độ này hoạt động, bạn hãy nhớ tạo tất cả các bản sao TestDispatcher khác sau khi gọi Dispatchers.setMain.

Một mẫu chung để tránh lặp lại mã sẽ thay thế bộ điều phối Main trong mỗi kiểm thử là trích xuất mã đó vào quy tắc kiểm thử JUnit:

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

Quá trình triển khai quy tắc này sử dụng UnconfinedTestDispatcher theo mặc định, nhưng hệ thống có thể chuyển StandardTestDispatcher dưới dạng thông số nếu bộ điều phối Main không thực thi một cách nhanh chóng trong lớp kiểm thử nhất định.

Khi cần một bản sao TestDispatcher trong phần nội dung kiểm thử, bạn có thể sử dụng lại testDispatcher từ quy tắc, miễn đó là loại mong muốn. Nếu muốn thông báo rõ ràng về loại TestDispatcher được sử dụng trong kiểm thử, hoặc nếu bạn cần một TestDispatcher khác với loại được sử dụng choMain, bạn có thể tạoTestDispatcher trongrunTest. Khi bộ điều phối Main được đặt thành TestDispatcher, mọi TestDispatchers mới tạo sẽ tự động chia sẻ trình lập lịch biểu.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

Tạo bộ điều phối ngoài một kiểm thử

Trong một số trường hợp, bạn có thể cần phải có một TestDispatcher ngoài phương pháp kiểm thử. Ví dụ: trong quá trình khởi tạo thuộc tính trong lớp kiểm thử:

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
    }
}

Nếu bạn đang thay thế bộ điều phối Main như minh họa ở phần trước, thì TestDispatchers được tạo sau khi bộ điều phối Main đã được thay thế sẽ tự động chia sẻ trình lập lịch biểu của bộ điều phối đó.

Tuy nhiên, TestDispatchers sẽ không được tạo dưới dạng thuộc tính của lớp kiểm thử hoặc TestDispatchers được tạo trong quá trình khởi tạo thuộc tính trong lớp kiểm thử. Các toán tử này được khởi tạo trước khi bộ điều phối Main được thay thế. Do đó, chúng sẽ tạo các trình lập lịch biểu mới.

Để đảm bảo chỉ có một trình lập lịch biểu trong kiểm thử, trước tiên, hãy tạo thuộc tính MainDispatcherRule. Sau đó, hãy dùng lại bộ điều phối (hoặc trình lập lịch biểu bạn nếu cần một TestDispatcher của loại khác) trong trình khởi tạo của các thuộc tính cấp lớp khác khi cần.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

Vui lòng lưu ý cả runTestTestDispatchers được tạo trong kiểm thử này vẫn sẽ tự động chia sẻ trình lập lịch biểu của bộ điều phối Main.

Nếu bạn không thay thế bộ điều phối Main, hãy tạo TestDispatcher đầu tiên (trình lập lịch biểu mới) làm thuộc tính của lớp. Sau đó, chuyển trình lập lịch biểu đó theo cách thủ công sang từng lệnh gọi runTest và mỗi TestDispatcher mới được tạo, cả dưới dạng thuộc tính và trong kiểm thử:

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

Ở ví dụ này, trình lập lịch biểu từ bộ điều phối đầu tiên được chuyển cho runTest. Thao tác này sẽ tạo một StandardTestDispatcher mới cho TestScope bằng cách sử dụng trình lập lịch biểu đó. Bạn cũng có thể chuyển trực tiếp bộ điều phối cho runTest để chạy coroutine kiểm thử trên bộ điều phối đó.

Tạo phạm vi kiểm thử của riêng bạn

Giống như với TestDispatchers, bạn có thể cần phải truy cập vào TestScope ngoài nội dung kiểm thử. Mặc dù runTest sẽ tự động tạo một TestScope trong kho lưu trữ nội dung, nhưng bạn cũng có thể tạo TestScope của riêng mình để sử dụng với runTest.

Khi thực hiện việc này, hãy nhớ gọi runTest trên TestScope bạn đã tạo:

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Đoạn mã ở trên tạo một StandardTestDispatcher cho TestScope, cũng như một trình lập lịch biểu mới. Bạn cũng có thể tạo các đối tượng này một cách rõ ràng. Việc đó hữu ích nếu bạn cần tích hợp tính năng này với thiết lập chèn phần phụ thuộc.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

Tài nguyên khác