تدفق اختبار 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 التي تجمعها لا تعود أبدًا. جارٍ بدء جمع الكوروتين في 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's في 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.WhileSubsribed بشكل متكرّر. في ViewModels.

حتى إذا كنت تؤكد على 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)
}

مصادر إضافية