การทดสอบขั้นตอนของ Kotlin ใน Android

วิธีทดสอบหน่วยหรือโมดูลที่สื่อสารกับ Flow จะขึ้นอยู่กับว่าออบเจ็กต์ภายใต้การทดสอบใช้ Flow เป็นอินพุตหรือเอาต์พุต

  • หาก Subject ที่อยู่ภายใต้การทดสอบสังเกตโฟลว์ คุณจะสร้างโฟลว์ภายใน การอ้างอิงปลอมที่ควบคุมได้จากการทดสอบ
  • หากหน่วยหรือโมดูลแสดงโฟลว์ คุณจะอ่านและยืนยันรายการเดียวหรือหลายรายการที่โฟลว์ปล่อยออกมาในการทดสอบได้

การสร้างโปรดิวเซอร์ปลอม

เมื่อเรื่องที่อยู่ภายใต้การทดสอบเป็นผู้ใช้โฟลว์ วิธีทั่วไปในการทดสอบคือการแทนที่ผู้ผลิตด้วยการติดตั้งใช้งานปลอม ตัวอย่างเช่น หากมีคลาสที่สังเกตที่เก็บที่รับข้อมูลจากแหล่งข้อมูล 2 แหล่งในสภาพแวดล้อมการผลิต

ออบเจ็กต์ภายใต้การทดสอบและชั้นข้อมูล
รูปที่ 1 เรื่องที่อยู่ระหว่างการทดสอบและ Data Layer

หากต้องการทำให้การทดสอบเป็นแบบกำหนด คุณสามารถแทนที่ที่เก็บและ การอ้างอิงด้วยที่เก็บปลอมที่ปล่อยข้อมูลปลอมเดียวกันเสมอได้

แทนที่การอ้างอิงด้วยการใช้งานแบบจำลอง
รูปที่ 2 โดยจะแทนที่การอ้างอิงด้วยการ ติดตั้งใช้งาน
แบบจำลอง

หากต้องการส่งค่าที่กำหนดไว้ล่วงหน้าเป็นชุดในโฟลว์ ให้ใช้เครื่องมือสร้าง flow โดยทำดังนี้

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

ในการทดสอบ ระบบจะแทรกที่เก็บปลอมนี้เพื่อแทนที่การใช้งานจริง

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    // ...
}

ตอนนี้คุณควบคุมเอาต์พุตของออบเจ็กต์ที่อยู่ภายใต้การทดสอบได้แล้ว คุณจึง ยืนยันได้ว่าออบเจ็กต์ทำงานได้อย่างถูกต้องโดยการตรวจสอบเอาต์พุต

การยืนยันการปล่อยก๊าซของโฟลว์ในการทดสอบ

หากเรื่องที่ทดสอบแสดงโฟลว์ การทดสอบต้องทำการยืนยัน ในองค์ประกอบของสตรีมข้อมูล

สมมติว่าที่เก็บของตัวอย่างก่อนหน้าแสดงโฟลว์ดังนี้

ที่เก็บที่มีทรัพยากร Dependency ปลอมซึ่งแสดงโฟลว์
รูปที่ 3 ที่เก็บ (ออบเจ็กต์ภายใต้การทดสอบ) ที่มีทรัพยากร Dependency ปลอม ซึ่งแสดงโฟลว์

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

คุณสามารถใช้การปล่อยก๊าซครั้งแรกกับโฟลว์ได้โดยโทรหา first() ฟังก์ชันนี้จะรอจนกว่าจะได้รับรายการแรก แล้วจึงส่งสัญญาณการยกเลิกไปยัง Producer

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

หากการทดสอบต้องตรวจสอบค่าหลายค่า การเรียก toList() จะทำให้โฟลว์ รอให้แหล่งที่มาปล่อยค่าทั้งหมดออกมา แล้วจึงแสดงค่าเหล่านั้นเป็น รายการ ซึ่งใช้ได้กับสตรีมข้อมูลที่สิ้นสุดแล้วเท่านั้น

@Test
fun myRepositoryTestList() = runTest {
    val repository = MyFakeRepository()
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

สำหรับสตรีมข้อมูลที่ต้องรวบรวมรายการที่ซับซ้อนมากขึ้นหรือไม่ได้แสดงรายการ จำนวนจำกัด คุณสามารถใช้ Flow API เพื่อเลือกและเปลี่ยนรูปแบบ รายการได้ ตัวอย่างเช่น

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

การรวบรวมข้อมูลอย่างต่อเนื่องระหว่างการทดสอบ

การรวบรวมโฟลว์โดยใช้ toList() ตามที่เห็นในตัวอย่างก่อนหน้าจะใช้ collect() ภายใน และจะระงับไว้จนกว่ารายการผลลัพธ์ทั้งหมดจะพร้อมที่จะแสดง

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

ตัวอย่างเช่น ลองใช้คลาส Repository ต่อไปนี้เพื่อทดสอบ และการติดตั้งใช้งานแหล่งข้อมูลจำลองที่มาพร้อมกันซึ่งมีเมธอด emit เพื่อสร้างค่าแบบไดนามิกระหว่างการทดสอบ

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

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

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected
}

เนื่องจากโฟลว์ที่แสดงโดย Repository ที่นี่ไม่เคยเสร็จสมบูรณ์ การเรียกใช้ toList ที่รวบรวมโฟลว์จึงไม่เคยส่งคืน การเริ่มต้นโครูทีนการรวบรวมใน TestScope.backgroundScope ช่วยให้มั่นใจได้ว่าโครูทีนจะถูกยกเลิกก่อนสิ้นสุดการทดสอบ ไม่เช่นนั้น runTest จะรอให้การดำเนินการเสร็จสมบูรณ์ต่อไป ซึ่งจะทำให้การทดสอบหยุดตอบสนองและล้มเหลวในที่สุด

โปรดสังเกตว่ามีการใช้ UnconfinedTestDispatcher สำหรับโครูทีนการรวบรวมที่นี่ ซึ่งจะช่วยให้มั่นใจได้ว่า Coroutine ของการรวบรวมจะเปิดตัวอย่างรวดเร็วและพร้อมรับค่าหลังจาก launch ส่งคืน

การใช้ Turbine

ไลบรารี Turbine ของบุคคลที่สาม มี API ที่สะดวกสำหรับการสร้างโครูทีนที่รวบรวมข้อมูล รวมถึง ฟีเจอร์อำนวยความสะดวกอื่นๆ สำหรับการทดสอบโฟลว์

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

ดูรายละเอียดเพิ่มเติมได้ใน เอกสารประกอบของไลบรารี

การทดสอบ StateFlow

StateFlow เป็นที่เก็บข้อมูลที่สังเกตได้ ซึ่งรวบรวมเพื่อสังเกตค่าที่เก็บไว้เมื่อเวลาผ่านไปในรูปแบบ สตรีม โปรดทราบว่าสตรีมค่านี้จะรวมกัน ซึ่งหมายความว่าหากตั้งค่าใน StateFlow อย่างรวดเร็ว ระบบจะไม่รับประกันว่าตัวรวบรวมของ StateFlow นั้นจะได้รับค่ากลางทั้งหมด แต่จะได้รับค่าล่าสุดเท่านั้น

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

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

ตัวอย่างเช่น ลองใช้ ViewModel นี้ที่รวบรวมค่าจาก Repository และ แสดงค่าเหล่านั้นใน UI ใน StateFlow

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

การติดตั้งใช้งานปลอมสำหรับ Repository นี้อาจมีลักษณะดังนี้

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

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

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

การทำงานกับ StateFlow ที่สร้างโดย stateIn

ในส่วนก่อนหน้า ViewModel ใช้ MutableStateFlow เพื่อจัดเก็บ ค่าล่าสุดที่โฟลว์ปล่อยออกมาจาก Repository นี่เป็นรูปแบบที่พบบ่อย ซึ่งมักจะใช้ในวิธีที่ง่ายกว่าโดยใช้ตัวดำเนินการ stateIn ซึ่งจะเปลี่ยนโฟลว์แบบเย็นเป็นโฟลว์แบบร้อน StateFlow

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

ตัวดำเนินการ stateIn มีพารามิเตอร์ SharingStarted ซึ่งกำหนดเวลาที่ จะเปิดใช้งานและเริ่มใช้โฟลว์พื้นฐาน ตัวเลือกต่างๆ เช่น SharingStarted.Lazily และ SharingStarted.WhileSubscribed มักใช้ ใน ViewModel

แม้ว่าคุณจะยืนยันใน value ของ StateFlow ในการทดสอบ คุณก็ยังต้องสร้างตัวรวบรวม ซึ่งอาจเป็นตัวรวบรวมข้อมูลที่ว่างเปล่าได้ดังนี้

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.score.collect {}
    }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

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