اختبار الكوروتينات في لغة Kotlin على Android

يتطلب رمز اختبار الوحدة الذي يستخدم coroutine بعض الاهتمام الإضافي، حيث يمكن أن يكون تنفيذه غير متزامن ويحدث عبر سلاسل محادثات متعددة. يتناول هذا الدليل كيفية اختبار دوال التعليق، وتركيبات الاختبار التي تحتاج إلى أن تكون على دراية بها، وكيفية جعل التعليمة البرمجية التي تستخدم الكوروتينات قابلة للاختبار.

تشكِّل واجهات برمجة التطبيقات المستخدَمة في هذا الدليل جزءًا من مكتبة kotlinx.coroutines.test. تأكَّد من إضافة العنصر كتبعية تجريبية لمشروعك حتى تتمكّن من الوصول إلى واجهات برمجة التطبيقات هذه.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

استدعاء وظائف التعليق في الاختبارات

لاستدعاء وظائف التعليق في الاختبارات، يجب أن تكون في الكوروتين. نظرًا لأن وظائف اختبار JUnit نفسها لا تقوم بتعليق الدوال، فأنت بحاجة إلى استدعاء أداة إنشاء الكوروتين داخل اختباراتك لبدء تشغيل كوروتين جديد.

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

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

وبشكل عام، يجب أن يكون لديك استدعاء واحد لـ runTest في كل اختبار، ويُنصح باستخدام نص إعلان.

يساعد التفاف رمز الاختبار في runTest على اختبار وظائف التعليق الأساسية، وسيتم تلقائيًا تخطي أي تأخيرات في الكوروتينات، ما يجعل الاختبار أعلاه أسرع بكثير من ثانية واحدة.

ومع ذلك، هناك اعتبارات إضافية يجب مراعاتها، بناءً على ما يحدث في الرمز البرمجي الذي يخضع له الاختبار:

  • عندما يُنشئ رمزك الكوروتينات الجديدة غير الكوروتين الاختباري ذي المستوى الأعلى الذي ينشئه runTest، ستحتاج إلى التحكّم في كيفية جدولة الكوروتينات الجديدة هذه عن طريق اختيار TestDispatcher المناسب.
  • إذا كان الرمز الخاص بك ينقل عملية تنفيذ الكوروتين إلى مُرسِلين آخرين (باستخدام withContext مثلاً)، سيستمر عمل runTest بشكلٍ عام، ولكن لن يتم تخطّي حالات التأخير، وسيكون من الصعب توقُّع الاختبارات عندما يتم تشغيل الرمز في سلاسل محادثات متعددة. ولهذه الأسباب، وفي الاختبارات، يجب إدخال مرسِلي الاختبار لاستبدال المُرسِلين الحقيقيين.

مرسِلو الاختبار

TestDispatchers هي عمليات تنفيذ CoroutineDispatcher لأغراض الاختبار. سيكون عليك استخدام TestDispatchers في حال إنشاء الكوروتينات الجديدة أثناء الاختبار لتسهيل تنفيذ الكوروتينات الجديدة.

يتوفّر نوعان من عمليات تنفيذ الترميز TestDispatcher: StandardTestDispatcher وUnconfinedTestDispatcher، واللذَين ينفِّذان جدولة مختلفة لعناصر الكوروتين التي تم بدؤها حديثًا. يستخدم كلاهما TestCoroutineScheduler للتحكّم في الوقت الافتراضي وإدارة تشغيل الكوروتينات داخل الاختبار.

يجب استخدام مثيل واحد فقط لأداة جدولة المهام في الاختبار، وتتم مشاركته بين جميع TestDispatchers. راجع Injecting TestDispatchers لمعرفة معلومات عن مشاركة أدوات جدولة المهام.

لبدء اختبار الكوروتين ذي المستوى الأعلى، ينشئ runTest TestScope، وهو تنفيذ CoroutineScope ستستخدم دائمًا TestDispatcher. إذا لم يتم تحديد ذلك، سينشئ TestScope StandardTestDispatcher تلقائيًا، ويستخدم ذلك لتشغيل الكوروتين من المستوى الأعلى.

يتتبّع runTest الكوروتينات المدرجة في قائمة الانتظار على نظام الجدولة الذي يستخدمه مرسل TestScope، ولن يعود ما دام هناك عمل في انتظار المراجعة على نظام الجدولة هذا.

شركة StandardTestDispatcher

عند بدء تشغيل الكوروتينات الجديدة على StandardTestDispatcher، يتم وضعها في قائمة الانتظار على نظام جدولة المهام الأساسي، ليتم تشغيلها عندما تكون سلسلة المحادثات التجريبية مجانية. للسماح بتشغيل هذه الكوروتينات الجديدة، عليك إرجاع سلسلة الاختبار (إخلاء مساحة كي تتمكن من استخدام الكوروتينات الأخرى). يمنحك سلوك وضع الانتظار هذا تحكمًا دقيقًا في كيفية تشغيل الكوروتينات الجديدة أثناء الاختبار، ويشبه جدولة الكوروتينات في التعليمات البرمجية للإنتاج.

إذا لم يتم عرض سلسلة الاختبار مطلقًا أثناء تنفيذ الكوروتين الاختباري العالي المستوى، لن يتم تشغيل أي كوروتيين جديد إلا بعد انتهاء اختبار الكوروتين (ولكن قبل عرض runTest):

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

وهناك عدة طرق لإخراج اختبار الكوروتين من أجل السماح بتشغيل الكوروتينات الموضوعة في قائمة الانتظار. تتيح كل هذه الطلبات استخدام الكوروتينات الأخرى في سلسلة الاختبار قبل عرضها:

  • advanceUntilIdle: لتشغيل جميع أنواع الكوروتين الأخرى على نظام جدولة المهام إلى أن ينتهي كل شيء في قائمة الانتظار. يُعد هذا اختيارًا افتراضيًا جيدًا للسماح بتشغيل جميع أنواع الكوروتين التي تنتظر المراجعة، ويعمل في معظم سيناريوهات الاختبار.
  • advanceTimeBy: يؤدي هذا الخيار إلى زيادة الوقت الافتراضي بمقدار الحدّ المحدّد وتشغيل أي عناصر كوروتيين تمت جدولتها للتشغيل قبل تلك النقطة في الوقت الافتراضي.
  • runCurrent: لتشغيل عناصر الكوروتين التي تمت جدولتها في الوقت الافتراضي الحالي

لإصلاح الاختبار السابق، يمكن استخدام advanceUntilIdle للسماح للكوروتيين المعلّقَين بأداء عملهما قبل المتابعة إلى خطوة التأكيد:

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

اختبار UnconfinedTestDispatcher

عند بدء تشغيل الكوروتينات الجديدة على UnconfinedTestDispatcher، يبدأون بفارغ الصبر في سلسلة المحادثات الحالية. هذا يعني أنه سيبدأ تشغيلها على الفور، دون انتظار عودة أداة إنشاء الكوروتين الخاصة بها. في كثير من الحالات، ينتج عن سلوك الإرسال هذا رمز اختبار أبسط، حيث لا تحتاج إلى عرض سلسلة الاختبار يدويًا للسماح بتشغيل الكوروتينات الجديدة.

ومع ذلك، يختلف هذا السلوك عما ستلاحظه في مرحلة الإنتاج لدى المستخدمين غير التابعين للاختبار. إذا كان الاختبار يركز على التزامن، يُفضَّل استخدام StandardTestDispatcher بدلاً من ذلك.

لاستخدام أداة الإرسال هذه للكوروتين الاختباري من المستوى الأعلى في runTest بدلاً من المرسِل التلقائي، يمكنك إنشاء مثيل وتمريره كمَعلمة. سيؤدي هذا الإجراء إلى تنفيذ عناصر الكوروتين الجديدة التي تم إنشاؤها داخل runTest في الحال، لأنّها تكتسب المرسِل من TestScope.

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

في هذا المثال، ستبدأ عمليات الإطلاق في الكوروتينات الجديدة الخاصة بهم بلهفة على UnconfinedTestDispatcher، ما يعني أنّ كل مكالمة للبدء لن تعود إلا بعد اكتمال التسجيل.

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

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

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

حقن مرسلي الاختبارات

في حال كان الرمز قيد الاختبار، قد يتم استخدام المُرسِلين لتبديل سلاسل المحادثات (باستخدام withContext) أو بدء الكوروتينات الجديدة. عندما يتم تنفيذ الرمز على سلاسل تعليمات متعددة بالتوازي، يمكن أن تصبح الاختبارات غير مستقرة. قد يكون من الصعب تنفيذ التأكيدات في الوقت الصحيح أو انتظار اكتمال المهام إذا كانت تعمل على سلاسل محادثات في الخلفية لا يمكنك التحكم فيها.

في الاختبارات، يمكنك استبدال المُرسِلين بمثيلات TestDispatchers. وهذا له فوائد عديدة:

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

يُسهِّل استخدام إدخال الاعتمادية لتزويد المرسلين إلى الصفوف الدراسية عليك استبدال المُرسِلين الحقيقيين في الاختبارات. سنطبّق في هذه الأمثلة نوع CoroutineDispatcher، ولكن يمكنك أيضًا إدخال النوع الأوسع نطاقًا CoroutineContext، ما يتيح مرونة أكبر أثناء إجراء الاختبارات.

بالنسبة إلى الصفوف التي تبدأ باستخدام الكوروتينات، يمكنك أيضًا إدخال CoroutineScope بدلاً من أداة الإرسال، كما هو موضّح بالتفصيل في القسم إدخال نطاق.

ستنشئ TestDispatchers تلقائيًا أداة جدولة جديدة عند إنشاء مثيل لها. في runTest، يمكنك الوصول إلى السمة testScheduler من TestScope وتمريرها إلى أي TestDispatchers تم إنشاؤها حديثًا. سيؤدي ذلك إلى مشاركة فهمهم للوقت الافتراضي، وسيتم من خلال طرق مثل advanceUntilIdle تشغيل الكوروتينات على جميع أجهزة إرسال الاختبار حتى اكتمالها.

في المثال التالي، يمكنك الاطّلاع على فئة Repository تنشئ الكوروتين الجديد باستخدام مرسِل IO في طريقة initialize وتنقل المتصل إلى مرسِل IO من خلال طريقة fetchData:

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

في الاختبارات، يمكنك إدخال عملية تنفيذ TestDispatcher لاستبدال مُرسِل IO.

في المثال أدناه، تم إدخال StandardTestDispatcher في المستودع ونستخدم advanceUntilIdle للتأكد من اكتمال عملية الكوروتين الجديد التي بدأت في initialize قبل المتابعة.

سيستفيد "fetchData" أيضًا من التشغيل على "TestDispatcher"، لأنّه سيتم تنفيذه في سلسلة محادثات الاختبار وتخطّي مدة التأخير التي يتضمّنها أثناء الاختبار.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

يمكن يدويًا تقديم برامج الكوروتين الجديدة التي تم تشغيلها على TestDispatcher كما هو موضّح أعلاه باستخدام initialize. مع ذلك، يُرجى العلم أنّ هذا الإجراء قد لا يكون ممكنًا أو غير مرغوب فيه في رمز الإنتاج. وبدلاً من ذلك، يجب إعادة تصميم هذه الطريقة بحيث تكون مُعلّقة (للتنفيذ التسلسلي)، أو لعرض قيمة Deferred (للتنفيذ المتزامن).

على سبيل المثال، يمكنك استخدام async لبدء كوروتيين جديد وإنشاء Deferred:

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

يتيح لك ذلك await إكمال هذا الرمز بأمان في كلٍّ من الاختبارات ورمز الإنتاج:

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

سينتظر "runTest" اكتمال وحدات الكوروتين المعلّقة قبل العودة إذا كانت أجهزة الكوروتين على جهاز TestDispatcher يتشارك معها نظام جدولة. وسينتظر أيضًا الكوروتينات الصغيرة من الكوروتينات التجريبية ذات المستوى الأعلى، حتى إذا كانت ضمن مرسِلين آخرين (تصل إلى مهلة تحدِّدها المعلَمة dispatchTimeoutMs، والتي تكون 60 ثانية تلقائيًا).

تحديد المرسل الرئيسي

في اختبارات الوحدات المحلية، لن يكون مُرسِل Main الذي يلفّ سلسلة محادثات واجهة مستخدم Android متاحًا، لأنّه يتم تنفيذ هذه الاختبارات على جهاز JVM محلي وليس على جهاز Android. إذا كانت التعليمة البرمجية ضمن الاختبار تشير إلى سلسلة التعليمات الرئيسية، فستطرح استثناءً أثناء اختبارات الوحدة.

في بعض الحالات، يمكنك إدخال مُرسِل Main بالطريقة نفسها التي يتم بها حقن المُرسِلين الآخرين، كما هو موضَّح في القسم السابق، ما يسمح لك باستبداله بـ TestDispatcher في الاختبارات. ومع ذلك، تستخدم بعض واجهات برمجة التطبيقات، مثل viewModelScope، مرسِل Main مشفّرًا بشكلٍ غير مرئي.

في ما يلي مثال على عملية تنفيذ ViewModel تستخدم viewModelScope لتشغيل الكوروتين الذي يُحمِّل البيانات:

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

لاستبدال مُرسِل Main بـ TestDispatcher في جميع الحالات، استخدِم الدالتين Dispatchers.setMain وDispatchers.resetMain.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

إذا تم استبدال مُرسِل Main بـ TestDispatcher، سيستخدم أي TestDispatchers تم إنشاؤه حديثًا تلقائيًا أداة جدولة المهام من مُرسِل Main، بما في ذلك StandardTestDispatcher الذي تم إنشاؤه من قبل runTest في حال عدم إرسال أي مرسِل آخر إليه.

يسهّل ذلك التأكد من استخدام نظام جدولة واحد فقط أثناء الاختبار. لتنفيذ ذلك، تأكَّد من إنشاء جميع مثيلات TestDispatcher الأخرى بعد طلب Dispatchers.setMain.

هناك نمط شائع لتجنُّب تكرار الرمز البرمجي الذي يحل محل مرسِل Main في كل اختبار، وهو استخراجه إلى قاعدة اختبار JUnit:

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

تستخدم عملية تنفيذ القاعدة هذه UnconfinedTestDispatcher بشكلٍ تلقائي، ولكن يمكن تمرير StandardTestDispatcher كمَعلمة إذا لم يكن من المطلوب تنفيذ مُرسِل Main في فئة اختبار معيّنة.

عندما تحتاج إلى مثيل TestDispatcher في نص الاختبار، يمكنك إعادة استخدام testDispatcher من القاعدة، ما دام هذا هو النوع المطلوب. إذا كنت تريد توضيح نوع TestDispatcher المستخدم في الاختبار أو إذا كنت بحاجة إلى TestDispatcher من نوع مختلف عن النوع Main، يمكنك إنشاء TestDispatcher جديد في runTest. بما أنه تم ضبط مرسِل Main على TestDispatcher، سيشارك أي TestDispatchers تم إنشاؤه حديثًا أداة جدولة المهام تلقائيًا.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

إنشاء المرسلين خارج الاختبار

وفي بعض الحالات، قد تحتاج إلى توفير TestDispatcher خارج طريقة الاختبار. على سبيل المثال، أثناء إعداد موقع في فئة الاختبار:

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

في حال استبدال مُرسِل "Main" كما هو موضّح في القسم السابق، ستتم تلقائيًا مشاركة أداة جدولة مهام TestDispatchers التي تم إنشاؤها بعد استبدال مرسِل Main.

ومع ذلك، لا ينطبق ذلك على TestDispatchers التي تم إنشاؤها كسمات لفئة الاختبار أو TestDispatchers تم إنشاؤها أثناء إعداد السمات في فئة الاختبار. يتم إعداد هذه الرسائل قبل استبدال مُرسِل "Main". لذلك، سيقوم بإنشاء برامج جدولة جديدة.

للتأكّد من وجود أداة جدولة واحدة فقط في الاختبار، أنشِئ السمة MainDispatcherRule أولاً. وبعد ذلك، يمكنك إعادة استخدام مرسِلها (أو أداة الجدولة، إذا كنت بحاجة إلى TestDispatcher من نوع مختلف) في أدوات إعداد المواقع الأخرى على مستوى الفئة حسب الحاجة.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

يُرجى ملاحظة أنّ كلاً من runTest وTestDispatchers اللذين تم إنشاؤهما ضمن الاختبار سيظلان يشاركان تلقائيًا أداة جدولة مهام الإرسال Main.

إذا لم تكن تريد استبدال مُرسِل Main، أنشِئ أول TestDispatcher (ما يؤدي إلى إنشاء أداة جدولة جديدة) كخاصية للفئة. وبعد ذلك، عليك تمرير أداة جدولة المهام يدويًا إلى كل استدعاء runTest وكل TestDispatcher جديد يتم إنشاؤه، سواء كخصائص أو ضمن الاختبار:

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

في هذا النموذج، يتم تمرير أداة الجدولة من المرسِل الأول إلى runTest. سيؤدي هذا إلى إنشاء StandardTestDispatcher جديد لـ TestScope باستخدام برنامج جدولة المهام هذا. يمكنك أيضًا إرسال جهاز الإرسال إلى "runTest" مباشرةً لتشغيل اختبار كوروتين على ذلك المُرسِل.

إنشاء نطاق TestScope

كما هي الحال في TestDispatchers، قد تحتاج إلى الوصول إلى TestScope خارج نص الاختبار. على الرغم من أنّ runTest ينشئ TestScope تلقائيًا، يمكنك أيضًا إنشاء TestScope الخاص بك لاستخدامه مع runTest.

عند إجراء ذلك، احرص على الاتصال بـ runTest على TestScope التي أنشأتها:

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

يُنشئ الرمز أعلاه StandardTestDispatcher لـ TestScope بشكل ضمني، بالإضافة إلى أداة جدولة جديدة. يمكن أيضًا إنشاء جميع هذه الكائنات بشكل صريح. ويمكن أن يكون هذا مفيدًا إذا كنت بحاجة إلى دمجه مع إعدادات إدخال التبعية.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

إدخال نطاق

إذا كان لديك فئة تنشئ الكوروتينات التي تحتاج إلى التحكّم فيها أثناء الاختبارات، يمكنك إدخال نطاق الكوروتين في تلك الفئة واستبداله بـ TestScope في الاختبارات.

في المثال التالي، تعتمد الفئة UserState على UserRepository لتسجيل المستخدمين الجدد وجلب قائمة المستخدمين المسجَّلين. بما أنّ هذه الاستدعاءات الموجَّهة إلى UserRepository يعلِّق استدعاءات الدوال، يستخدم UserState العنصر CoroutineScope الذي تم إدخاله لتشغيل الكوروتين الجديد داخل دالة registerUser.

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

لاختبار هذه الفئة، يمكنك تمرير TestScope من runTest عند إنشاء كائن UserState:

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

ولإدخال نطاق خارج دالة الاختبار، على سبيل المثال في كائن ضمن الاختبار الذي تم إنشاؤه كسمة في فئة الاختبار، يُرجى الاطّلاع على إنشاء نطاق TestScope.

مراجع إضافية