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