סנכרון הבדיקות

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

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

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

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

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions.
    var lastSeenValue = 0 // Used to track recompositions.
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition.

    // Fails because nothing triggered a recomposition.
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition.
    composeTestRule.onNodeWithText("1").assertExists()
}

הערה: הדרישה הזו חלה רק על היררכיות של Compose ולא על שאר האפליקציה.

השבתת הסנכרון האוטומטי

כשקוראים לאסרטיביות או לפעולה באמצעות ComposeTestRule כמו assertExists(), הבדיקה מסונכרנת עם ממשק המשתמש של Compose. במקרים מסוימים, יכול להיות שתרצו להפסיק את הסנכרון הזה ולשלוט בשעון בעצמכם. לדוגמה, אתם יכולים לשלוט בזמן כדי לצלם צילומי מסך מדויקים של אנימציה בנקודה שבה ממשק המשתמש עדיין יהיה עסוק. כדי להשבית את הסנכרון האוטומטי, צריך להגדיר את המאפיין autoAdvance ב-mainClock לערך false:

composeTestRule.mainClock.autoAdvance = false

בדרך כלל, בשלב הזה אתם מקדמים את השעה בעצמכם. אפשר להתקדם בדיוק פריים אחד באמצעות advanceTimeByFrame() או להתקדם למשך זמן ספציפי באמצעות advanceTimeBy():

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

משאבים בהמתנה לפעילות

ב-Compose אפשר לסנכרן בין הבדיקות לבין ממשק המשתמש, כך שכל פעולה וכל טענה מתבצעות במצב המתנה, תוך המתנה או קידום של השעון לפי הצורך. עם זאת, אפשר להריץ ברקע פעולות אסינכרוניות מסוימות שהתוצאות שלהן משפיעות על מצב ממשק המשתמש, בלי שהבדיקה תהיה מודעת להן.

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

ה-API הזה דומה מאוד ל-Idling Resources של Espresso, והוא מציין אם הנושא שנבדק נמצא במצב המתנה או במצב פעיל. משתמשים בכלל הבדיקה Compose כדי לרשום את ההטמעה של IdlingResource.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

סנכרון ידני

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

הפונקציה waitForIdle() ממתינה עד שהרכיב Compose לא פעיל, אבל הפונקציה תלויה במאפיין autoAdvance:

composeTestRule.mainClock.autoAdvance = true // Default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle.

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for idling resources to become idle.

שימו לב שבשני המקרים, waitForIdle() גם מחכה למעברים של ציור ופריסה בהמתנה.

בנוסף, אפשר להריץ את השעון קדימה עד שתנאי מסוים מתקיים באמצעות advanceTimeUntil().

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

הערה: התנאי שצוין צריך לבדוק את המצב שיכול להיות מושפע מהשעון הזה (הוא פועל רק עם מצב Compose).

אופטימיזציה של בדיקות אנימציה

כשבודקים אנימציות באיכות גבוהה, לעיתים קרובות צריך להשבית את ההתקדמות האוטומטית ולעבור בין הפריים באופן ידני כדי לאשר מצבי ביניים של ממשק המשתמש. כדי להריץ את הטענות שלכם בלולאות ספציפיות של פריים אחר פריים, צריך להשתמש בשיטה runWithoutImplicitWait. שאילתות רגילות של צמתים (כמו onNodeWithTag או fetchSemanticsNode) מפעילות סנכרונים מרומזים שהם מיותרים כששולטים בשעון באופן ידני, ולכן דילוג עליהם מקצר באופן משמעותי את משך הזמן של הרצת הבדיקה.

הנחיות שימוש

  • ניהול ידני של השעון: משתמשים ב-API הזה כשהערך של mainClock.autoAdvance מוגדר ל-false וממשק המשתמש נמצא במצב יציב ומוכר עבור המסגרת הנוכחית.
  • הרצה של שרשור UI: כדי להבטיח את היציבות של עץ ממשק המשתמש, צריך לקרוא ל-runWithoutImplicitWait בשרשור UI, למשל באמצעות runOnUiThread. הפעלת הבדיקה מחוץ לשרשור UI חושפת אותה למרוץ תהליכים ולקריאות של מצב לא עדכני.
  • טענות לקריאה בלבד: הבלוק צריך להכיל רק טענות לקריאה בלבד. כל הפעולות שמשנות את המצב צריכות להתבצע מחוץ לבלוק הזה.

דוגמה

@Test
fun runWithoutImplicitWaitSample() = runComposeUiTest {
    setContent { MainScreen() }
    mainClock.autoAdvance = false

    // Trigger an animation
    onNodeWithText("Start Animation").performClick()

    // Step through the animation frame-by-frame
    while (hasPendingWork()) {
        mainClock.advanceTimeByFrame()
        waitForIdle()
        runOnUiThread {
            // Suppress implicit synchronization inside this block to avoid redundant
            // waits on each node query, making the frame assertions execute much faster.
            runWithoutImplicitWait {
                val box1 = onNodeWithTag("Box1").fetchSemanticsNode()
                val box2 = onNodeWithTag("Box2").fetchSemanticsNode()
                val box3 = onNodeWithTag("Box3").fetchSemanticsNode()

                // Assert the exact intermediate state of all three properties for this frame
                assert(box1.boundsInRoot.right <= box2.boundsInRoot.left)
                assert(box2.boundsInRoot.right <= box3.boundsInRoot.left)
            }
        }
    }
}

סנכרון ה-thread הראשי

בדיקות Compose תומכות עכשיו בסנכרון של ה-thread הראשי, כך שאפשר לקרוא בבטחה ל-waitForIdle – ובהרחבה, לפעולות ולאישורים של ממשק המשתמש של Compose – ישירות מה-thread הראשי.

בעבר, בדיקות Compose אכפו באופן קפדני מודל של שני שרשורים: ביצוע הבדיקה התרחש בשרשור בדיקה ברקע, בעוד שעדכוני ממשק המשתמש התרחשו בשרשור הראשי. קריאה לשיטות סנכרון כמו waitForIdle או runOnIdle מה-thread הראשי (לדוגמה, בתוך בלוק runOnUiThread) תגרום לשגיאת IllegalStateException כי המסגרת אוכפת בדיקות thread קפדניות כדי למנוע סנכרון של ה-thread הראשי.

כשסנכרון ה-thread הראשי מופעל, אפשר להשתמש ב-Compose test framework כדי להריץ את השעון ולעבד עבודה בהמתנה, גם כשמתבצעות קריאות חסימה ב-thread הראשי.

מתי כדאי להשתמש בסנכרון של השרשור הראשי

אומנם שמירה של בדיקות ב-thread ברקע היא הסטנדרט לבדיקות Compose טהורות, אבל סנכרון של ה-thread הראשי מועיל מאוד בכמה תרחישים ספציפיים:

  • יכולת פעולה הדדית מורכבת של View: כשבודקים ממשקי משתמש היברידיים שמכילים גם פיתוח נייטיב וגם תצוגות (Views) מדור קודם של Android, לעיתים קרובות נדרשת הפעלה בשרשור הראשי כדי לבצע מניפולציה של תצוגות. עכשיו אפשר לקיים אינטראקציה עם תצוגות ולהצהיר על צמתי Compose ברצף בלי לעבור כל הזמן בין הקשרים של השרשורים.
  • שינויים סינכרוניים במצב: אם הארכיטקטורה שלכם מסתמכת על מחזיקי מצב שקשורים באופן הדוק לשרשור הראשי, עכשיו אתם יכולים לשנות את המצב ולהמתין מיד עד שממשק המשתמש של Compose יתייצב, בלי לצאת מהשרשור הראשי.
  • מריצי בדיקות בהתאמה אישית: אם אתם יוצרים תשתית בדיקות בהתאמה אישית או משתמשים בסביבות שבהן מריץ הבדיקות מופעל באופן מובנה בשרשור הראשי, בדיקות Compose מופעלות עכשיו בצורה נקייה בלי לדרוש העברה לשרשור ברקע.

דוגמה

בעבר, מכיוון שהיה אסור לבצע סנכרון ב-thread הראשי, מפתחים נאלצו לעבור הלוך ושוב בין ה-thread של כלי ההרצה של בדיקות ברקע לבין שרשור UI, מה שהוביל לבדיקות לא רציפות:

@Test
fun testBidirectionalInteropUIUpdates_old() {
    val scenario = launchFragmentInContainer<InteropFragment>()
    composeTestRule.waitForIdle()
    scenario.onFragment { fragment ->
        fragment.legacyButton.performClick()
    }
    // Jump to Test Thread to verify state settles inside compose
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed()
    composeTestRule.onNodeWithText("Increment Legacy TextView").performClick()
    composeTestRule.waitForIdle()
    // Jump back to Main Thread to verify target view state settles
    scenario.onFragment { fragment ->
        assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1")
    }
}

אם מפעילים סנכרון של ה-thread הראשי, אפשר להריץ את הטענות לגבי היררכיות של Compose ו-View באותו בלוק:

@Test
fun testBidirectionalInteropUIUpdates_new() {
    val scenario = launchFragmentInContainer<InteropFragment>()
    composeTestRule.waitForIdle()
    scenario.onFragment { fragment ->
        fragment.legacyButton.performClick()
        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithText("Legacy Clicks: 1").assertIsDisplayed()
        composeTestRule.onNodeWithText("Increment Legacy TextView").performClick()
        composeTestRule.waitForIdle()
        assert(fragment.legacyTextView.text.toString() == "Compose Clicks: 1")
    }
}

המתנה לתנאים

כל תנאי שתלוי בעבודה חיצונית, כמו טעינת נתונים או מדידה או ציור של Android (כלומר, מדידה או ציור חיצוניים ל-Compose), צריך להשתמש במושג כללי יותר כמו waitUntil():

composeTestRule.waitUntil(timeoutMs) { condition }

אפשר גם להשתמש באחד מwaitUntilהעוזרים:

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

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

  • בדיקת אפליקציות ב-Android: דף הנחיתה הראשי בנושא בדיקות ב-Android מספק סקירה רחבה יותר של עקרונות וטכניקות בדיקה.
  • יסודות הבדיקה: מידע נוסף על המושגים הבסיסיים שמאחורי בדיקת אפליקציית Android.
  • בדיקות מקומיות: אתם יכולים להריץ בדיקות מסוימות באופן מקומי, בתחנת העבודה שלכם.
  • בדיקות עם מכשור: מומלץ להריץ גם בדיקות עם מכשור. כלומר, בדיקות שמופעלות ישירות במכשיר.
  • אינטגרציה רציפה (CI): אינטגרציה רציפה מאפשרת לכם לשלב את הבדיקות בצינור הפריסה.
  • בדיקה בגדלים שונים של מסכים: יש למשתמשים הרבה מכשירים שונים, ולכן כדאי לבדוק את האתר בגדלים שונים של מסכים.
  • Espresso: למרות שהיא מיועדת לממשקי משתמש מבוססי-תצוגה, הידע ב-Espresso עדיין יכול לעזור בחלק מההיבטים של בדיקות ב-Compose.