אופן הבדיקה של יחידות או מודולים שמתקשרים עם flow תלוי בשאלה אם הנושא שנבדק משתמש ב-flow כקלט או כפלט.
- אם הנבדק צופה בתהליך, אפשר ליצור תהליכים בתוך תלות מזויפת שאפשר לשלוט בהם מבדיקות.
- אם היחידה או המודול חושפים זרימה, אפשר לקרוא ולאמת פריט אחד או כמה פריטים שמועברים על ידי הזרימה בבדיקה.
יצירת תוכן מזויף
כשנושא הבדיקה הוא צרכן של רצף פעולות, דרך נפוצה לבדוק אותו היא להחליף את היצרן בהטמעה מזויפת. לדוגמה, אם יש לכם מחלקה שעוקבת אחרי מאגר שמקבל נתונים משני מקורות נתונים בסביבת ייצור:
כדי שהבדיקה תהיה דטרמיניסטית, אפשר להחליף את המאגר ואת התלות שלו במאגר מזויף שתמיד פולט את אותם נתונים מזויפים:
כדי להפיק סדרה מוגדרת מראש של ערכים בתהליך, משתמשים בכלי ליצירת 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 // ... }
עכשיו שיש לכם שליטה על התוצאות של הנושא שנבדק, אתם יכולים לבדוק את התוצאות כדי לוודא שהוא פועל בצורה תקינה.
הצהרה על פליטות בתהליך בעת בדיקה
אם הנושא שנבדק חושף זרימה, הבדיקה צריכה ליצור טענות לגבי הרכיבים של מקור הנתונים.
נניח שהמאגר בדוגמה הקודמת חושף זרימה:
בבדיקות מסוימות, צריך לבדוק רק את הפריט הראשון שמופק או מספר סופי של פריטים שמופקים מהתהליך.
אפשר להשתמש בפליטה הראשונה לתהליך על ידי קריאה ל-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) }
מקורות מידע נוספים
- בדיקת שגרות משנה (coroutines) ב-Kotlin ב-Android
- תהליכים ב-Kotlin ב-Android
-
StateFlowו-SharedFlow - מקורות מידע נוספים על שגרות משנה (coroutines) ועל Flow ב-Kotlin