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

הדרך שבה בודקים יחידות או מודולים שמתקשרים עם 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
    ...
}

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

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

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

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

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

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

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

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

שימוש בטורבינה

פלטפורמת 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())
    }
}

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

בדיקת StateFlows

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

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

עם זאת, באופן כללי מומלץ להתייחס אל 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 עם הזיוף הזה, אפשר לפלוט ערכים להפעיל עדכונים ב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.WhileSubsribed בשימוש לעיתים קרובות ב-ViewModels.

גם אם טענת נכוֹנוּת על 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)
}

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