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

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

تُعد واجهات برمجة التطبيقات المستخدمة في هذا الدليل جزءًا من مكتبة 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 {0}{/0}.
  • إذا نقلت الشفرة تنفيذ عملية كوروتين إلى مُرسلين آخرين (على سبيل المثال، باستخدام withContext)، فسيستمر عمل runTest بشكل عام، ولكن لن يتم تخطي أي تأخير بعد ذلك، وسيتم إجراء الاختبارات. أقل قابلية للتوقع حيث تعمل الشفرة على سلاسل محادثات متعددة. لهذه الأسباب، في الاختبارات، عليك إدخال مُرسِلين تجريبيين بدلاً من مُرسلِين حقيقيين.

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

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

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

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

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

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

مُرسِل الإصدار القياسي من الاختبار

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

ومع ذلك، يختلف هذا السلوك عن ما ستراه في مرحلة الإنتاج مع مُرسلين غير تجريبيين. إذا كان الاختبار يركّز على التزامن، يُفضَّل استخدام 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 التي تنشئ مستوى coroutine جديدًا باستخدام المرسِل 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 {0}{/0}. بما أنه تم ضبط المُرسل 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 مباشرةً لتشغيل كوروتين الاختبار على ذلك المُرسل.

إنشاء نطاق الاختبار الخاص بك

كما هو الحال مع 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)
    }
}

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

مصادر إضافية