การทดสอบโครูทีน Kotlin ใน Android

โค้ดการทดสอบ 1 หน่วยที่ใช้Coroutine ต้องใช้การดำเนินการเพิ่มเติม เนื่องจากการดำเนินการอาจไม่พร้อมกันและเกิดขึ้นกับชุดข้อความหลายรายการ คู่มือนี้จะกล่าวถึงวิธีทดสอบฟังก์ชันการระงับ โครงสร้างการทดสอบที่คุณต้องคุ้นเคย และวิธีทำให้โค้ดที่ใช้โกโรทีนสามารถทดสอบได้

API ที่ใช้ในคู่มือนี้เป็นส่วนหนึ่งของไลบรารี kotlinx.coroutines.test อย่าลืมเพิ่มอาร์ติแฟกต์เป็นทรัพยากร Dependency ทดสอบในโปรเจ็กต์ของคุณเพื่อให้มีสิทธิ์เข้าถึง API เหล่านี้

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

การเรียกใช้ฟังก์ชันระงับในการทดสอบ

หากต้องการเรียกใช้ฟังก์ชันการระงับในการทดสอบ คุณต้องอยู่ในโครูทีน เนื่องจากฟังก์ชันการทดสอบ JUnit ไม่ได้ระงับฟังก์ชันต่างๆ คุณจึงต้องเรียกใช้เครื่องมือสร้าง Coroutine ภายในการทดสอบเพื่อเริ่ม Coroutine ใหม่

runTest คือเครื่องมือสร้างโอโรทีนที่ออกแบบมาเพื่อการทดสอบ ใช้ค่านี้เพื่อห่อหุ้มการทดสอบที่มีโครูทีน โปรดทราบว่าโครูทีนออกฤทธิ์ได้ไม่เพียงแค่ในหน่วยงานทดสอบเท่านั้น แต่ยังเริ่มจากวัตถุที่ใช้ในการทดสอบด้วย

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

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

โดยทั่วไป คุณควรมีการเรียก runTest 1 คำขอต่อการทดสอบ และขอแนะนำให้ใช้เนื้อความของนิพจน์

การรวมโค้ดการทดสอบใน runTest จะทำหน้าที่ทดสอบฟังก์ชันการระงับพื้นฐาน และจะข้ามการหน่วงเวลาของโครูทีนโดยอัตโนมัติ ทำให้การทดสอบข้างต้นเสร็จสมบูรณ์เร็วกว่า 1 วินาที

อย่างไรก็ตาม ยังมีข้อควรพิจารณาเพิ่มเติม ทั้งนี้ขึ้นอยู่กับสิ่งที่เกิดขึ้นในโค้ดของคุณที่อยู่ในการทดสอบ:

  • เมื่อโค้ดสร้างโครูทีนใหม่นอกเหนือจากโครูทีนทดสอบระดับบนสุดที่ runTest สร้างขึ้น คุณจะต้องควบคุมวิธีกำหนดเวลาโครูทีนใหม่เหล่านั้นโดยเลือก TestDispatcher ที่เหมาะสม
  • หากโค้ดของคุณย้ายการดำเนินการ Coroutine ไปยังผู้มอบหมายงานคนอื่นๆ (เช่น โดยใช้ withContext) โดยทั่วไป runTest จะยังคงใช้งานได้ แต่จะไม่มีการข้ามการหน่วงเวลาอีกต่อไป และการทดสอบจะคาดการณ์ได้น้อยลงเนื่องจากโค้ดทำงานในหลายเทรด ด้วยเหตุผลเหล่านี้ ในการทดสอบ คุณจึงควรแทรกเจ้าหน้าที่ทดสอบเพื่อแทนที่เจ้าหน้าที่จ่ายงานจริง

TestDispatchers

TestDispatchers คือการติดตั้งใช้งาน CoroutineDispatcher เพื่อวัตถุประสงค์ในการทดสอบ คุณจะต้องใช้ TestDispatchers หากมีการสร้างโครูทีนใหม่ในระหว่างการทดสอบ เพื่อให้คาดการณ์โครูทีนใหม่ได้

มีการใช้งาน TestDispatcher อยู่ 2 แบบ ได้แก่ StandardTestDispatcher และ UnconfinedTestDispatcher ซึ่งกำหนดเวลาต่างๆ สำหรับโครูทีนที่เพิ่งเริ่มใหม่ ทั้งคู่ใช้ TestCoroutineScheduler เพื่อควบคุมเวลาเสมือนและจัดการโครูทีนในการวิ่งภายในการทดสอบ

ควรมีอินสแตนซ์เครื่องจัดตารางเวลาเพียง 1 รายการในการทดสอบ โดยแชร์ระหว่าง TestDispatchers ทั้งหมด โปรดดูการแทรก TestDispatchers เพื่อเรียนรู้เกี่ยวกับการแชร์เครื่องจัดตารางเวลา

ในการเริ่มต้นโครูทีนทดสอบระดับบนสุด runTest จะสร้าง TestScope ซึ่งเป็นการติดตั้งใช้งาน CoroutineScope ซึ่งจะใช้ TestDispatcher เสมอ หากไม่ได้ระบุไว้ TestScope จะสร้าง StandardTestDispatcher โดยค่าเริ่มต้น และใช้เพื่อการเรียกใช้ Coroutine ทดสอบระดับบนสุด

runTest จะติดตาม coroutine ที่ต่อคิวอยู่ในเครื่องจัดตารางเวลาที่ผู้มอบหมายของ TestScope ใช้ และจะไม่ส่งคืนอีกตราบใดที่ยังมีงานที่รอดำเนินการอยู่ในเครื่องจัดตารางเวลานั้น

ผู้สั่งทดสอบมาตรฐาน

เมื่อคุณเริ่มโครูทีนใหม่ใน StandardTestDispatcher โครูทีนจะจัดคิวอยู่ในเครื่องจัดตารางเวลาที่สำคัญเพื่อให้เรียกใช้เมื่อใดก็ตามที่เทรดทดสอบใช้งานได้ฟรี หากต้องการให้โครูทีนใหม่เหล่านี้ทํางาน คุณต้องตอบกลับชุดข้อความทดสอบ (เพิ่มพื้นที่ว่างเพื่อให้โครูทีนอื่นๆ ใช้ได้) ลักษณะการจัดคิวนี้ช่วยให้คุณควบคุมโครูทีนใหม่ในระหว่างการทดสอบได้อย่างแม่นยำ ทั้งยังคล้ายกับการจัดตารางเวลาของโครูทีนในโค้ดการผลิต

หากเทรดทดสอบไม่แสดงในระหว่างการดำเนินการของ coroutine ทดสอบระดับบนสุด โครูทีนใหม่จะทำงานหลังจากที่ทดสอบโครูทีนเสร็จแล้วเท่านั้น (แต่ก่อนแสดงผล 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 เพื่ออนุญาตให้โครูทีนที่รอดำเนินการ 2 รายการทำงานได้ก่อนที่จะยืนยันต่อ

@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 จะเริ่มด้วยใจจดใจจ่อในชุดข้อความปัจจุบัน ซึ่งหมายความว่าจะเริ่มทำงานทันทีโดยไม่ต้องรอให้เครื่องมือสร้าง Coroutine กลับมา ในหลายกรณี ลักษณะการส่งแบบนี้จะส่งผลให้โค้ดทดสอบง่ายขึ้น เนื่องจากคุณไม่จำเป็นต้องแสดงเธรดทดสอบด้วยตนเองเพื่อให้โครูทีนใหม่ทำงาน

อย่างไรก็ตาม ลักษณะการทำงานนี้จะแตกต่างจากสิ่งที่คุณจะเห็นในการใช้งานจริงกับผู้มอบหมายงานที่ไม่ใช่ผู้ทดสอบ หากการทดสอบมุ่งเน้นที่การเกิดขึ้นพร้อมกัน ให้ใช้ 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 จะเริ่มต้นโครูทีนใหม่อย่างตั้งใจ แต่ไม่ได้หมายความว่าจะเรียกใช้โครูทีนได้สำเร็จอย่างตั้งใจเช่นกัน หากโครูทีนใหม่พักการทำงาน โครูทีนอื่นๆ จะกลับมาทำงานอีกครั้ง

ตัวอย่างเช่น Coroutine ที่เปิดตัวใหม่ภายในการทดสอบนี้จะลงทะเบียน Alice แต่จะระงับเมื่อมีการเรียกใช้ 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 จะใช้เครื่องจัดตารางเวลาสำหรับเวลาเสมือน ซึ่งจะข้ามการหน่วงเวลาโดยอัตโนมัติและช่วยให้คุณเลื่อนเวลาได้ด้วยตนเอง

ใช้การแทรก Dependency เพื่อระบุ ผู้แจกจ่ายงานในชั้นเรียน ทำให้สามารถแทนที่ผู้แจกจ่ายงานจริง การทดสอบ ในตัวอย่างเหล่านี้ เราจะแทรก 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 ที่แชร์เครื่องจัดตารางเวลาด้วย นอกจากนี้ ยังจะรอ coroutine ที่เป็นลูกของ coroutine ทดสอบระดับบนสุดด้วย ถึงแม้ว่าจะอยู่บนแอพพลิเมนต์อื่นๆ ก็ตาม (จนถึงระยะหมดเวลาที่ระบุไว้โดยพารามิเตอร์ dispatchTimeoutMs ซึ่งคือ 60 วินาทีโดยค่าเริ่มต้น)

การตั้งค่าผู้มอบหมายงานหลัก

ในการทดสอบหน่วยในเครื่อง ผู้มอบหมายงาน Main ที่รวมชุดข้อความ UI ของ Android ไว้จะไม่สามารถใช้ได้ เนื่องจากการทดสอบเหล่านี้จะดำเนินการใน JVM ในเครื่อง ไม่ใช่อุปกรณ์ Android หากโค้ดที่อยู่ในการทดสอบอ้างอิงเทรดหลัก จะมีข้อยกเว้นในระหว่างการทดสอบ 1 หน่วย

ในบางกรณี คุณสามารถแทรกผู้มอบหมายงาน Main ได้ด้วยวิธีเดียวกับผู้มอบหมายงานคนอื่นๆ ดังที่อธิบายไว้ในส่วนก่อนหน้า โดยคุณสามารถแทนที่ด้วย TestDispatcher ในการทดสอบได้ อย่างไรก็ตาม API บางรายการ เช่น viewModelScope จะใช้เครื่องมือจัดการ Main แบบฮาร์ดโค้ดในขั้นสูง

ตัวอย่างการติดตั้งใช้งาน ViewModel ที่ใช้ viewModelScope ในการเปิดใช้ Coroutine ที่โหลดข้อมูลมีดังนี้

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 ก่อนเพื่อให้แน่ใจว่ามีเครื่องจัดตารางเวลาเพียง 1 รายการในการทดสอบ จากนั้นนำผู้มอบหมายงานกลับมาใช้ใหม่ (หรือเครื่องจัดตารางเวลา ถ้าคุณต้องการ 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 ของคุณเอง

คุณอาจต้องเข้าถึง TestScope นอกหน่วยทดสอบ เช่นเดียวกับ TestDispatchers แม้ว่า runTest จะสร้าง TestScope ขั้นสูงโดยอัตโนมัติ แต่คุณก็สร้าง TestScope ของคุณเองเพื่อใช้กับ runTest ได้เช่นกัน

เมื่อดำเนินการนี้ โปรดโทรหา runTest ใน TestScope ที่คุณสร้างไว้

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

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

โค้ดข้างต้นจะสร้าง StandardTestDispatcher สำหรับ TestScope โดยปริยาย รวมทั้งเครื่องจัดตารางเวลาใหม่ คุณยังสามารถสร้างออบเจ็กต์เหล่านี้อย่างชัดแจ้งได้อีกด้วย ซึ่งจะเป็นประโยชน์ในกรณีที่คุณต้องผสานรวมกับการตั้งค่าการแทรกทรัพยากร Dependency

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

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

การแทรกขอบเขต

ในกรณีที่มีชั้นเรียนที่สร้างโครูทีนซึ่งคุณต้องควบคุม คุณสามารถแทรกขอบเขต Coroutine ลงในคลาสนั้น แล้วแทนที่ด้วย 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 ของคุณเอง

แหล่งข้อมูลเพิ่มเติม