מבוא ל-Test Doubles ול-Dependency Injection

ה-codelab הזה הוא חלק מהקורס Advanced Android in Kotlin (פיתוח מתקדם ל-Android ב-Kotlin). כדי להפיק את המרב מהקורס הזה, מומלץ לעבוד על ה-codelabs לפי הסדר, אבל זה לא חובה. כל ה-codelab של הקורס מפורטים בדף הנחיתה של ה-codelab בנושא Android מתקדם ב-Kotlin.

מבוא

בסדנת ה-codelab השנייה בנושא בדיקות נתמקד בבדיקות כפולות: מתי כדאי להשתמש בהן ב-Android ואיך להטמיע אותן באמצעות הזרקת תלות, תבנית Service Locator וספריות. במהלך התהליך הזה תלמדו איך לכתוב:

  • בדיקות יחידה במאגר
  • בדיקות שילוב של קטעים ושל ViewModel
  • בדיקות ניווט במקטעים

מה שכדאי לדעת

חשוב שתכירו את:

מה תלמדו

  • איך מתכננים אסטרטגיית בדיקה
  • איך ליצור כפילים לבדיקה ולהשתמש בהם, כלומר, זיופים ומוקים
  • איך משתמשים בהזרקת תלות ידנית ב-Android לבדיקות יחידה ובדיקות שילוב
  • איך משתמשים בתבנית Service Locator
  • איך בודקים מאגרי מידע, קטעים, מודלים של תצוגות ורכיב הניווט

תשתמשו בספריות ובמושגי הקוד הבאים:

הפעולות שתבצעו:

  • כתיבת בדיקות יחידה למאגר באמצעות כפיל בדיקה והזרקת תלות.
  • כתיבת בדיקות יחידה למודל תצוגה באמצעות כפיל בדיקה והזרקת תלות.
  • כתיבת בדיקות שילוב עבור פרגמנטים ומודלים של תצוגות שלהם באמצעות מסגרת הבדיקות של ממשק המשתמש Espresso.
  • כתיבת בדיקות ניווט באמצעות Mockito ו-Espresso.

בסדרת ה-codelabs הזו, תעבדו עם אפליקציית TO-DO Notes. האפליקציה מאפשרת לכם לרשום משימות לביצוע ולהציג אותן ברשימה. אחר כך תוכלו לסמן אותן כהשלמה או כלא השלמה, לסנן אותן או למחוק אותן.

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

הורדת הקוד

כדי להתחיל, מורידים את הקוד:

הורדת קובץ Zip

אפשרות אחרת היא לשכפל את מאגר GitHub של הקוד:

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_1

חשוב להקדיש רגע כדי להבין את הקוד, ולפעול לפי ההוראות שבהמשך.

שלב 1: מריצים את אפליקציית הדוגמה

אחרי שמורידים את אפליקציית רשימת המשימות, פותחים אותה ב-Android Studio ומריצים אותה. הקוד אמור לעבור קומפילציה. כדי לבדוק את האפליקציה:

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

שלב 2: בודקים את קוד האפליקציה לדוגמה

אפליקציית רשימת המשימות מבוססת על דגימת הבדיקה והארכיטקטורה של Architecture Blueprints (באמצעות גרסת הארכיטקטורה התגובתית של הדגימה). האפליקציה פועלת לפי הארכיטקטורה שמוסברת במדריך לארכיטקטורת אפליקציות. הוא משתמש ב-ViewModels עם Fragments, במאגר וב-Room. אם אתם מכירים את אחת מהדוגמאות שבהמשך, האפליקציה הזו מבוססת על ארכיטקטורה דומה:

חשוב יותר להבין את הארכיטקטורה הכללית של האפליקציה מאשר להבין לעומק את הלוגיקה בכל אחת מהשכבות.

סיכום החבילות שזמינות:

חבילה: com.example.android.architecture.blueprints.todoapp

.addedittask

מסך הוספה או עריכה של משימה: קוד שכבת ממשק המשתמש להוספה או לעריכה של משימה.

.data

שכבת הנתונים: השכבה הזו עוסקת בשכבת הנתונים של המשימות. הוא מכיל את מסד הנתונים, הרשת וקוד המאגר.

.statistics

מסך הנתונים הסטטיסטיים: קוד שכבת ממשק המשתמש של מסך הנתונים הסטטיסטיים.

.taskdetail

מסך פרטי המשימה: קוד שכבת ממשק המשתמש של משימה אחת.

.tasks

מסך המשימות: קוד שכבת ממשק המשתמש לרשימה של כל המשימות.

.util

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

שכבת נתונים (‎.data)

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

השכבה DefaultTasksRepository מתאמת או מתווכת בין שכבת הרשת לשכבת מסד הנתונים, והיא זו שמחזירה נתונים לשכבת ממשק המשתמש.

שכבת ממשק המשתמש (‎.addedittask, .statistics, .taskdetail, .tasks)

כל אחת מהחבילות של שכבת ממשק המשתמש מכילה קטע ו-ViewModel, וגם כל מחלקה אחרת שנדרשת לממשק המשתמש (כמו מתאם לרשימת המשימות). ‫TaskActivity היא הפעילות שמכילה את כל המקטעים.

ניווט

הניווט באפליקציה נשלט על ידי רכיב הניווט. הוא מוגדר בקובץ nav_graph.xml. הניווט מופעל במודלים של התצוגה באמצעות המחלקה Event. המודלים של התצוגה גם קובעים אילו ארגומנטים להעביר. הקטעים עוקבים אחרי ה-Event ומבצעים את הניווט בפועל בין המסכים.

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

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

פירמידת הבדיקות

כשחושבים על אסטרטגיית בדיקות, יש שלושה היבטים קשורים של בדיקות:

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

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

  • בדיקות יחידה – אלה בדיקות ממוקדות מאוד שמופעלות על מחלקה אחת, בדרך כלל על שיטה אחת במחלקה הזו. אם בדיקת יחידה נכשלת, אפשר לדעת בדיוק איפה הבעיה בקוד. רמת הדיוק שלהן נמוכה כי בעולם האמיתי, האפליקציה כוללת הרבה יותר מהרצה של שיטה או מחלקה אחת. הם מהירים מספיק כדי לפעול בכל פעם שמשנים את הקוד. ברוב המקרים אלה יהיו בדיקות שמופעלות באופן מקומי (בtest source set). דוגמה: בדיקה של שיטות יחידות במאגרי תצוגות ובמאגרים.
  • בדיקות שילוב – הבדיקות האלה בודקות את האינטראקציה בין כמה מחלקות כדי לוודא שהן מתנהגות כמצופה כשמשתמשים בהן יחד. אחת הדרכים לבנות בדיקות שילוב היא לבדוק תכונה אחת, כמו היכולת לשמור משימה. הן בודקות היקף קוד גדול יותר מבדיקות יחידה, אבל הן עדיין מותאמות לפעולה מהירה, לעומת נאמנות מלאה. אפשר להריץ אותם באופן מקומי או כבדיקות מכשור, בהתאם למצב. דוגמה: בדיקת כל הפונקציונליות של צמד יחיד של fragment ו-view model.
  • בדיקות מקצה לקצה (E2e) – בדיקה של שילוב בין תכונות שפועלות יחד. הם בודקים חלקים גדולים של האפליקציה, מדמים שימוש אמיתי בצורה מדויקת ולכן בדרך כלל הם איטיים. הם הכי מדויקים ומראים שהאפליקציה שלכם באמת פועלת כמכלול. בדרך כלל, הבדיקות האלה יהיו בדיקות עם מכשור (בandroidTest ערכת המקור)
    דוגמה: הפעלה של האפליקציה כולה ובדיקה של כמה תכונות ביחד.

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

ארכיטקטורה ובדיקות

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

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



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

  1. קודם כול, תבצעו בדיקת יחידה של מאגר.
  2. לאחר מכן תשתמשו ב-test double במודל התצוגה, שנדרש לבדיקת יחידות ולבדיקת שילוב של מודל התצוגה.
  3. בשלב הבא תלמדו לכתוב בדיקות שילוב לקטעי קוד ולמודלים של תצוגות.
  4. בסוף, תלמדו לכתוב בדיקות אינטגרציה שכוללות את רכיב הניווט.

בשיעור הבא נסביר על בדיקות מקצה לקצה.

כשכותבים בדיקת יחידה לחלק ממחלקה (שיטה או אוסף קטן של שיטות), המטרה היא לבדוק רק את הקוד במחלקה הזו.

יכול להיות שיהיה לכם קשה לבדוק רק קוד בכיתה ספציפית או בכיתות ספציפיות. נתבונן בדוגמה. פותחים את הכיתה data.source.DefaultTaskRepository במערך המקור main. זהו המאגר של האפליקציה, והוא המחלקה שבה תכתבו את בדיקות היחידה הבאות.

המטרה היא לבדוק רק את הקוד בכיתה הזו. עם זאת, הפונקציה DefaultTaskRepository תלויה בפונקציות אחרות, כמו LocalTaskDataSource ו-RemoteTaskDataSource, כדי לפעול. אפשר גם לומר ש-LocalTaskDataSource ו-RemoteTaskDataSource הם תלויות של DefaultTaskRepository.

לכן, כל שיטה ב-DefaultTaskRepository קוראת לשיטות במחלקות של מקורות נתונים, שבתורן קוראות לשיטות במחלקות אחרות כדי לשמור מידע במסד נתונים או לתקשר עם הרשת.



לדוגמה, אפשר לעיין בשיטה הזו ב-DefaultTasksRepo.

    suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>> {
        if (forceUpdate) {
            try {
                updateTasksFromRemoteDataSource()
            } catch (ex: Exception) {
                return Result.Error(ex)
            }
        }
        return tasksLocalDataSource.getTasks()
    }

getTasks היא אחת מהקריאות ה "בסיסיות" ביותר שאפשר לבצע למאגר. השיטה הזו כוללת קריאה ממסד נתונים של SQLite וביצוע קריאות לרשת (הקריאה אל updateTasksFromRemoteDataSource). היא כוללת הרבה יותר קוד מאשר רק קוד המאגר.

הנה כמה סיבות ספציפיות לכך שקשה לבדוק את המאגר:

  • כדי לבצע אפילו את הבדיקות הפשוטות ביותר במאגר הזה, צריך לחשוב על יצירה וניהול של מסד נתונים. זה מעלה שאלות כמו "האם זו צריכה להיות בדיקה מקומית או בדיקה עם מכשור?" והאם כדאי להשתמש ב-AndroidX Test כדי לקבל סביבת Android מדומה.
  • חלקים מסוימים בקוד, כמו קוד הרשת, יכולים לפעול במשך זמן רב, או אפילו להיכשל מדי פעם, וכך ליצור בדיקות ארוכות ולא יציבות.
  • הבדיקות שלכם עלולות לאבד את היכולת לאבחן איזה קוד אחראי לכשל בבדיקה. הבדיקות שלכם יכולות להתחיל לבדוק קוד שלא נמצא במאגר, ולכן, לדוגמה, בדיקות היחידות שמוגדרות כ'מאגר' עלולות להיכשל בגלל בעיה בחלק מהקוד התלוי, כמו קוד מסד הנתונים.

Test Doubles

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

אלה כמה סוגים של כפילים לבדיקה:

מזויף

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

Mock

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

Stub

כפיל בדיקה שלא כולל לוגיקה ומחזיר רק את מה שתוכנתם להחזיר. אפשר לתכנת StubTaskRepository כך שיחזיר שילובים מסוימים של משימות מ-getTasks, למשל.

Dummy

כפיל בדיקה שמועבר אבל לא בשימוש, למשל אם צריך לספק אותו כפרמטר. אם היה לכם NoOpTaskRepository, הוא פשוט היה מטמיע את TaskRepository ללא קוד באף אחת מהשיטות.

Spy

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

מידע נוסף על כפילים לבדיקה זמין במאמר Testing on the Toilet: Know Your Test Doubles.

הכפילים הנפוצים ביותר לבדיקה ב-Android הם Fakes ו-Mocks.

במשימה הזו תיצרו FakeDataSource כפילת בדיקה כדי לבצע בדיקת יחידה של DefaultTasksRepository בניתוק ממקורות הנתונים בפועל.

שלב 1: יוצרים את המחלקה FakeDataSource

בשלב הזה תיצרו מחלקה בשם FakeDataSouce, שתהיה כפילה לבדיקה של LocalDataSource ושל RemoteDataSource.

  1. בסט המקורות test, לוחצים לחיצה ימנית ובוחרים באפשרות New -> Package (חדש -> חבילה).

  1. יוצרים חבילת נתונים עם חבילת מקור בתוכה.
  2. יוצרים מחלקה חדשה בשם FakeDataSource בחבילה data/source.

שלב 2: הטמעה של הממשק TasksDataSource

כדי שתוכלו להשתמש בכיתה החדשה FakeDataSource ככפיל לבדיקה, היא צריכה להיות מסוגלת להחליף את מקורות הנתונים האחרים. מקורות הנתונים האלה הם TasksLocalDataSource ו-TasksRemoteDataSource.

  1. שימו לב ששני הקודים האלה מטמיעים את הממשק TasksDataSource.
class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }
  1. גורמים ל-FakeDataSource להטמיע את TasksDataSource:
class FakeDataSource : TasksDataSource {

}

מערכת Android Studio תתריע שלא הטמעתם את ה-methods הנדרשים עבור TasksDataSource.

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


  1. בוחרים את כל השיטות ולוחצים על אישור.

שלב 3: מטמיעים את השיטה getTasks ב-FakeDataSource

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

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

FakeDataSource

  • מאפשרת לכם לבדוק את הקוד ב-DefaultTasksRepository בלי להסתמך על מסד נתונים או רשת אמיתיים.
  • מספק הטמעה 'מספיק אמיתית' לבדיקות.
  1. משנים את הבונה FakeDataSource כדי ליצור var בשם tasks שהוא MutableList<Task>? עם ערך ברירת מחדל של רשימה ריקה שניתנת לשינוי.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


זו רשימת המשימות שמתחזות לתגובה של מסד נתונים או שרת. בינתיים, המטרה היא לבדוק את שיטת getTasks של המאגר . הפעולה הזו קוראת לשיטות getTasks, deleteAllTasks ו-saveTask של מקור הנתונים .

תכתוב גרסה מזויפת של השיטות האלה:

  1. ‫Write getTasks: If tasks isn't null, return a Success result. אם tasks הוא null, מחזירה תוצאה של Error.
  2. כתיבה של deleteAllTasks: ניקוי רשימת המשימות שניתנות לשינוי.
  3. כותבים saveTask: המשימה תתווסף לרשימה.

השיטות האלה, שמוטמעות עבור FakeDataSource, נראות כמו הקוד שבהמשך.

override suspend fun getTasks(): Result<List<Task>> {
    tasks?.let { return Success(ArrayList(it)) }
    return Error(
        Exception("Tasks not found")
    )
}


override suspend fun deleteAllTasks() {
    tasks?.clear()
}

override suspend fun saveTask(task: Task) {
    tasks?.add(task)
}

הצהרות הייבוא שצריך להוסיף:

import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task

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

בשלב הזה, תשתמשו בטכניקה שנקראת הזרקת תלות ידנית כדי שתוכלו להשתמש ב-test double המזויף שיצרתם.

הבעיה העיקרית היא שיש לך FakeDataSource, אבל לא ברור איך את משתמשת בו בבדיקות. היא צריכה להחליף את TasksRemoteDataSource ואת TasksLocalDataSource, אבל רק בבדיקות. המחלקות TasksRemoteDataSource ו-TasksLocalDataSource הן תלויות של DefaultTasksRepository, כלומר המחלקה DefaultTasksRepositories דורשת את המחלקות האלה או "תלויה" בהן כדי לפעול.

בשלב הזה, יחסי התלות נוצרים בתוך השיטה init של DefaultTasksRepository.

DefaultTasksRepository.kt

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

    init {
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }
    // Rest of class
}

מכיוון שאתם יוצרים ומקצים את taskLocalDataSource ו-tasksRemoteDataSource בתוך DefaultTasksRepository, הם למעשה מוצפנים. אין אפשרות להחליף את הכפיל של הבדיקה.

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

הזרקת תלות של Constructor מאפשרת להחליף את הכפיל לבדיקה על ידי העברתו אל ה-constructor.

No injection

החדרה

שלב 1: שימוש בהזרקת תלות של Constructor ב-DefaultTasksRepository

  1. משנים את ה-constructor של DefaultTaskRepository כך שיקבל גם את מקורות הנתונים וגם את ה-dispatcher של ה-Coroutine (תצטרכו גם להחליף אותו בבדיקות – זה מתואר בפירוט רב יותר בקטע השלישי של השיעור בנושא Coroutines).Application

DefaultTasksRepository.kt

// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }

// WITH

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
  1. מכיוון שהעברת את התלויות, צריך להסיר את השיטה init. כבר לא צריך ליצור את התלות.
  2. מוחקים גם את משתני המופע הישנים. אתם מגדירים אותם ב-constructor:

DefaultTasksRepository.kt

// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
  1. לבסוף, מעדכנים את השיטה getRepository כדי להשתמש בבונה החדש:

DefaultTasksRepository.kt

    companion object {
        @Volatile
        private var INSTANCE: DefaultTasksRepository? = null

        fun getRepository(app: Application): DefaultTasksRepository {
            return INSTANCE ?: synchronized(this) {
                val database = Room.databaseBuilder(app,
                    ToDoDatabase::class.java, "Tasks.db")
                    .build()
                DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                    INSTANCE = it
                }
            }
        }
    }

אתם משתמשים עכשיו בהזרקת תלות של בנאי!

שלב 2: שימוש ב-FakeDataSource בבדיקות

עכשיו, אחרי שהקוד משתמש בהזרקת תלות של בנאי, אפשר להשתמש במקור הנתונים המזויף כדי לבדוק את DefaultTasksRepository.

  1. לוחצים לחיצה ימנית על DefaultTasksRepository שם המחלקה ובוחרים באפשרות יצירה ואז באפשרות בדיקה.
  2. פועלים לפי ההוראות כדי ליצור DefaultTasksRepositoryTest בערכת המקורות test.
  3. בחלק העליון של המחלקה החדשה DefaultTasksRepositoryTest, מוסיפים את משתני החברים שבהמשך כדי לייצג את הנתונים במקורות הנתונים המזויפים.

DefaultTasksRepositoryTest.kt

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }
  1. יוצרים שלושה משתנים, שני FakeDataSource משתני חברים (אחד לכל מקור נתונים במאגר) ומשתנה ל-DefaultTasksRepository שייבדק.

DefaultTasksRepositoryTest.kt

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

יוצרים שיטה להגדרה ולאתחול של DefaultTasksRepository שאפשר לבדוק. ה-DefaultTasksRepository הזה ישתמש בכפילת הבדיקה שלך, FakeDataSource.

  1. יוצרים שיטה בשם createRepository ומוסיפים לה את ההערה @Before.
  2. יוצרים מופעים של מקורות הנתונים המזויפים באמצעות הרשימות remoteTasks ו-localTasks.
  3. מפעילים את tasksRepository באמצעות שני מקורות הנתונים המזויפים שיצרתם ו-Dispatchers.Unconfined.

השיטה הסופית צריכה להיראות כמו הקוד שבהמשך.

DefaultTasksRepositoryTest.kt

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

שלב 3: כותבים את הבדיקה DefaultTasksRepository getTasks()

הגיע הזמן לכתוב מבחן DefaultTasksRepository!

  1. כותבים בדיקה לשיטה getTasks במאגר. בודקים שכאשר קוראים ל-getTasks עם true (כלומר, צריך לטעון מחדש ממקור הנתונים המרוחק) מוחזרים נתונים ממקור הנתונים המרוחק (ולא ממקור הנתונים המקומי).

DefaultTasksRepositoryTest.kt

@Test
    fun getTasks_requestsAllTasksFromRemoteDataSource(){
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

תופיע שגיאה כשמתקשרים אל getTasks:

שלב 4: מוסיפים את הפונקציה runBlockingTest

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

  1. מוסיפים את התלויות הנדרשות לבדיקת קורוטינות למערך מקור הבדיקה באמצעות testImplementation.

app/build.gradle

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

אל תשכחו לסנכרן!

kotlinx-coroutines-test היא ספריית הבדיקות של קורוטינות, שמיועדת במיוחד לבדיקת קורוטינות. כדי להריץ את הבדיקות, משתמשים בפונקציה runBlockingTest. זו פונקציה שמסופקת על ידי ספריית הבדיקה של קורוטינות. הוא מקבל בלוק קוד ואז מריץ את בלוק הקוד הזה בהקשר מיוחד של קורוטינה שפועלת באופן סינכרוני ומיידי, כלומר הפעולות יתרחשו בסדר דטרמיניסטי. הפעולה הזו גורמת לקורוטינות לפעול כמו פונקציות רגילות, ולכן היא מיועדת לבדיקת קוד.

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

  1. מוסיפים את @ExperimentalCoroutinesApi מעל הכיתה. ההצהרה הזו מציינת שאתם יודעים שאתם משתמשים בממשק API ניסיוני של קורוטינה (runBlockingTest) במחלקה. אם לא תעשו זאת, תקבלו אזהרה.
  2. חוזרים אל DefaultTasksRepositoryTest ומוסיפים runBlockingTest כדי שהבדיקה כולה תתקבל כ'בלוק' של קוד

הבדיקה הסופית נראית כמו הקוד שבהמשך.

DefaultTasksRepositoryTest.kt

import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.core.IsEqual
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test


@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

}
  1. מריצים את הבדיקה החדשה getTasks_requestsAllTasksFromRemoteDataSource ומוודאים שהיא פועלת והשגיאה נעלמה.

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

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

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

במשימה הזו תשתמשו בהזרקת תלות כדי להציג מודלים.

שלב 1. יצירת ממשק TasksRepository

השלב הראשון בשימוש בהזרקת תלות של בנאי הוא ליצור ממשק משותף בין המחלקה המזויפת למחלקה האמיתית.

איך זה נראה בפועל? אפשר לראות שTasksRemoteDataSource, ‏TasksLocalDataSource ו-FakeDataSource חולקים את אותו ממשק: TasksDataSource. כך אפשר לציין בקונסטרוקטור של DefaultTasksRepository שאתם מקבלים TasksDataSource.

DefaultTasksRepository.kt

class DefaultTasksRepository(
   private val tasksRemoteDataSource: TasksDataSource,
   private val tasksLocalDataSource: TasksDataSource,
   private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {

כך אנחנו יכולים להחליף את FakeDataSource שלכם!

לאחר מכן, יוצרים ממשק ל-DefaultTasksRepository, כמו שעשיתם למקורות הנתונים. היא צריכה לכלול את כל השיטות הציבוריות (public API surface) של DefaultTasksRepository.

  1. פותחים את DefaultTasksRepository ולוחצים לחיצה ימנית על שם הכיתה. לאחר מכן בוחרים באפשרות Refactor -> Extract -> Interface (שינוי מבנה -> חילוץ -> ממשק).

  1. בוחרים באפשרות חילוץ לקובץ נפרד.

  1. בחלון Extract Interface, משנים את שם הממשק ל-TasksRepository.
  2. בקטע Members to form interface, מסמנים את כל החברים חוץ משני החברים במצב Companion והשיטות private.


  1. לוחצים על Refactor (שיפור קוד). ממשק TasksRepository החדש אמור להופיע בחבילה data/source .

ועכשיו DefaultTasksRepository מטמיע את TasksRepository.

  1. מריצים את האפליקציה (לא את הבדיקות) כדי לוודא שהכול עדיין תקין.

שלב 2. יצירת FakeTestRepository

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

  1. במערך מקורות הבדיקה, בתיקייה data/source, יוצרים את קובץ ה-Kotlin ואת המחלקה FakeTestRepository.kt ומרחיבים מהממשק TasksRepository.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

תקבלו הודעה שאתם צריכים להטמיע את שיטות הממשק.

  1. מעבירים את העכבר מעל השגיאה עד שמופיע תפריט ההצעות, ואז לוחצים על Implement members (הטמעה של חברים).
  1. בוחרים את כל השיטות ולוחצים על אישור.

שלב 3. הטמעה של שיטות FakeTestRepository

עכשיו יש לך מחלקה FakeTestRepository עם שיטות שלא יושמו. בדומה לאופן שבו הטמעתם את FakeDataSource, ה-FakeTestRepository יגובה על ידי מבנה נתונים, במקום להתמודד עם תיווך מסובך בין מקורות נתונים מקומיים ומרוחקים.

שימו לב: FakeTestRepository לא צריך להשתמש ב-FakeDataSource או במשהו דומה. הוא רק צריך להחזיר פלט מזויף ריאליסטי בהינתן קלט. תשתמשו ב-LinkedHashMap כדי לאחסן את רשימת המשימות וב-MutableLiveData כדי לעקוב אחרי המשימות.

  1. ב-FakeTestRepository, מוסיפים גם משתנה LinkedHashMap שמייצג את רשימת המשימות הנוכחית וגם MutableLiveData למשימות שניתן לצפות בהן.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()


    // Rest of class
}

מטמיעים את השיטות הבאות:

  1. getTasks—השיטה הזו צריכה לקחת את tasksServiceData ולהפוך אותו לרשימה באמצעות tasksServiceData.values.toList() ואז להחזיר את הרשימה כתוצאה Success.
  2. refreshTasks – מעדכן את הערך של observableTasks לערך שמוחזר על ידי getTasks().
  3. observeTasks – יוצר קורוטינה באמצעות runBlocking ומריץ את refreshTasks, ואז מחזיר את observableTasks.

בהמשך מופיע הקוד של השיטות האלה.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        return Result.Success(tasksServiceData.values.toList())
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    // Rest of class

}

שלב 4. הוספת שיטה לבדיקה אל addTasks

כשבודקים, עדיף שיהיו כבר כמה Tasks במאגר. אפשר לקרוא ל-saveTask כמה פעמים, אבל כדי לפשט את התהליך, כדאי להוסיף שיטת עזר במיוחד לבדיקות, שתאפשר לכם להוסיף משימות.

  1. מוסיפים את השיטה addTasks, שמקבלת vararg של משימות, מוסיפה כל אחת מהן ל-HashMap ואז מרעננת את המשימות.

FakeTestRepository.kt

    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }

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

במשימה הזו תשתמשו במחלקה מזויפת בתוך ViewModel. משתמשים בהזרקת תלות של בנאי כדי לקבל את שני מקורות הנתונים באמצעות הזרקת תלות של בנאי. לשם כך, מוסיפים משתנה TasksRepository לבנאי של TasksViewModel.

התהליך הזה קצת שונה כשמדובר במודלים של תצוגות, כי לא בונים אותם ישירות. לדוגמה:

class TasksFragment : Fragment() {

    private val viewModel by viewModels<TasksViewModel>()
    
    // Rest of class...

}


כמו בקוד שלמעלה, אתם משתמשים בviewModel's property delegate שיוצר את מודל התצוגה. כדי לשנות את אופן הבנייה של מודל התצוגה, צריך להוסיף ולהשתמש ב-ViewModelProvider.Factory. אם אתם לא מכירים את ViewModelProvider.Factory, תוכלו לקרוא מידע נוסף על כאן.

שלב 1. יצירה של ViewModelFactory ושימוש בו ב-TasksViewModel

מתחילים בעדכון הכיתות והמבחן שקשורים למסך Tasks.

  1. פותחים את TasksViewModel.
  2. משנים את ה-constructor של TasksViewModel כך שיקבל את TasksRepository במקום ליצור אותו בתוך המחלקה.

TasksViewModel.kt

// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() { 
    // Rest of class 
}

שינית את ה-constructor, ולכן עכשיו צריך להשתמש ב-factory כדי ליצור את TasksViewModel. ממקמים את מחלקת היצרן באותו קובץ כמו TasksViewModel, אבל אפשר גם למקם אותה בקובץ משלה.

  1. בתחתית הקובץ TasksViewModel, מחוץ לכיתה, מוסיפים TasksViewModelFactory שמקבל TasksRepository רגיל.

TasksViewModel.kt

@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TasksViewModel(tasksRepository) as T)
}


זו הדרך הרגילה לשנות את האופן שבו נוצרים ViewModel. עכשיו, אחרי שיש לכם את ה-factory, אתם יכולים להשתמש בו בכל מקום שבו אתם בונים את מודל התצוגה.

  1. צריך לעדכן את TasksFragment כדי להשתמש במפעל.

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TasksViewModel>()

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. מריצים את הקוד של האפליקציה ומוודאים שהכול עדיין עובד.

שלב 2. שימוש ב-FakeTestRepository בתוך TasksViewModelTest

עכשיו, במקום להשתמש במאגר האמיתי בבדיקות של מודל התצוגה, אפשר להשתמש במאגר המזויף.

  1. פותחים את TasksViewModelTest.
  2. מוסיפים נכס FakeTestRepository ב-TasksViewModelTest.

TaskViewModelTest.kt

@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

    // Use a fake repository to be injected into the viewmodel
    private lateinit var tasksRepository: FakeTestRepository
    
    // Rest of class
}
  1. מעדכנים את השיטה setupViewModel כדי ליצור FakeTestRepository עם שלוש משימות, ואז בונים את tasksViewModel עם המאגר הזה.

TasksViewModelTest.kt

    @Before
    fun setupViewModel() {
        // We initialise the tasks to 3, with one active and two completed
        tasksRepository = FakeTestRepository()
        val task1 = Task("Title1", "Description1")
        val task2 = Task("Title2", "Description2", true)
        val task3 = Task("Title3", "Description3", true)
        tasksRepository.addTasks(task1, task2, task3)

        tasksViewModel = TasksViewModel(tasksRepository)
        
    }
  1. מכיוון שאתם כבר לא משתמשים בקוד AndroidX Test ApplicationProvider.getApplicationContext, אתם יכולים גם להסיר את ההערה @RunWith(AndroidJUnit4::class).
  2. מריצים את הבדיקות ומוודאים שכולן עדיין פועלות.

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

שלב 3. עדכון גם של TaskDetail Fragment ו-ViewModel

מבצעים את אותם שינויים בדיוק במאפיינים TaskDetailFragment ו-TaskDetailViewModel. כך הקוד יהיה מוכן כשכותבים TaskDetail tests בפעם הבאה.

  1. פותחים את TaskDetailViewModel.
  2. מעדכנים את ה-constructor:

TaskDetailViewModel.kt

// REPLACE
class TaskDetailViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TaskDetailViewModel(
    private val tasksRepository: TasksRepository
) : ViewModel() { // Rest of class }
  1. בתחתית הקובץ TaskDetailViewModel, מחוץ לכיתה, מוסיפים TaskDetailViewModelFactory.

TaskDetailViewModel.kt

@Suppress("UNCHECKED_CAST")
class TaskDetailViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TaskDetailViewModel(tasksRepository) as T)
}
  1. צריך לעדכן את TasksFragment כדי להשתמש במפעל.

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. מריצים את הקוד ומוודאים שהכול עובד.

עכשיו אפשר להשתמש ב-FakeTestRepository במקום במאגר האמיתי ב-TasksFragment וב-TasksDetailFragment.

בשלב הבא תכתבו בדיקות שילוב כדי לבדוק את האינטראקציות בין ה-Fragment לבין ה-ViewModel. תוכלו לדעת אם קוד מודל התצוגה מעדכן את ממשק המשתמש בצורה מתאימה. כדי לעשות את זה, משתמשים

  • הדפוס ServiceLocator
  • הספריות Espresso ו-Mockito

בדיקות שילוב בודקות את האינטראקציה בין כמה מחלקות כדי לוודא שהן מתנהגות כמצופה כשמשתמשים בהן יחד. אפשר להריץ את הבדיקות האלה באופן מקומי (test source set) או כבדיקות מכשור (androidTest source set).

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

שלב 1. הוספת יחסי תלות ב-Gradle

  1. מוסיפים את יחסי התלות הבאים של Gradle.

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "junit:junit:$junitVersion"
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

    // Testing code should not be included in the main code.
    // Once https://issuetracker.google.com/128612536 is fixed this can be fixed.

    implementation "androidx.fragment:fragment-testing:$fragmentVersion"
    implementation "androidx.test:core:$androidXTestCoreVersion"

יחסי התלות האלה כוללים:

  • junit:junit—JUnit, שנדרש לכתיבת הצהרות בדיקה בסיסיות.
  • androidx.test:core—Core AndroidX test library
  • kotlinx-coroutines-test – ספריית הבדיקות של שגרות המשנה
  • androidx.fragment:fragment-testing—ספריית הבדיקה AndroidX ליצירת רכיבי Fragment בבדיקות ולשינוי המצב שלהם.

מכיוון שתשתמשו בספריות האלה בערכת המקורות androidTest, צריך להשתמש ב-androidTestImplementation כדי להוסיף אותן כתלויות.

שלב 2. יצירת מחלקה TaskDetailFragmentTest

ב-TaskDetailFragment מוצג מידע על משימה אחת.

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

  1. פותחים את taskdetail.TaskDetailFragment.
  2. תצור בדיקה ל-TaskDetailFragment, כמו שעשית קודם. מאשרים את ברירות המחדל ומציבים אותו בערכת המקור androidTest (ולא בערכת המקור test).

  1. מוסיפים את ההערות הבאות לכיתה TaskDetailFragmentTest.

TaskDetailFragmentTest.kt

@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

}

מטרת ההערות האלה היא:

  • @MediumTest – מציין שהבדיקה היא בדיקת אינטגרציה עם זמן ריצה בינוני (לעומת בדיקות יחידה @SmallTest ובדיקות מקצה לקצה @LargeTest). כך תוכלו לקבץ את הבדיקות ולבחור את הגודל שלהן.
  • @RunWith(AndroidJUnit4::class) – משמש בכל כיתה שמשתמשת ב-AndroidX Test.

שלב 3. הפעלת קטע מתוך בדיקה

במשימה הזו, תפעילו את TaskDetailFragment באמצעות ספריית הבדיקות של AndroidX. ‫FragmentScenario היא מחלקה מ-AndroidX Test שעוטפת קטע ומאפשרת לכם שליטה ישירה במחזור החיים של הקטע לצורך בדיקה. כדי לכתוב בדיקות עבור קטעים, יוצרים FragmentScenario עבור הקטע שרוצים לבדוק (TaskDetailFragment).

  1. מעתיקים את הבדיקה הזו אל TaskDetailFragmentTest.

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

הקוד שלמעלה:

  • יוצר משימה.
  • יוצרת Bundle, שמייצגת את הארגומנטים של הקטע במשימה שמועברים לקטע).
  • הפונקציה launchFragmentInContainer יוצרת FragmentScenario, עם החבילה הזו ועם עיצוב.

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

  1. זו בדיקה עם מכשור, לכן חשוב לוודא שהאמולטור או המכשיר גלויים.
  2. מריצים את הבדיקה.

צריכים לקרות כמה דברים.

  • קודם כל, מכיוון שמדובר בבדיקה עם מכשור, הבדיקה תפעל במכשיר הפיזי (אם הוא מחובר) או באמולטור.
  • הפרגמנט אמור להיפתח.
  • שימו לב שלא עוברים דרך אף פרגמנט אחר ואין תפריטים שמשויכים לפעילות – זה רק הפרגמנט.

לבסוף, בוחנים את הקטע מקרוב ורואים שכתוב בו 'אין נתונים', כי נתוני המשימה לא נטענו בהצלחה.

הבדיקה צריכה לטעון את TaskDetailFragment (כפי שעשיתם) ולוודא שהנתונים נטענו בצורה נכונה. למה אין נתונים? הסיבה לכך היא שיצרתם משימה, אבל לא שמרתם אותה במאגר.

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // This DOES NOT save the task anywhere
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

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

במשימה הזו תספקו את מאגר הדמה שלכם לשבר באמצעות ServiceLocator. כך תוכלו לכתוב את הקטע ולראות את בדיקות השילוב של המודל.

אי אפשר להשתמש כאן בהזרקת תלות של בנאי, כמו שעשיתם קודם, כשנדרש לספק תלות במודל התצוגה או במאגר. הזרקת תלות של בנאי מחייבת בנייה של המחלקה. ‫Fragments ו-activities הן דוגמאות למחלקות שאתם לא בונים ובדרך כלל אין לכם גישה לבונה שלהן.

מכיוון שלא בונים את הפרגמנט, אי אפשר להשתמש בהזרקת תלות של בנאי כדי להחליף את כפיל הבדיקה של המאגר (FakeTestRepository) בפרגמנט. במקום זאת, כדאי להשתמש בתבנית Service Locator. התבנית Service Locator היא חלופה ל-Dependency Injection. היא כוללת יצירה של מחלקה יחידה (singleton) שנקראת Service Locator, שמטרתה לספק תלות, גם לקוד הרגיל וגם לקוד הבדיקה. בקוד האפליקציה הרגיל (קבוצת המקור main), כל התלויות האלה הן תלויות רגילות של האפליקציה. לצורך הבדיקות, משנים את Service Locator כדי לספק גרסאות כפולות של התלויות.

לא משתמשים בכלי לאיתור שירותים


שימוש בכלי לאיתור שירותים

באפליקציית ה-Codelab הזו, מבצעים את הפעולות הבאות:

  1. יוצרים מחלקה של Service Locator שיכולה ליצור ולאחסן מאגר. כברירת מחדל, הוא יוצר מאגר 'רגיל'.
  2. משנים את המבנה של הקוד כך שכשתזדקקו למאגר, תשתמשו ב-Service Locator.
  3. במחלקת הבדיקה, קוראים לשיטה ב-Service Locator שמחליפה את המאגר 'הרגיל' ב-test double.

שלב 1. יצירת ServiceLocator

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

הערה: ServiceLocator הוא סינגלטון, ולכן צריך להשתמש במילת המפתח Kotlin object עבור המחלקה.

  1. יוצרים את הקובץ ServiceLocator.kt ברמה העליונה של קבוצת המקורות הראשית.
  2. הגדרת object בשם object.ServiceLocator
  3. יוצרים משתני מופע database ו-repository ומגדירים את שניהם ל-null.
  4. מוסיפים את ההערה @Volatile למאגר כי יכול להיות שייעשה בו שימוש בכמה שרשורים (ההסבר המפורט על @Volatile מופיע כאן).

הקוד שלכם צריך להיראות כמו הקוד שמוצג למטה.

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

}

בשלב הזה, ServiceLocator צריך רק לדעת איך להחזיר TasksRepository. הפונקציה תחזיר DefaultTasksRepository קיים או תיצור DefaultTasksRepository חדש ותחזיר אותו, אם צריך.

מגדירים את הפונקציות הבאות:

  1. provideTasksRepository – מספק מאגר קיים או יוצר מאגר חדש. צריך להשתמש בשיטה הזו synchronized ב-this כדי למנוע יצירה של שני מופעים של מאגר בטעות במצבים שבהם פועלים כמה שרשורים.
  2. createTasksRepository – קוד ליצירת מאגר חדש. תתבצע שיחה אל createTaskLocalDataSource וייווצר TasksRemoteDataSource חדש.
  3. createTaskLocalDataSource – קוד ליצירת מקור נתונים מקומי חדש. תתבצע התקשרות אל createDataBase.
  4. createDataBase – קוד ליצירת מסד נתונים חדש.

הקוד המלא מופיע בהמשך.

ServiceLocator.kt

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

    fun provideTasksRepository(context: Context): TasksRepository {
        synchronized(this) {
            return tasksRepository ?: createTasksRepository(context)
        }
    }

    private fun createTasksRepository(context: Context): TasksRepository {
        val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
        tasksRepository = newRepo
        return newRepo
    }

    private fun createTaskLocalDataSource(context: Context): TasksDataSource {
        val database = database ?: createDataBase(context)
        return TasksLocalDataSource(database.taskDao())
    }

    private fun createDataBase(context: Context): ToDoDatabase {
        val result = Room.databaseBuilder(
            context.applicationContext,
            ToDoDatabase::class.java, "Tasks.db"
        ).build()
        database = result
        return result
    }
}

שלב 2. שימוש ב-ServiceLocator באפליקציה

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

חשוב ליצור רק מופע אחד של מחלקת המאגר. כדי לוודא זאת, תשתמשו ב-Service locator במחלקה Application.

  1. ברמה העליונה של היררכיית החבילות, פותחים את TodoApplication ויוצרים val למאגר ומקצים לו מאגר שמתקבל באמצעות ServiceLocator.provideTaskRepository.

TodoApplication.kt

class TodoApplication : Application() {

    val taskRepository: TasksRepository
        get() = ServiceLocator.provideTasksRepository(this)

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(DebugTree())
    }
}

אחרי שיוצרים מאגר באפליקציה, אפשר להסיר את השיטה הישנה getRepository ב-DefaultTasksRepository.

  1. פותחים את DefaultTasksRepository ומוחקים את האובייקט הנלווה.

DefaultTasksRepository.kt

// DELETE THIS COMPANION OBJECT
companion object {
    @Volatile
    private var INSTANCE: DefaultTasksRepository? = null

    fun getRepository(app: Application): DefaultTasksRepository {
        return INSTANCE ?: synchronized(this) {
            val database = Room.databaseBuilder(app,
                ToDoDatabase::class.java, "Tasks.db")
                .build()
            DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                INSTANCE = it
            }
        }
    }
}

מעכשיו, בכל מקום שבו השתמשתם ב-getRepository, תצטרכו להשתמש ב-taskRepository של האפליקציה. כך תוכלו לוודא שבמקום ליצור את המאגר ישירות, תקבלו את המאגר שסופק על ידי ServiceLocator.

  1. פותחים את TaskDetailFragement ומחפשים את השיחה עם getRepository בחלק העליון של הכיתה.
  2. מחליפים את הקריאה הזו בקריאה שמקבלת את המאגר מ-TodoApplication.

TaskDetailFragment.kt

// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}

// WITH this code

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
  1. חוזרים על הפעולה גם עבור TasksFragment.

TasksFragment.kt

// REPLACE this code
    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
    }


// WITH this code

    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
    }
  1. במקרים של StatisticsViewModel ו-AddEditTaskViewModel, מעדכנים את הקוד שמקבל את המאגר כדי להשתמש במאגר מ-TodoApplication.

TasksFragment.kt

// REPLACE this code
    private val tasksRepository = DefaultTasksRepository.getRepository(application)



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. מריצים את האפליקציה (לא את הבדיקה).

מכיוון שביצעתם רק רפקטורינג, האפליקציה אמורה לפעול באותו אופן ללא בעיות.

שלב 3. Create FakeAndroidTestRepository

כבר יש לך FakeTestRepository במערך מקורות הבדיקה. כברירת מחדל, אי אפשר לשתף מחלקות בדיקה בין קבוצות המקורות test ו-androidTest. לכן, צריך ליצור עותק של המחלקה FakeTestRepository במערך המקור androidTest ולקרוא לו FakeAndroidTestRepository.

  1. לוחצים לחיצה ימנית על קבוצת המקור androidTest ויוצרים חבילת נתונים. לוחצים שוב לחיצה ימנית ויוצרים חבילת מקור .
  2. יוצרים מחלקה חדשה בחבילת המקור הזו בשם FakeAndroidTestRepository.kt.
  3. מעתיקים את הקוד הבא לכיתה הזו.

FakeAndroidTestRepository.kt

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking
import java.util.LinkedHashMap



class FakeAndroidTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private var shouldReturnError = false

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    fun setReturnError(value: Boolean) {
        shouldReturnError = value
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override suspend fun refreshTask(taskId: String) {
        refreshTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    override fun observeTask(taskId: String): LiveData<Result<Task>> {
        runBlocking { refreshTasks() }
        return observableTasks.map { tasks ->
            when (tasks) {
                is Result.Loading -> Result.Loading
                is Error -> Error(tasks.exception)
                is Success -> {
                    val task = tasks.data.firstOrNull() { it.id == taskId }
                        ?: return@map Error(Exception("Not found"))
                    Success(task)
                }
            }
        }
    }

    override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        tasksServiceData[taskId]?.let {
            return Success(it)
        }
        return Error(Exception("Could not find task"))
    }

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        return Success(tasksServiceData.values.toList())
    }

    override suspend fun saveTask(task: Task) {
        tasksServiceData[task.id] = task
    }

    override suspend fun completeTask(task: Task) {
        val completedTask = Task(task.title, task.description, true, task.id)
        tasksServiceData[task.id] = completedTask
    }

    override suspend fun completeTask(taskId: String) {
        // Not required for the remote data source.
        throw NotImplementedError()
    }

    override suspend fun activateTask(task: Task) {
        val activeTask = Task(task.title, task.description, false, task.id)
        tasksServiceData[task.id] = activeTask
    }

    override suspend fun activateTask(taskId: String) {
        throw NotImplementedError()
    }

    override suspend fun clearCompletedTasks() {
        tasksServiceData = tasksServiceData.filterValues {
            !it.isCompleted
        } as LinkedHashMap<String, Task>
    }

    override suspend fun deleteTask(taskId: String) {
        tasksServiceData.remove(taskId)
        refreshTasks()
    }

    override suspend fun deleteAllTasks() {
        tasksServiceData.clear()
        refreshTasks()
    }

   
    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }
}

שלב 4. הכנת ServiceLocator לבדיקות

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

  1. פותחים את ServiceLocator.kt.
  2. מסמנים את הפונקציה להגדרת הערך של tasksRepository כ-@VisibleForTesting. ההערה הזו היא דרך לציין שהסיבה לכך שפונקציית ה-setter היא ציבורית היא בגלל בדיקות.

ServiceLocator.kt

    @Volatile
    var tasksRepository: TasksRepository? = null
        @VisibleForTesting set

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

מכיוון ש-ServiceLocator הוא singleton, יכול להיות שהוא ישותף בטעות בין בדיקות. כדי להימנע מכך, צריך ליצור שיטה שמאפסת את המצב של ServiceLocator בצורה נכונה בין הבדיקות.

  1. מוסיפים משתנה מופע בשם lock עם הערך Any.

ServiceLocator.kt

private val lock = Any()
  1. מוסיפים שיטה ספציפית לבדיקה בשם resetRepository שמנקה את מסד הנתונים ומגדירה את המאגר ואת מסד הנתונים כ-null.

ServiceLocator.kt

    @VisibleForTesting
    fun resetRepository() {
        synchronized(lock) {
            runBlocking {
                TasksRemoteDataSource.deleteAllTasks()
            }
            // Clear all data to avoid test pollution.
            database?.apply {
                clearAllTables()
                close()
            }
            database = null
            tasksRepository = null
        }
    }

שלב 5. שימוש ב-ServiceLocator

בשלב הזה משתמשים ב-ServiceLocator.

  1. פותחים את TaskDetailFragmentTest.
  2. מצהירים על משתנה lateinit TasksRepository.
  3. מוסיפים שיטת הגדרה ושיטת הסרה כדי להגדיר FakeAndroidTestRepository לפני כל בדיקה ולנקות אותו אחרי כל בדיקה.

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. עוטפים את גוף הפונקציה של activeTaskDetails_DisplayedInUi() ב-runBlockingTest.
  2. שומרים את activeTask במאגר לפני שמפעילים את הפריט.
repository.saveTask(activeTask)

הבדיקה הסופית נראית כמו הקוד שבהמשך.

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }
  1. להוסיף הערות לכל הכיתה באמצעות @ExperimentalCoroutinesApi.

בסיום, הקוד ייראה כך.

TaskDetailFragmentTest.kt

@MediumTest
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }


    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

}
  1. מריצים את הבדיקה activeTaskDetails_DisplayedInUi().

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


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

בעזרת Espresso תוכלו:

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

שלב 1. הערה לגבי תלות ב-Gradle

תלות ה-Espresso הראשית כבר תהיה קיימת, כי היא כלולה בפרויקטים של Android כברירת מחדל.

app/build.gradle

dependencies {

  // ALREADY in your code
    androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
   
 // Other dependencies
}

androidx.test.espresso:espresso-core – התלות הזו ב-Espresso כלולה כברירת מחדל כשיוצרים פרויקט Android חדש. הוא מכיל את קוד הבדיקה הבסיסי לרוב התצוגות והפעולות בהן.

שלב 2. השבתת אנימציות

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

כדי לבצע בדיקות של ממשק המשתמש באמצעות Espresso, מומלץ להשבית את האנימציות (כך גם הבדיקה תפעל מהר יותר):

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

שלב 3. בדיקת Espresso

לפני שכותבים בדיקת Espresso, כדאי לעיין בקוד Espresso.

onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))

ההצהרה הזו מוצאת את התצוגה של תיבת הסימון עם המזהה task_detail_complete_checkbox, לוחצת עליה ואז בודקת שהיא מסומנת.

רוב ההצהרות ב-Espresso מורכבות מארבעה חלקים:

1. שיטת אספרסו סטטית

onView

onView היא דוגמה לשיטה סטטית של Espresso שמתחילה הצהרת Espresso. ‫onView היא אחת מהאפשרויות הנפוצות ביותר, אבל יש אפשרויות אחרות, כמו onData.

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId היא דוגמה לViewMatcher שמקבלת תצוגה לפי המזהה שלה. יש עוד התאמות להצגת תצוגה שאפשר למצוא בתיעוד.

3. ViewAction

perform(click())

השיטה perform שמקבלת ViewAction. ViewAction היא פעולה שאפשר לבצע בתצוגה, למשל לחיצה על התצוגה.

4. ViewAssertion

check(matches(isChecked()))

check שמשתמש בViewAssertion. ‫ViewAssertions בודק או קובע משהו לגבי התצוגה. הטענה הנפוצה ביותר לשימוש היא matches.ViewAssertion כדי לסיים את ההצהרה, משתמשים בעוד ViewMatcher, במקרה הזה isChecked.

הערה: לא תמיד צריך לקרוא גם ל-perform וגם ל-check בהצהרת Espresso. אפשר להשתמש בהצהרות שכוללות רק טענה באמצעות check או רק פעולה באמצעות ViewAction עם perform.

  1. פותחים את TaskDetailFragmentTest.kt.
  2. מעדכנים את הבדיקה activeTaskDetails_DisplayedInUi.

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
        onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_title_text)).check(matches(withText("Active Task")))
        onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
    }

הצהרות הייבוא, אם צריך:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
  1. כל מה שמופיע אחרי התגובה // THEN משתמש ב-Espresso. בודקים את מבנה הבדיקה ואת השימוש בתג withId כדי להצהיר איך דף הפרטים אמור להיראות.
  2. מריצים את הבדיקה ומוודאים שהיא עוברת.

שלב 4. אופציונלי, כותבים בדיקת Espresso משלכם

עכשיו כותבים בעצמכם מבחן.

  1. יוצרים בדיקה חדשה בשם completedTaskDetails_DisplayedInUi ומעתיקים את קוד השלד הזה.

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
       
        // WHEN - Details fragment launched to display task
        
        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
}
  1. בהתבסס על הבדיקה הקודמת, השלם את הבדיקה הזו.
  2. מריצים את הבדיקה ומוודאים שהיא עוברת.

התג completedTaskDetails_DisplayedInUi הסופי צריך להיראות כמו הקוד הזה.

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
        val completedTask = Task("Completed Task", "AndroidX Rocks", true)
        repository.saveTask(completedTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(completedTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
        onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_title_text)).check(matches(withText("Completed Task")))
        onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
    }

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

ב-codelab הזה השתמשתם ב-test double שנקרא fake. ‫Fakes הם אחד מתוך סוגים רבים של כפילים לבדיקה. באיזה כפיל בדיקה כדאי להשתמש כדי לבדוק את רכיב הניווט?

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

הנה קוד ב-TasksFragment שמנווט למסך פרטי המשימה כשלוחצים עליו.

TasksFragment.kt

private fun openTaskDetails(taskId: String) {
    val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
    findNavController().navigate(action)
}


הניווט מתבצע בגלל קריאה לשיטה navigate. אם הייתם צריכים לכתוב הצהרת assert, אין דרך פשוטה לבדוק אם עברתם אל TaskDetailFragment. הניווט הוא פעולה מורכבת שלא מובילה לתוצאה ברורה או לשינוי במצב, מעבר לאתחול של TaskDetailFragment.

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

Mockito הוא framework ליצירת כפילים לבדיקה. למרות שהמילה mock מופיעה ב-API ובשם, הוא לא משמש רק ליצירת מוקאפים. הוא יכול גם ליצור stubs ו-spies.

תשתמשו ב-Mockito כדי ליצור אובייקט מדומה NavigationController שיוכל לאשר שהמתודה navigate נקראה בצורה נכונה.

שלב 1. הוספת יחסי תלות ב-Gradle

  1. מוסיפים את יחסי התלות של Gradle.

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "org.mockito:mockito-core:$mockitoVersion"

    androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion" 

    androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"



  • org.mockito:mockito-core – זוהי תלות ב-Mockito.
  • dexmaker-mockito – הספרייה הזו נדרשת כדי להשתמש ב-Mockito בפרויקט Android. ‫Mockito צריך ליצור מחלקות בזמן ריצה. ב-Android, הפעולה הזו מתבצעת באמצעות קוד בייט של dex, ולכן הספרייה הזו מאפשרת ל-Mockito ליצור אובייקטים במהלך זמן הריצה ב-Android.
  • androidx.test.espresso:espresso-contrib – הספרייה הזו מורכבת מתכנים חיצוניים (ומכאן השם) שמכילים קוד לבדיקה של תצוגות מתקדמות יותר, כמו DatePicker ו-RecyclerView. הוא מכיל גם בדיקות נגישות ומחלקה בשם CountingIdlingResource, שנסביר עליה בהמשך.

שלב 2. יצירת TasksFragmentTest

  1. פתיחת TasksFragment.
  2. לוחצים לחיצה ימנית על שם המחלקה TasksFragment ובוחרים באפשרות יצירה ואז באפשרות בדיקה. יוצרים בדיקה בערכת המקור androidTest.
  3. מעתיקים את הקוד הזה אל TasksFragmentTest.

TasksFragmentTest.kt

@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
class TasksFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }

}

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

  1. מוסיפים את הבדיקה clickTask_navigateToDetailFragmentOne.

TasksFragmentTest.kt

    @Test
    fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
        repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
        repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))

        // GIVEN - On the home screen
        val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
        
    }
  1. משתמשים בפונקציה mock של Mockito כדי ליצור mock.

TasksFragmentTest.kt

 val navController = mock(NavController::class.java)

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

בשלב הבא, צריך לשייך את NavController לקטע. ‫onFragment מאפשרת להפעיל שיטות בקטע עצמו.

  1. הופכים את המוק החדש ל-NavController של הפרגמנט.
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. מוסיפים את הקוד ללחיצה על הפריט ב-RecyclerView עם הטקסט TITLE1.
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActions הוא חלק מהספרייה espresso-contrib ומאפשר לכם לבצע פעולות Espresso ב-RecyclerView.

  1. מוודאים שהפונקציה navigate נקראה עם הארגומנט הנכון.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

השיטה verify של Mockito היא מה שהופך את זה ל-mock – אתם יכולים לאשר שה-mock navController קרא לשיטה ספציפית (navigate) עם פרמטר (actionTasksFragmentToTaskDetailFragment עם המזהה id1).

הבדיקה המלאה נראית כך:

@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
    repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
    repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))

    // GIVEN - On the home screen
    val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
    
                val navController = mock(NavController::class.java)
    scenario.onFragment {
        Navigation.setViewNavController(it.view!!, navController)
    }

    // WHEN - Click on the first list item
    onView(withId(R.id.tasks_list))
        .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
            hasDescendant(withText("TITLE1")), click()))


    // THEN - Verify that we navigate to the first detail screen
    verify(navController).navigate(
        TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
    )
}
  1. מריצים את הבדיקה.

לסיכום, כדי לבדוק את הניווט אפשר:

  1. משתמשים ב-Mockito כדי ליצור NavController mock.
  2. מצרפים את ה-mock NavController אל ה-fragment.
  3. מוודאים שהפונקציה navigate הופעלה עם הפעולה והפרמטרים הנכונים.

שלב 3. אופציונלי, כותבים clickAddTaskButton_navigateToAddEditFragment

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

  1. כותבים את הבדיקה clickAddTaskButton_navigateToAddEditFragment שבודקת שאם לוחצים על לחצן הפעולה הצף +, עוברים אל AddEditTaskFragment.

התשובה מופיעה בהמשך.

TasksFragmentTest.kt

    @Test
    fun clickAddTaskButton_navigateToAddEditFragment() {
        // GIVEN - On the home screen
        val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
        val navController = mock(NavController::class.java)
        scenario.onFragment {
            Navigation.setViewNavController(it.view!!, navController)
        }

        // WHEN - Click on the "+" button
        onView(withId(R.id.add_task_fab)).perform(click())

        // THEN - Verify that we navigate to the add screen
        verify(navController).navigate(
            TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
                null, getApplicationContext<Context>().getString(R.string.add_task)
            )
        )
    }

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

כדי להוריד את הקוד של ה-codelab המוגמר, אפשר להשתמש בפקודת ה-git הבאה:

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_2


אפשר גם להוריד את המאגר כקובץ ZIP, לבטל את הדחיסה שלו ולפתוח אותו ב-Android Studio.

הורדת קובץ Zip

בשיעור הזה למדתם איך להגדיר הזרקת תלות ידנית, איתור שירותים ואיך להשתמש ב-fakes וב-mocks באפליקציות Android Kotlin. הקפידו במיוחד על הדברים הבאים:

  • מה שרוצים לבדוק ואסטרטגיית הבדיקה קובעים את סוגי הבדיקות שצריך להטמיע באפליקציה. בדיקות יחידה הן ממוקדות ומהירות. בדיקות שילוב מאמתות את האינטראקציה בין חלקי התוכנית. בדיקות מקצה לקצה מאמתות תכונות, הן הכי מדויקות, הן לרוב מנוהלות באמצעות מכשירים ועשויות להימשך זמן רב יותר.
  • הארכיטקטורה של האפליקציה משפיעה על מידת הקושי של הבדיקה.
  • TDD או פיתוח מונחה בדיקות היא אסטרטגיה שבה כותבים את הבדיקות קודם, ואז יוצרים את התכונה כדי לעבור את הבדיקות.
  • כדי לבודד חלקים באפליקציה לצורך בדיקה, אפשר להשתמש ב-test doubles. כפילת בדיקה היא גרסה של מחלקה שנוצרה במיוחד לצורך בדיקה. לדוגמה, אתם מזייפים קבלת נתונים ממסד נתונים או מהאינטרנט.
  • משתמשים בהזרקת תלות כדי להחליף מחלקה אמיתית במחלקה לבדיקה, למשל מאגר או שכבת רשת.
  • משתמשים בבדיקות עם מכשור (androidTest) כדי להפעיל רכיבי ממשק משתמש.
  • כשאי אפשר להשתמש בהזרקת תלות של בנאי, למשל כדי להפעיל fragment, אפשר בדרך כלל להשתמש ב-service locator. התבנית Service Locator היא חלופה ל-Dependency Injection. היא כוללת יצירה של מחלקה יחידה (singleton) שנקראת Service Locator, שמטרתה לספק תלות, גם לקוד הרגיל וגם לקוד הבדיקה.

קורס ב-Udacity:

מסמכי תיעוד למפתחי Android:

סרטי וידאו:

אחר:

קישורים למדריכי Codelab נוספים בקורס הזה זמינים בדף הנחיתה של מדריכי Codelab בנושא Android מתקדם ב-Kotlin.