تعتمد الطريقة التي تختبر بها الوحدات أو الوحدات التي تتواصل مع flow على ما إذا كان الشخص الذي يخضع للاختبار يستخدم التدفق كإدخال أو إخراج.
- إذا لاحظ الشخص الذي يخضع للاختبار وجود تدفق، يمكنك إنشاء تدفقات ضمن تبعيات زائفة يمكنك التحكم فيها من خلال الاختبارات.
- إذا كانت الوحدة أو الوحدة تعرض تدفقًا، يمكنك قراءة والتحقق من عنصر واحد أو عدة عناصر ينبعث منها تدفق في الاختبار.
إنشاء منتج مزيّف
عندما يكون الموضوع محل الاختبار مستهلكًا لتدفق ما، فإن إحدى الطرق الشائعة لاختباره هي استبدال المنتج بتطبيق مزيف. على سبيل المثال، وفقًا لفئة تراقب مستودعًا تأخذ البيانات من مصدري بيانات في مرحلة الإنتاج:
لجعل الاختبار مؤكدًا، يمكنك استبدال المستودع وتبعياته بمستودع مزيف يصدر دائمًا نفس البيانات المزيفة:
لإرسال سلسلة من القيم المحدّدة مسبقًا في أحد التدفق، استخدِم أداة إنشاء flow
:
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
وفي الاختبار، يتم إدخال هذا المستودع الزائف بدلاً من التطبيق الفعلي:
@Test
fun myTest() {
// Given a class with fake dependencies:
val sut = MyUnitUnderTest(MyFakeRepository())
// Trigger and verify
...
}
الآن بعد أن أصبحت لديك إمكانية التحكم في مخرجات الموضوع الذي يخضع للاختبار، يمكنك التحقق من أنه يعمل بشكل صحيح عن طريق التحقق من مخرجاته.
تأكيد انبعاثات التدفق في الاختبار
إذا كان الشخص الذي يخضع للاختبار يعرض تدفقًا، يحتاج الاختبار إلى تقديم تأكيدات على عناصر مصدر البيانات.
لنفترض أن مستودع المثال السابق يعرض تدفقًا:
في بعض الاختبارات، ستحتاج فقط إلى التحقّق من الانبعاثات الأولى أو على عدد محدود من السِلع الواردة من التدفق.
يمكنك استهلاك أول انبعاثات من التدفق من خلال طلب الرقم first()
. وتنتظر هذه الدالة حتى يتم استلام العنصر الأول ثم ترسل إشارة الإلغاء إلى المنتج.
@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)
}
إذا كان الاختبار بحاجة إلى التحقق من قيم متعددة، سيؤدي استدعاء toList()
إلى انتظار التدفق حتى يصدر المصدر جميع قيمه، ثم عرض هذه القيم كقائمة. ولا تعمل هذه الطريقة إلا مع مصادر البيانات المحدودة.
@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)
}
بالنسبة إلى مصادر البيانات التي تتطلّب مجموعة أكثر تعقيدًا من العناصر أو لا تعرض
عددًا محدودًا من العناصر، يمكنك استخدام Flow
API لاختيار العناصر
وتحويلها. وفي ما يلي بعض الأمثلة:
// 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)
الجمع المستمر أثناء الاختبار
جمع التدفق باستخدام toList()
كما يتضح في المثال السابق يستخدم collect()
داخليًا، ويتم تعليقه إلى أن تصبح قائمة النتائج كاملةً جاهزة للعرض.
لتداخل الإجراءات التي تؤدي إلى إصدار قيم وتأكيدات على القيم التي صدر منها التدفق، يمكنك جمع القيم باستمرار من التدفق أثناء الاختبار.
على سبيل المثال، يمكنك اختبار الفئة Repository
التالية، بالإضافة إلى تنفيذ مصدر بيانات مزيّف يستخدم طريقة emit
لإنتاج القيم ديناميكيًا أثناء الاختبار:
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
}
عند استخدام هذا الزائف في اختبار، يمكنك إنشاء كوروتين جمع سيتلقّى القيم باستمرار من Repository
. في هذا المثال، نقوم بجمعها في قائمة
ثم إجراء تأكيدات على محتوياتها:
@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
}
بما أنّ التدفق الظاهر من خلال Repository
هنا لا يكتمل أبدًا، لا يتم إرجاع مكالمة toList
التي يتم جمعها. ويؤدي بدء جمع الكوروتين في
TestScope.backgroundScope
إلى ضمان إلغاء الكوروتين قبل نهاية الاختبار. وإلا، سيستمر runTest
في انتظار اكتماله، ما يتسبب في توقف الاختبار
وإخفاقه في النهاية.
لاحظ كيف يتم استخدام
UnconfinedTestDispatcher
لجمع الكوروتين هنا. يضمن ذلك إطلاق مجموعة الكوروتين بفارغ الصبر وأن يكون جاهزًا لتلقّي القيم بعد إرجاع launch
.
استخدام التوربينات
توفّر مكتبة Turbine التابعة لجهة خارجية واجهة برمجة تطبيقات ملائمة لإنشاء تجميع الكوروتين، بالإضافة إلى ميزات مريحة أخرى لاختبار المسارات:
@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())
}
}
راجِع مستندات المكتبة للحصول على مزيد من التفاصيل.
اختبار StateFlows
StateFlow
هي مصدر بيانات يمكن ملاحظته، ويمكن جمعه لملاحظة القيم التي يحتفظ بها بمرور الوقت كمصدر بيانات. يُرجى العلم أنّه يتم دمج سلسلة القيم هذه، ما يعني أنّه في حال ضبط القيم بسرعة في StateFlow
، لن يكون بإمكان جامعي قيم StateFlow
هذه الحصول على جميع القيم الوسيطة، بل فقط القيمة الأحدث.
في الاختبارات، إذا وضعت في الاعتبار المزج، يمكنك جمع قيم StateFlow
لأنه يمكنك جمع أي تدفقات أخرى، بما في ذلك باستخدام Turbine. قد تكون محاولة جمع جميع القيم المتوسطة
وتأكيدها أمرًا مرغوبًا في بعض سيناريوهات الاختبار.
مع ذلك، ننصح بشكل عام بالتعامل مع StateFlow
كصاحب بيانات وتأكيد ملكية خاصية value
الخاصة به بدلاً من ذلك. بهذه الطريقة، تتحقق الاختبارات من الحالة الحالية للكائن في مرحلة زمنية معينة، ولا تعتمد على ما إذا كان
الخلط حدث أم لا.
على سبيل المثال، لنفترض أن ViewModel الذي يجمع القيم من Repository
ويعرضها على واجهة المستخدم في 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
}
}
}
}
قد يبدو التنفيذ الزائف للعنصر Repository
على النحو التالي:
class FakeRepository : MyRepository {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun scores(): Flow<Int> = flow
}
عند اختبار ViewModel مع هذا الزائف، يمكنك إصدار قيم من الزائفة
إلى تشغيل التحديثات في StateFlow
لـ ViewModel، ثم تأكيد التعديل على
value
:
@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
}
العمل مع StateFlows التي أنشأتها StateIn
في القسم السابق، يستخدم نموذج العرض MutableStateFlow
لتخزين أحدث قيمة صادرة من تدفق من Repository
. وهذا نمط شائع،
يتم تنفيذه عادةً بطريقة أبسط باستخدام
عامل التشغيل
stateIn
، الذي يحوِّل التدفق البارد إلى تدفق بارد في StateFlow
:
class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
val score: StateFlow<Int> = myRepository.scores()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}
يحتوي عامل التشغيل stateIn
على معلَمة SharingStarted
تحدد الوقت الذي يصبح فيه نشطًا ويبدأ في استهلاك التدفق الأساسي. تُستخدم خيارات مثل
SharingStarted.Lazily
وSharingStarted.WhileSubsribed
بشكل متكرر
في نماذج العرض.
حتى إذا كنت تؤكد value
لـ StateFlow
في الاختبار، ستحتاج إلى إنشاء أداة تجميع. قد يكون هذا جامعًا فارغًا:
@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)
}
مراجع إضافية
- اختبار الكوروتينات في لغة Kotlin على Android
- خطوات Kotlin على Android
StateFlow
وSharedFlow
- مراجع إضافية لتدفقات الكوروتينات في لغة Kotlin