تست کوروتین های Kotlin در اندروید

کد تست واحد که از کوروتین‌ها استفاده می‌کند به توجه بیشتری نیاز دارد، زیرا اجرای آن‌ها می‌تواند ناهمزمان باشد و در چندین رشته اتفاق بیفتد. این راهنما نحوه آزمایش توابع تعلیق، ساختارهای آزمایشی که باید با آن‌ها آشنا باشید و چگونه کد خود را که از کوروتین‌ها استفاده می‌کند قابل آزمایش کنید، پوشش می‌دهد.

API های استفاده شده در این راهنما بخشی از کتابخانه kotlinx.coroutines.test هستند. مطمئن شوید که مصنوع را به عنوان یک وابستگی آزمایشی به پروژه خود اضافه کنید تا به این APIها دسترسی داشته باشید.

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

TestDispatchers

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 ، به حالت تعلیق در می‌آید. این اجازه می دهد تا برنامه سطح بالا با ادعا ادامه دهد، و آزمایش با شکست مواجه شود زیرا Bob هنوز ثبت نشده است:

@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 که در بخش Injecting a scope توضیح داده شده است، تزریق کنید.

TestDispatchers ، به طور پیش‌فرض، زمانی که نمونه‌سازی می‌شوند، یک زمان‌بندی جدید ایجاد می‌کنند. در داخل runTest ، می‌توانید به ویژگی testScheduler TestScope دسترسی داشته باشید و آن را به هر TestDispatchers که به تازگی ایجاد شده‌اند ارسال کنید. با این کار درک آن‌ها از زمان مجازی به اشتراک گذاشته می‌شود و روش‌هایی مانند advanceUntilIdle برنامه‌های روتین را در تمام توزیع‌کننده‌های آزمایشی اجرا می‌کنند.

در مثال زیر، می توانید یک کلاس Repository را ببینید که با استفاده از IO dispatcher در روش initialize خود، یک coroutine جدید ایجاد می کند و در متد 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 استفاده می‌کنیم تا مطمئن شویم که coroutine جدید که در 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 مشخص شده است که به طور پیش‌فرض 60 ثانیه است).

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

در آزمایش‌های واحد محلی ، توزیع‌کننده Main که رشته رابط کاربری Android را می‌پیچد، در دسترس نخواهد بود، زیرا این آزمایش‌ها بر روی یک JVM محلی و نه یک دستگاه Android اجرا می‌شوند. اگر کد تحت آزمایش شما به رشته اصلی ارجاع می دهد، در طول تست های واحد یک استثنا ایجاد می کند.

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

این باعث می شود اطمینان حاصل شود که تنها یک زمانبندی در طول آزمایش استفاده می شود. برای اینکه این کار کار کند، مطمئن شوید که همه نمونه های دیگر TestDispatcher را پس از فراخوانی Dispatchers.setMain ایجاد کرده اید.

یک الگوی رایج برای جلوگیری از تکرار کدی که جایگزین Dispatcher 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 استفاده می‌کند، اما اگر Dispatcher 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 را به اشتراک می گذارند.

اگر Dispatcher 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 {
        // ...
    }
}

تزریق یک اسکوپ

اگر کلاسی دارید که کوروتین‌هایی را ایجاد می‌کند که باید در طول تست‌ها آن‌ها را کنترل کنید، می‌توانید یک Coroutine scope به آن کلاس تزریق کنید و آن را با 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() }
        }
    }
}

برای آزمایش این کلاس، می توانید هنگام ایجاد شی UserState ، TestScope از runTest ارسال کنید:

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 خود مراجعه کنید.

منابع اضافی