تدفق اختبار Kotlin على نظام Android

تعتمد طريقة اختبار الوحدات أو الوحدات النمطية التي تتواصل مع المسار على ما إذا كان الموضوع الذي يتم اختباره يستخدم المسار كمدخل أو مخرج.

  • إذا لاحظ الموضوع الخاضع للاختبار تدفّقًا، يمكنك إنشاء تدفّقات ضمن تبعيات زائفة يمكنك التحكّم فيها من الاختبارات.
  • إذا كانت الوحدة أو الوحدة النمطية تعرض عملية معالجة، يمكنك قراءة عنصر واحد أو عناصر متعددة تنشرها عملية المعالجة في الاختبار والتحقّق منها.

إنشاء منتج مزيّف

عندما يكون الموضوع الذي يتم اختباره مستهلكًا لأحد المسارات، من الطرق الشائعة لاختباره استبدال المُنشئ بتنفيذ مزيّف. على سبيل المثال، في حال توفّر ملف برمجي يرصد مستودعًا يستخرج البيانات من مصدرَين للبيانات في مرحلة النشر:

الموضوع الذي يتم اختباره وطبقة البيانات
الشكل 1. الموضوع الذي يتم اختباره وأحد ملفّات ملفّات البيانات

لجعل الاختبار حتميًا، يمكنك استبدال المستودع ومكوّناته التابعة بمستودع مزيّف يُصدر دائمًا البيانات المزيّفة نفسها:

يتم استبدال التبعيات بتنفيذ زائف.
الشكل 2. يتم استبدال التبعيات بتطبيق زائف.

لعرض سلسلة قيم محدّدة مسبقًا في مسار، استخدِم أداة إنشاء 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
    ...
}

والآن بعد أن أصبح بإمكانك التحكّم في نتائج الموضوع الذي يتم اختباره، يمكنك التأكّد من أنّه يعمل بشكل صحيح من خلال التحقّق من نتائجه.

تأكيد انبعاثات التدفق في اختبار

إذا كان الموضوع الذي يتم اختباره يعرض عملية تدفق، يجب أن يُجري الاختبار تأكيدات على عناصر مصدر البيانات.

لنفترض أنّ مستودع المثال السابق يعرِض عملية:

مستودع يتضمّن تبعيات زائفة تؤدي إلى تعريض عملية تدفق
الشكل 3. مستودع (الموضوع الذي يتم اختباره) يحتوي على ملف تعريف اعتماد زائف يعرِض عملية.

في بعض الاختبارات، لن تحتاج سوى إلى التحقّق من البث الأول أو عدد محدود من العناصر الواردة من عملية البث.

يمكنك استخدام أول بث في البث من خلال استدعاء 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 لاختيار العناصر ونقلها. في ما يلي بعض الأمثلة:

// 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 call التي تجمعها. يؤدّي بدء دالة جمع البيانات في TestScope.backgroundScope إلى إلغاء دالة جمع البيانات قبل نهاية الاختبار. بخلاف ذلك، سيستمر runTest في الانتظار إلى أن يكتمل الاختبار، ما يؤدي إلى إيقاف الاختبار عن الردّ وبالتالي تعذُّر إكماله.

لاحظ كيفية استخدام UnconfinedTestDispatcher لحلقة الاستدعاء المتعدّد المجمّعة هنا. يضمن ذلك بدء coroutine لجمع القيم بشكلٍ فوري وجاهزيته لتلقّي القيم بعد عرض 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 في اختبارك، عليك إنشاء أداة جمع. يمكن أن يكون هذا جامعًا فارغًا:

@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)
}

مصادر إضافية