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 (flow) còn tuỳ thuộc vào việc đối tượng 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à bạn có thể kiểm soát qua bài 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ả, luôn chuyển phát cùng một dữ liệu giả:

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ẽ những 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 kiểm thử.

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 nguồn dữ liệu giả này trong bài kiểm thử, bạn có thể tạo một 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>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        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
}

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 luồng này sẽ không bao giờ trả về. Việc bắt đầu thu thập coroutine trong TestScope.backgroundScope đảm bảo rằng coroutine này sẽ bị huỷ trước khi kết thúc kiểm thử. 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 thử 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 một 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ó thể ghi nhận được, 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 (stream). Hãy lưu ý rằng 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ả giá trị trung gian như mong muốn trong một số trường hợp kiểm thử.

Tuy nhiên, bạn nên sử dụng StateFlow làm nơi giữ dữ liệu và chuyển sang xác nhận trên thuộc tính value. Bằng cách này, chương trình kiểm thử sẽ xác thực trạng thái hiện tại của đối tượng tại một thời điểm nhất định và không phụ thuộc vào việc quá trình hợp nhất có diễn ra hay không.

Ví dụ: hãy dùng ViewModel này để thu thập các giá trị qua Repository rồi đưa 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 thử ViewModel bằng cách triển khai giả này, bạn có thể phát các giá trị từ cách triển khai giả để kích hoạt bản cập nhật trong StateFlow của ViewModel, sau đó xác nhận 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 tuỳ chọn như SharingStarted.LazilySharingStarted.WhileSubscribed thường được dùng trong mô hình thành phần hiển thị.

Ngay cả khi bạn đang xác nhận value của StateFlow khi kiểm thử, bạn vẫn cần phải tạo một trình 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
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        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)
}

Tài nguyên khác