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:
Để 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ả:
Để 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:
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.Lazily
và SharingStarted.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
- Kiểm thử coroutine của Kotlin trên Android
- Luồng của Kotlin trên Android
StateFlow
vàSharedFlow
- Tài nguyên khác về coroutine và luồng trong Kotlin