Kiểm thử luồng Kotlin trên Android

Cách bạn kiểm thử các đơn vị hoặc mô-đun giao tiếp với luồng còn tùy thuộc vào việc chủ thể trong quá trình kiểm thử có sử dụng luồng làm đầu vào hoặc đầu ra hay không.

  • Nếu đối tượng kiểm thử quan sát thấy một luồng, bạn có thể tạo luồng trong các phần phụ thuộc giả mạo mà bạn có thể kiểm soát qua chương trình kiểm thử.
  • Nếu đơn vị hoặc mô-đun cho thấy một luồng, bạn có thể đọc và xác minh một hoặc nhiều mục do một luồng trong kiểm thử chuyển ra.

Tạo một nhà sản xuất giả mạo

Khi đối tượng kiểm thử là đối tượng tiêu thụ trong một luồng, có một cách phổ biến để kiểm thử là thay thế nhà sản xuất bằng một cách triển khai giả mạo. Ví dụ: trong một lớp nhất định quan sát kho lưu trữ lấy dữ liệu từ hai nguồn dữ liệu trong quá trình sản xuất:

đối tượng kiểm thử và lớp dữ liệu
Hình 1. Đối tượng kiểm thử và lớp dữ liệu.

Để chương trình kiểm thử có tính xác định, bạn có thể thay thế kho lưu trữ và các phần phụ thuộc của kho lưu trữ đó bằng một kho lưu trữ giả mạo, luôn chuyển phát cùng một dữ liệu giả mạo:

các phần phụ thuộc được thay thế bằng cách triển khai giả mạo
Hình 2. Các phần phụ thuộc được thay thế bằng cách triển khai giả mạo.

Để phát hành một chuỗi giá trị xác định trước trong một luồng, hãy sử dụng trình tạo flow:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

Trong kiểm thử, kho lưu trữ giả mạo này được chèn, thay thế cách triển khai thực tế:

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    ...
}

Giờ đây, khi đã có quyền kiểm soát đầu ra của chủ đề kiểm thử, bạn có thể xác minh đối tượng hoạt động chính xác hay không bằng cách kiểm tra đầu ra của đối tượng.

Nhận định việc truyền phát luồng trong kiểm thử

Nếu đối tượng kiểm thử đang cho thấy một luồng, thì kiểm thử đó cần phải đưa ra nhận định về những phần tử trong luồng dữ liệu.

Giả sử kho lưu trữ của ví dụ trước cho thấy một luồng:

kho lưu trữ có các phần phụ thuộc giả mạo cho thấy một luồng
Hình 3. Một kho lưu trữ (chủ thể kiểm thử) có các phần phụ thuộc giả mạo cho thấy một luồng.

Với một số thử nghiệm nhất định, bạn sẽ chỉ cần kiểm tra việc truyền phát đầu tiên hoặc một số mục có hạn từ luồng.

Bạn có thể tiêu thụ giá trị chuyển phát đầu tiên vào luồng bằng cách gọi first(). Hàm này chờ cho đến khi nhận được mục đầu tiên rồi gửi tín hiệu huỷ đến nhà sản xuất.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

Nếu chương trình kiểm thử cần kiểm tra nhiều giá trị, việc gọi toList() sẽ khiến luồng đợi nguồn chuyển phát tất cả giá trị rồi sau đó trả về các giá trị đó dưới dạng danh sách. Phương thức này chỉ dành cho luồng dữ liệu hữu hạn.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

Đối với các luồng dữ liệu đòi hỏi một bộ sưu tập mục phức tạp hơn hoặc không trả về số lượng mục hữu hạn, bạn có thể sử dụng API Flow để chọn và chuyển đổi các mục. Dưới đây là một số ví dụ:

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

Thu thập liên tục trong quá trình thử nghiệm

Việc thu thập luồng theo toList() như trong ví dụ trước sẽ sử dụng collect() nội bộ và tạm ngưng cho đến khi toàn bộ danh sách kết quả sẵn sàng được trả về.

Để xen kẽ các hành động khiến luồng phát ra giá trị và xác nhận trên những giá trị được phát ra, bạn có thể liên tục thu thập các giá trị của một luồng trong quá trình thử nghiệm.

Ví dụ: lấy lớp Repository sau để kiểm tra và triển khai nguồn dữ liệu giả đi kèm có phương thức emit để tạo giá trị một cách linh động trong quá trình thử nghiệm:

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

Khi sử dụng giá trị giả này trong thử nghiệm, bạn có thể tạo coroutine thu thập để có thể liên tục nhận được các giá trị từ Repository. Ở ví dụ này, chúng tôi sẽ thu thập thành một danh sách và sau đó xác nhận nội dung:

@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    val collectJob = launch(UnconfinedTestDispatcher()) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected

    collectJob.cancel()
}

Vì luồng mà Repository hiển thị ở đây không bao giờ hoàn tất, nên lệnh gọi toList đang thu thập dữ liệu sẽ không bao giờ quay về được. Do đó, coroutine thu thập cần bị hủy một cách rõ ràng trước khi kết thúc thử nghiệm. Nếu không, runTest sẽ tiếp tục chờ hoạt động hoàn tất khiến quá trình kiểm tra ngừng phản hồi và cuối cùng sẽ không đạt được kết quả.

Lưu ý cách UnconfinedTestDispatcher được sử dụng cho coroutine thu thập tại đây. Điều này giúp đảm bảo coroutine thu thập được khởi chạy ngay lập tức và nhanh chóng nhận các giá trị sau khi launch trả về.

Sử dụng tuabin

Thư viện Turbine của bên thứ ba cung cấp API thuận tiện cho việc tạo coroutine thu thập, cũng như các tính năng tiện lợi khác để thử nghiệm Luồng:

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

Vui lòng xem tài liệu của thư viện để biết thêm thông tin chi tiết.

Kiểm thử StateFlows

StateFlow là một trình lưu trữ dữ liệu có khả năng ghi nhận, có thể được thu thập để quan sát các giá trị mà nó lưu giữ theo thời gian dưới dạng luồng. Lưu ý luồng giá trị này kết hợp với nhau, tức là nếu các giá trị được đặt trong StateFlow nhanh chóng, thì trình thu thập của StateFlow đó không đảm bảo sẽ nhận được tất cả các giá trị trung gian, mà chỉ nhận các giá trị gần đây nhất.

Trong thử nghiệm, nếu bạn đã lưu ý đến sự kết hợp, bạn có thể thu thập các giá trị của StateFlow cũng như thu thập bất kỳ luồng nào khác, bao gồm cả Tuabin. Bạn có thể thử thu thập và xác nhận tất cả các giá trị trung gian như mong muốn trong một số trường hợp thử nghiệm.

Tuy nhiên, bạn nên sử dụng StateFlow làm chủ sở hữu dữ liệu và xác nhận quyền sở hữu trên tài sản value. Bằng cách này, thử nghiệm sẽ xác thực trạng thái hiện tại của đối tượng tại thời điểm nhất định và không phụ thuộc vào việc có nên xác nhận hay không.

Ví dụ: hãy dùng ViewModel này để thu thập các giá trị từ Repository và hiển thị lên giao diện người dùng trong StateFlow:

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

Cách triển khai giả cho Repository này sẽ có dạng như sau:

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

Khi kiểm tra ViewModel bằng giá trị giả này, bạn có thể truyền phát các giá trị từ giả mạo đến kích hoạt cập nhật trong StateFlow của ViewModel, sau đó xác nhận giá trị value đã cập nhật:

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

Làm việc với StateFlows do stateIn tạo

Ở phần trước, ViewModel sử dụng MutableStateFlow để lưu trữ giá trị mới nhất do một luồng từ Repository phát ra. Đây là một mẫu phổ biến, thường được triển khai theo cách đơn giản hơn bằng cách sử dụng toán tử stateIn. Toán tử này sẽ chuyển đổi luồng lạnh thành StateFlow nóng:

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

Toán tử stateIn có tham số SharingStarted. Tham số này sẽ xác định thời điểm bắt đầu hoạt động và sử dụng luồng cơ bản. Các tùy chọn như SharingStarted.LazilySharingStarted.WhileSubsribed thường được dùng trong ViewModel.

Ngay cả khi bạn đang xác nhận value của StateFlow trong thử nghiệm, bạn vẫn cần phải tạo một bộ thu thập. Đây có thể là một trình thu thập trống:

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.score.collect() }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)

    // Cancel the collecting job at the end of the test
    collectJob.cancel()
}

Additional resources