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