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

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

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

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

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

หัวข้อทดสอบและชั้นข้อมูล
รูปที่ 1 หัวข้อทดสอบและเลเยอร์ข้อมูล

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

Dependency จะถูกแทนที่ด้วยการใช้งานจำลอง
รูปที่ 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 ที่เก็บ (เรื่องที่ทดสอบ) ที่มีข้อกําหนดซึ่งแสดงขั้นตอนการทำงานซึ่งจำลองขึ้นมา

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

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

@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 myRepositoryTest() = runTest {
    // 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
}

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

@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 การเรียกเก็บเงินที่รวบรวมข้อมูลจึงไม่มีวันแสดงผล การเริ่ม Coroutine ที่รวบรวมใน TestScope.backgroundScope จะทำให้ Coroutine ถูกยกเลิกก่อนการทดสอบสิ้นสุด ไม่เช่นนั้น runTest จะรอให้เสร็จสมบูรณ์อยู่เรื่อยๆ ซึ่งทําให้การทดสอบหยุดตอบกลับและในที่สุดก็ทํางานไม่สําเร็จ

โปรดสังเกตวิธีใช้ UnconfinedTestDispatcher กับ Coroutine การเก็บรวบรวมที่นี่ วิธีนี้ช่วยให้มั่นใจว่าระบบจะเปิดใช้ coroutine การเก็บรวบรวมทันทีและพร้อมรับค่าหลังจากที่ launch แสดงผล

การใช้ Turbine

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

@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 มักใช้ในโมเดลมุมมอง

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

@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)
}

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