تعتمد طريقة اختبار الوحدات أو الوحدات النمطية التي تتواصل مع Flow على ما إذا كان العنصر الخاضع للاختبار يستخدم 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 myRepositoryTestList() = runTest { val repository = MyFakeRepository() // 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 لاختيار العناصر وتحويلها. وإليك بعض الأمثلة:
// 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. في هذا المثال، نجمع العناصر في قائمة ثم نتحقّق من صحة محتواها:
@OptIn(ExperimentalCoroutinesApi::class) @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
في روتين التجميع هنا. يضمن ذلك إطلاق روتين Collect
الفرعي بشكل سريع والاستعداد لتلقّي القيم بعد أن تعرض الدالة launch
القيمة التي تم إرجاعها.
استخدام Turbine
توفر مكتبة 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
في القسم السابق، تستخدم ViewModel 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.WhileSubscribed بشكل متكرّر في نماذج العرض.
حتى إذا كنت تؤكّد على value من StateFlow في اختبارك، عليك إنشاء أداة تجميع. يمكن أن يكون هذا جامعًا فارغًا:
@OptIn(ExperimentalCoroutinesApi::class) @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 - اختبار الروتينات المشتركة