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

تعتمد الطريقة التي تختبر بها الوحدات أو الوحدات التي تتواصل مع 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 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)
}

مراجع إضافية