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

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

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

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

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

العنصر الخاضع للاختبار وطبقة البيانات
الشكل 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 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)
}

مراجع إضافية