בדיקת תהליכים של Kotlin ב-Android

אופן הבדיקה של יחידות או מודולים שמתקשרים עם flow תלוי בשאלה אם הנושא שנבדק משתמש ב-flow כקלט או כפלט.

  • אם הנבדק צופה בתהליך, אפשר ליצור תהליכים בתוך תלות מזויפת שאפשר לשלוט בהם מבדיקות.
  • אם היחידה או המודול חושפים זרימה, אפשר לקרוא ולאמת פריט אחד או כמה פריטים שמועברים על ידי הזרימה בבדיקה.

יצירת תוכן מזויף

כשנושא הבדיקה הוא צרכן של רצף פעולות, דרך נפוצה לבדוק אותו היא להחליף את היצרן בהטמעה מזויפת. לדוגמה, אם יש לכם מחלקה שעוקבת אחרי מאגר שמקבל נתונים משני מקורות נתונים בסביבת ייצור:

הנושא שנבדק ושכבת הנתונים
איור 1. הנושא שנבדק ושכבת הנתונים.

כדי שהבדיקה תהיה דטרמיניסטית, אפשר להחליף את המאגר ואת התלות שלו במאגר מזויף שתמיד פולט את אותם נתונים מזויפים:

יחסי התלות מוחלפים בהטמעה מזויפת
איור 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
    // ...
}

עכשיו שיש לכם שליטה על התוצאות של הנושא שנבדק, אתם יכולים לבדוק את התוצאות כדי לוודא שהוא פועל בצורה תקינה.

הצהרה על פליטות בתהליך בעת בדיקה

אם הנושא שנבדק חושף זרימה, הבדיקה צריכה ליצור טענות לגבי הרכיבים של מקור הנתונים.

נניח שהמאגר בדוגמה הקודמת חושף זרימה:

repository with fake dependencies that exposes a flow
איור 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 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)
}

לפיד נתונים שנדרש בו איסוף מורכב יותר של פריטים או שלא מוחזר בו מספר סופי של פריטים, אפשר להשתמש ב-API של Flow כדי לבחור ולשנות פריטים. הנה כמה דוגמאות:

// 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 הבאה לבדיקה, והטמעה של מקור נתונים פיקטיבי עם ה-method 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
}

כשמשתמשים ב-fake הזה בבדיקה, אפשר ליצור קורוטינה לאיסוף שתקבל באופן רציף את הערכים מ-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 ששולפת את הנתונים אף פעם לא חוזרת. הפעלת ה-Coroutine של איסוף הנתונים ב-TestScope.backgroundScope מבטיחה שה-Coroutine יבוטל לפני סיום הבדיקה. אחרת, runTest ימשיך לחכות לסיום שלו, מה שיגרום לבדיקה להפסיק להגיב ובסופו של דבר להיכשל.

שימו לב איך נעשה שימוש ב-UnconfinedTestDispatcher כאן עבור קורוטינת האיסוף. כך מוודאים שהקורוטינה של האיסוף מופעלת באופן מיידי ומוכנה לקבל ערכים אחרי שהפונקציה launch מחזירה ערך.

שימוש ב-Turbine

ספריית Turbine של צד שלישי מציעה API נוח ליצירת קורוטינה לאיסוף, וגם תכונות נוחות אחרות לבדיקת אובייקטים מסוג 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())
    }
}

פרטים נוספים מופיעים במסמכי התיעוד של הספרייה.

בדיקת StateFlows

StateFlow הוא מאגר נתונים שניתן לצפייה, שאפשר לאסוף אותו כדי לצפות בערכים שהוא מכיל לאורך זמן כזרם. שימו לב שזרם הערכים הזה הוא מאוחד, כלומר אם ערכים מוגדרים ב-StateFlow במהירות, אין ערובה לכך שאוספי ה-StateFlow יקבלו את כל ערכי הביניים, אלא רק את הערך האחרון.

בבדיקות, אם זוכרים את ההצמדה, אפשר לאסוף את הערכים של StateFlow כמו שאפשר לאסוף כל זרימה אחרת, כולל באמצעות Turbine. במקרים מסוימים, כדאי לנסות לאסוף את כל ערכי הביניים ולבדוק אותם.

עם זאת, בדרך כלל מומלץ להתייחס אל StateFlow כאל מחזיק נתונים ולהצהיר על הנכס value שלו במקום זאת. כך, הבדיקות מאמתות את המצב הנוכחי של האובייקט בנקודת זמן מסוימת, ולא תלויות בשאלה אם מתבצעת השטחה או לא.

לדוגמה, נניח שיש לנו את ViewModel הבא שאוסף ערכים מ-Repository ומציג אותם בממשק המשתמש ב-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 באמצעות ה-fake הזה, אפשר להפיק ערכים מה-fake כדי להפעיל עדכונים ב-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
}

עבודה עם StateFlows שנוצרו על ידי 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, תצטרכו ליצור אוסף. יכול להיות שזה אוסף ריק:

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

מקורות מידע נוספים