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

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

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

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

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

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

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

יחסי התלות מוחלפים בהטמעה מזויפת
איור 2. יחסי התלות מוחלפים בהטמעה מזויפת.

כדי להפיק סדרה מוגדרת מראש של ערכים בתהליך, משתמשים ב-builder 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
    ...
}

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

בדיקה של פליטות תהליך בבדיקה

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

נניח שהמאגר של הדוגמה הקודמת חושף תהליך:

מאגר עם יחסי תלות מזויפים שחשפו תהליך
איור 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)
}

במקרים של מקורות נתונים שדורשים אוסף פריטים מורכב יותר או לא מחזירים מספר סופי של פריטים, אפשר להשתמש ב-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 הבאה לבדיקה, ואת ההטמעה הנלווית של מקור הנתונים המזויף שיש לה שיטה 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. בדוגמה הזו, אנחנו אוספים אותם לרשימה ולאחר מכן מבצעים טענות נכוֹנוּת (assertions) על התוכן שלה:

@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 נוח ליצירת קורוטין לאיסוף, וגם תכונות נוחות אחרות לבדיקה של תהליכי 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. יכול להיות שתרצו לנסות לאסוף את כל ערכי הביניים ולבצע טענת נכוֹנוּת (assertion) לגבי כולם בתרחישי בדיקה מסוימים.

עם זאת, בדרך כלל מומלץ להתייחס ל-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, ואז לבצע טענת נכוֹנוּת (assertion) על ה-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)
}

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