آزمایش کردن روال‌های مشترک 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 مناسب، نحوه زمان‌بندی آن روتین‌های هم‌زمان جدید را کنترل کنید.
  • اگر کد شما اجرای روتین همکار را به توزیع‌کننده‌های دیگر منتقل کند (برای مثال، بااستفاده از withContext)، runTest همچنان به‌طورکلی کار خواهد کرد، اما دیگر از تأخیرها صرف‌نظر نخواهد شد و آزمایش‌ها به‌دلیل اجرای کد در چند رشته قابل‌پیش‌بینی نخواهند بود. به همین دلایل، در آزمایش‌ها باید فرستندگان آزمایشی تزریق کنید تا جایگزین فرستندگان واقعی شوند.

TestDispatchers

TestDispatchers پیاده‌سازی CoroutineDispatcher برای اهداف آزمایشی است. اگر درطول آزمایش روتین‌های هم‌زمان جدیدی ایجاد شود، باید از TestDispatchers استفاده کنید تا اجرای روتین‌های هم‌زمان جدید قابل‌پیش‌بینی باشد.

دو پیاده‌سازی دردسترس برای TestDispatcher وجود دارد: StandardTestDispatcher و UnconfinedTestDispatcher که زمان‌بندی متفاوتی برای روال‌های هم‌کار جدیداً شروع‌شده انجام می‌دهند. هر دو از TestCoroutineScheduler برای کنترل زمان مجازی و مدیریت روتین‌های هم‌زمان درحال اجرا در یک آزمایش استفاده می‌کنند.

فقط یک نمونه زمان‌بند باید در آزمایش استفاده شود، که بین همه TestDispatchers هم‌رسانی می‌شود. برای آشنایی با هم‌رسانی زمان‌بندها، وارد کردن 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 خود، یک روتین همکار جدید ایجاد می‌کند و تماس‌گیرنده را در روش fetchData خود به توزیع‌کننده IO تغییر می‌دهد:

// 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())
    // ...
}

اگر روتین‌های همکار در TestDispatcher باشند که زمان‌بند را با آن هم‌رسانی می‌کند، runTest قبل‌از بازگشت منتظر می‌ماند تا روتین‌های همکار معلقه تکمیل شوند. همچنین منتظر می‌ماند تا روال‌های هم‌زمان فرزند روال هم‌زمان آزمایشی سطح بالا تمام شوند، حتی اگر در توزیع‌کننده‌های دیگر باشند (تا زمان اتمام مشخص‌شده توسط پارامتر dispatchTimeoutMs، که به‌طور پیش‌فرض ۶۰ ثانیه است).

تنظیم توزیع‌کننده اصلی

در آزمایش‌های واحد محلی، توزیع‌کننده 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 استفاده می‌کند، اما اگر توزیع‌کننده Main نباید در کلاس آزمایشی معینی مشتاقانه اجرا شود، می‌توان StandardTestDispatcher را به‌عنوان پارامتر ارسال کرد.

وقتی در متن آزمایش به نمونه 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 ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = ExampleRepository(/* 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 = ExampleRepository(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 = ExampleRepository(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 خودتان مراجعه کنید.

منابع بیشتر