تعتمد طريقة اختبار الوحدات أو الوحدات النمطية التي تتواصل مع تدفق على ما إذا كان العنصر الخاضع للاختبار يستخدم التدفق كمدخل أو كمخرج.
- إذا كان العنصر الخاضع للاختبار يراقب تدفقًا، يمكنك إنشاء تدفقات ضمن تبعيات وهمية يمكنك التحكّم فيها من الاختبارات.
- إذا كانت الوحدة أو الوحدة النمطية تعرض تدفقًا، يمكنك قراءة والتحقّق من عنصر واحد أو عدة عناصر صادرة من تدفق في الاختبار.
إنشاء منتج مزيّف
عندما يكون العنصر الخاضع للاختبار مستهلكًا لمسار، إحدى الطرق الشائعة لاختباره هي استبدال المنتج بتنفيذ زائف. على سبيل المثال، لنفترض أنّ لديك فئة تراقب مستودعًا يستمد البيانات من مصدرَي بيانات في بيئة الإنتاج:
لجعل الاختبار حتميًا، يمكنك استبدال المستودع وتبعياته بمستودع زائف يعرض دائمًا البيانات الزائفة نفسها:
لإصدار سلسلة محدّدة مسبقًا من القيم في تدفق، استخدِم أداة إنشاء 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 - مراجع إضافية حول الكوروتينات والتدفق في Kotlin