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

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

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

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

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

הנושא שנבדק ושכבת הנתונים
איור 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)
}

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

שימוש ב-Turbine

ספריית Turbine של צד שלישי מציעה API נוח ליצירת שגרת המשך (coroutine) לאיסוף, וגם תכונות נוחות אחרות לבדיקת Flows:

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

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