مقدمه ای بر تست دوتایی و تزریق وابستگی

این کد لبه بخشی از دوره آموزشی Advanced Android in Kotlin است. اگر از طریق کدها به صورت متوالی کار کنید، بیشترین ارزش را از این دوره خواهید گرفت، اما اجباری نیست. همه کدهای دوره در صفحه فرود Android Advanced in Kotlin Codelabs فهرست شده اند.

مقدمه

این دومین کد لبه آزمایشی همه چیز در مورد آزمایش دوگانه است: زمان استفاده از آنها در اندروید، و نحوه پیاده سازی آنها با استفاده از تزریق وابستگی، الگوی یاب سرویس و کتابخانه ها. با انجام این کار، یاد می گیرید که چگونه بنویسید:

  • تست های واحد مخزن
  • تست‌های یکپارچه‌سازی قطعات و viewmodel
  • تست های ناوبری قطعه

آنچه از قبل باید بدانید

باید با:

  • زبان برنامه نویسی کاتلین
  • آزمایش مفاهیم پوشش داده شده در اولین کد: نوشتن و اجرای تست های واحد در اندروید، با استفاده از JUnit، Hamcrest، تست AndroidX، Robolectric، و همچنین تست LiveData
  • کتابخانه‌های اصلی Android Jetpack: ViewModel ، LiveData و Navigation Component
  • معماری برنامه، از الگوی راهنمای معماری برنامه و کد لبه های Android Fundamentals پیروی می کند
  • اصول اولیه کوروتین ها در اندروید

چیزی که یاد خواهید گرفت

  • نحوه برنامه ریزی استراتژی تست
  • نحوه ایجاد و استفاده از دوبل های آزمایشی، یعنی تقلبی و ساختگی
  • نحوه استفاده از تزریق وابستگی دستی در اندروید برای تست های واحد و ادغام
  • نحوه اعمال الگوی یاب سرویس
  • نحوه تست مخازن، قطعات، مدل های مشاهده و مولفه Navigation

شما از کتابخانه ها و مفاهیم کد زیر استفاده خواهید کرد:

کاری که خواهی کرد

  • تست های واحد را برای یک مخزن با استفاده از تزریق دوتایی و وابستگی بنویسید.
  • تست های واحد را برای یک مدل view با استفاده از دوتایی تست و تزریق وابستگی بنویسید.
  • با استفاده از چارچوب تست UI Espresso، تست های یکپارچه سازی قطعات و مدل های نمای آنها را بنویسید.
  • تست های ناوبری را با استفاده از موکیتو و اسپرسو بنویسید.

در این سری از کدها، شما با برنامه TO-DO Notes کار خواهید کرد. این برنامه به شما امکان می دهد وظایف را برای تکمیل بنویسید و آنها را در یک لیست نمایش دهید. سپس می‌توانید آن‌ها را به‌عنوان تکمیل‌شده یا خیر علامت‌گذاری کنید، فیلتر کنید یا حذف کنید.

این برنامه به زبان Kotlin نوشته شده است، دارای چند صفحه نمایش است، از اجزای Jetpack استفاده می کند و معماری را از یک راهنما به معماری برنامه دنبال می کند. با یادگیری نحوه آزمایش این برنامه، می توانید برنامه هایی را که از کتابخانه ها و معماری مشابهی استفاده می کنند، آزمایش کنید.

کد را دانلود کنید

برای شروع، کد را دانلود کنید:

زیپ را دانلود کنید

همچنین، می‌توانید مخزن Github را برای کد کلون کنید:

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

با دنبال کردن دستورالعمل‌های زیر، لحظه‌ای را صرف کنید و با کد آشنا شوید.

مرحله 1: برنامه نمونه را اجرا کنید

هنگامی که برنامه TO-DO را دانلود کردید، آن را در Android Studio باز کرده و اجرا کنید. باید کامپایل شود. با انجام موارد زیر برنامه را کاوش کنید:

  • با دکمه اکشن شناور پلاس یک کار جدید ایجاد کنید. ابتدا عنوانی را وارد کنید، سپس اطلاعات تکمیلی مربوط به کار را وارد کنید. آن را با چک سبز FAB ذخیره کنید.
  • در لیست کارها، روی عنوان کاری که به تازگی تکمیل کرده اید کلیک کنید و به صفحه جزئیات آن کار نگاه کنید تا بقیه توضیحات را ببینید.
  • در فهرست یا در صفحه جزئیات، کادر انتخاب آن کار را علامت بزنید تا وضعیت آن را روی « تکمیل » تنظیم کنید.
  • به صفحه وظایف برگردید، منوی فیلتر را باز کنید و وظایف را بر اساس وضعیت فعال و تکمیل شده فیلتر کنید.
  • کشوی پیمایش را باز کنید و روی Statistics کلیک کنید.
  • به صفحه نمای کلی بازگشته و از منوی کشوی پیمایش، پاک کردن تکمیل شده را انتخاب کنید تا همه وظایف با وضعیت تکمیل شده حذف شوند.

مرحله 2: نمونه کد برنامه را کاوش کنید

برنامه TO-DO مبتنی بر نمونه آزمایشی و معماری محبوب طرح‌های معماری (با استفاده از نسخه معماری واکنشی نمونه) است. این برنامه از معماری یک راهنما به معماری برنامه پیروی می کند. از ViewModels با Fragments، Repository و Room استفاده می کند. اگر با هر یک از نمونه های زیر آشنا هستید، این برنامه معماری مشابهی دارد:

مهم‌تر است که معماری کلی برنامه را درک کنید تا اینکه درک عمیقی از منطق در هر لایه داشته باشید.

در اینجا خلاصه بسته هایی است که خواهید یافت:

بسته: com.example.android.architecture.blueprints.todoapp

.addedittask

صفحه افزودن یا ویرایش یک کار: کد لایه رابط کاربری برای افزودن یا ویرایش یک کار.

.data

لایه داده: این لایه به لایه داده وظایف می پردازد. این شامل پایگاه داده، شبکه و کد مخزن است.

.statistics

صفحه آمار: کد لایه رابط کاربری برای صفحه آمار.

.taskdetail

صفحه جزئیات کار: کد لایه رابط کاربری برای یک کار واحد.

.tasks

صفحه وظایف: کد لایه رابط کاربری برای لیست همه وظایف.

.util

کلاس‌های کاربردی : کلاس‌های مشترک مورد استفاده در بخش‌های مختلف برنامه، به‌عنوان مثال برای طرح‌بندی بازخوانی کشیدن انگشتی که در چندین صفحه استفاده می‌شود.

لایه داده (.data)

این برنامه شامل یک لایه شبکه شبیه سازی شده، در بسته راه دور ، و یک لایه پایگاه داده، در بسته محلی است. برای سادگی، در این پروژه لایه شبکه تنها با یک HashMap با تاخیر شبیه سازی می شود تا درخواست های واقعی شبکه.

DefaultTasksRepository بین لایه شبکه و لایه پایگاه داده مختصات یا واسطه می شود و همان چیزی است که داده ها را به لایه UI برمی گرداند.

لایه رابط کاربری (.addedittask، .statistics، .taskdetail، .tasks)

هر یک از بسته های لایه UI شامل یک قطعه و یک مدل view به همراه هر کلاس دیگری است که برای UI مورد نیاز است (مانند یک آداپتور برای لیست وظایف). TaskActivity است که شامل تمام قطعات است.

جهت یابی

ناوبری برای برنامه توسط مؤلفه ناوبری کنترل می شود . در فایل nav_graph.xml تعریف شده است. ناوبری در مدل های view با استفاده از کلاس Event فعال می شود. مدل‌های view نیز تعیین می‌کنند که چه آرگومان‌هایی باید منتقل شوند. قطعات Event را مشاهده می‌کنند و پیمایش واقعی بین صفحه‌ها را انجام می‌دهند.

در این کد لبه، نحوه تست مخازن، مشاهده مدل ها و قطعات با استفاده از دو برابر و تزریق وابستگی را یاد خواهید گرفت. قبل از اینکه در مورد این تست ها غوطه ور شوید، مهم است که استدلالی را که راهنمایی می کند این تست ها را چگونه و چگونه بنویسید، درک کنید.

این بخش برخی از بهترین روش‌های آزمایش را به طور کلی پوشش می‌دهد، زیرا برای Android اعمال می‌شود.

هرم تست

وقتی در مورد استراتژی تست فکر می کنیم، سه جنبه تست مرتبط وجود دارد:

  • محدوده — تست چه مقدار از کد را لمس می کند؟ آزمایش‌ها می‌توانند روی یک روش واحد، در کل برنامه یا جایی در میان اجرا شوند.
  • سرعت — تست با چه سرعتی اجرا می شود؟ سرعت تست می تواند از میلی ثانیه تا چند دقیقه متفاوت باشد.
  • وفاداری — آزمون چقدر «دنیای واقعی» است؟ به عنوان مثال، اگر بخشی از کدی که در حال آزمایش آن هستید نیاز به درخواست شبکه داشته باشد، آیا کد آزمایشی واقعاً این درخواست شبکه را انجام می دهد یا نتیجه را جعلی می کند؟ اگر آزمایش واقعاً با شبکه صحبت می کند، به این معنی است که وفاداری بالاتری دارد. معاوضه این است که اجرای آزمایش ممکن است بیشتر طول بکشد، در صورت قطع شدن شبکه ممکن است منجر به خطا شود یا استفاده از آن پرهزینه باشد.

مبادلات ذاتی بین این جنبه ها وجود دارد. به عنوان مثال، سرعت و وفاداری یک مبادله هستند - هر چه تست سریعتر باشد، به طور کلی، وفاداری کمتر، و بالعکس. یکی از روش های رایج برای تقسیم تست های خودکار به این سه دسته است:

  • تست های واحد — این تست ها بسیار متمرکز هستند که روی یک کلاس اجرا می شوند، معمولاً یک متد در آن کلاس. اگر تست واحد ناموفق باشد، می‌توانید دقیقاً بدانید که مشکل در کجای کدتان قرار دارد. آنها وفاداری پایینی دارند زیرا در دنیای واقعی، برنامه شما بسیار بیشتر از اجرای یک متد یا کلاس است. آنها به اندازه ای سریع هستند که هر بار که کد خود را تغییر می دهید اجرا شوند. آنها اغلب به صورت محلی (در مجموعه منبع test ) اجرا می شوند. مثال: تست تک روش ها در مدل های view و مخازن.
  • تست‌های یکپارچه‌سازی : این تست‌ها تعامل چندین کلاس را آزمایش می‌کنند تا مطمئن شوند که هنگام استفاده با هم مطابق انتظار رفتار می‌کنند. یکی از راه‌های ساختاربندی تست‌های یکپارچه‌سازی این است که آن‌ها یک ویژگی واحد را آزمایش کنند، مانند توانایی ذخیره یک کار. آنها محدوده وسیع تری از کد را نسبت به تست های واحد آزمایش می کنند، اما همچنان برای اجرای سریع، در مقابل وفاداری کامل، بهینه شده اند. بسته به شرایط می توان آنها را به صورت محلی یا به عنوان تست ابزار دقیق اجرا کرد. مثال: آزمایش تمام عملکردهای یک جفت مدل قطعه و view.
  • تست های پایان به انتها (E2e) - ترکیبی از ویژگی ها را آزمایش کنید که با هم کار می کنند. آنها بخش‌های بزرگی از برنامه را آزمایش می‌کنند، استفاده واقعی را از نزدیک شبیه‌سازی می‌کنند و بنابراین معمولاً کند هستند. آنها بالاترین وفاداری را دارند و به شما می گویند که برنامه شما در واقع به طور کلی کار می کند. به طور کلی، این تست ها تست های ابزاری خواهند بود (در مجموعه منبع androidTest )
    مثال: راه اندازی کل برنامه و آزمایش چند ویژگی با هم.

نسبت پیشنهادی این تست‌ها اغلب توسط یک هرم نشان داده می‌شود، که اکثریت قریب به اتفاق تست‌ها تست‌های واحد هستند.

معماری و تست

توانایی شما برای آزمایش برنامه خود در تمام سطوح مختلف هرم آزمایشی به طور ذاتی با معماری برنامه شما مرتبط است. به عنوان مثال، یک برنامه کاربردی با معماری بسیار ضعیف ممکن است تمام منطق خود را در یک متد قرار دهد. ممکن است بتوانید یک تست پایان به انتها برای این کار بنویسید، زیرا این تست‌ها تمایل دارند بخش‌های بزرگی از برنامه را آزمایش کنند، اما در مورد تست‌های واحد نوشتن یا یکپارچه‌سازی چطور؟ با وجود همه کدها در یک مکان، آزمایش فقط کد مربوط به یک واحد یا ویژگی دشوار است.

یک رویکرد بهتر این است که منطق برنامه را به چندین روش و کلاس تقسیم کنیم و به هر قطعه اجازه دهیم به صورت مجزا آزمایش شود. معماری راهی برای تقسیم و سازماندهی کد شماست که امکان تست واحد و یکپارچه سازی آسان تر را فراهم می کند. برنامه TO-DO که آزمایش می کنید از معماری خاصی پیروی می کند:



در این درس، نحوه تست کردن بخش هایی از معماری فوق را به صورت مجزا مشاهده خواهید کرد:

  1. ابتدا مخزن را واحد تست خواهید کرد.
  2. سپس از یک تست دوبل در مدل view استفاده می کنید که برای تست واحد و تست یکپارچه سازی مدل view ضروری است.
  3. در مرحله بعد، نوشتن تست‌های یکپارچه‌سازی برای قطعات و مدل‌های نمای آن‌ها را یاد خواهید گرفت.
  4. در نهایت، نوشتن تست‌های یکپارچه‌سازی را یاد خواهید گرفت که شامل مولفه Navigation باشد.

تست پایان تا پایان در درس بعدی پوشش داده خواهد شد.

وقتی برای بخشی از یک کلاس (یک متد یا مجموعه کوچکی از متدها) یک تست واحد می نویسید، هدف شما این است که فقط کد موجود در آن کلاس را آزمایش کنید .

آزمایش فقط کد در یک کلاس یا کلاس های خاص می تواند مشکل باشد. بیایید به یک مثال نگاه کنیم. کلاس 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 برای دریافت یک محیط اندروید شبیه سازی شده استفاده کنید.
  • برخی از بخش‌های کد، مانند کد شبکه، ممکن است زمان زیادی طول بکشد تا اجرا شود، یا حتی گاهی اوقات با شکست مواجه می‌شود و آزمایش‌های طولانی‌مدت و پوسته‌پوستی ایجاد می‌کند.
  • آزمایش‌های شما ممکن است توانایی خود را برای تشخیص اینکه کدام کد مقصر خطای آزمایش است، از دست بدهند. تست‌های شما می‌توانند شروع به آزمایش کد غیر مخزن کنند، بنابراین، برای مثال، آزمایش‌های واحد "مخزن" فرضی شما ممکن است به دلیل مشکل در برخی از کدهای وابسته، مانند کد پایگاه داده، با شکست مواجه شوند.

تست دونفره

راه حل این است که وقتی در حال آزمایش مخزن هستید، از کد شبکه یا پایگاه داده واقعی استفاده نکنید ، بلکه از یک تست دوبل استفاده کنید. تست دو نسخه ای از یک کلاس است که به طور خاص برای آزمایش ساخته شده است. این به معنای جایگزینی نسخه واقعی یک کلاس در تست ها است. شبیه این است که یک بدلکار بازیگری است که در بدلکاری تخصص دارد و بازیگر واقعی را برای اقدامات خطرناک جایگزین می کند.

در اینجا چند نوع تست دوبل آورده شده است:

جعلی

یک تست دوتایی که دارای یک پیاده‌سازی "در حال کار" از کلاس است، اما به گونه‌ای پیاده‌سازی شده است که برای آزمایش‌ها خوب است اما برای تولید نامناسب است.

مسخره کردن

یک تست دوبل که ردیابی می کند کدام یک از متدهای آن فراخوانی شده است. سپس بسته به اینکه روش‌های آن به درستی فراخوانی شده باشند، در یک آزمون موفق می‌شود یا ناموفق می‌شود.

خرد

یک تست دوبل که شامل هیچ منطقی نمی شود و فقط آنچه را که برنامه ریزی کرده اید برمی گرداند. یک StubTaskRepository می تواند برنامه ریزی شود تا ترکیب خاصی از وظایف را برای مثال از getTasks .

ساختگی

یک تست دوتایی که در اطراف ارسال می شود اما استفاده نمی شود، مثلاً اگر فقط باید آن را به عنوان یک پارامتر ارائه کنید. اگر NoOpTaskRepository ، TaskRepository را بدون کد در هیچ یک از متدها پیاده سازی می کرد.

جاسوس

یک تست دوبل که همچنین برخی از اطلاعات اضافی را ردیابی می کند. برای مثال، اگر یک SpyTaskRepository ساخته اید، ممکن است تعداد دفعاتی که متد addTask فراخوانی شده است را پیگیری کند.

برای کسب اطلاعات بیشتر در مورد تست های دوتایی، تست در توالت: دوبل های تست خود را بشناسید .

رایج ترین تست های دوگانه مورد استفاده در اندروید Fakes و Mocks هستند.

در این کار، شما یک FakeDataSource تست دو تا واحدی DefaultTasksRepository جدا از منابع داده واقعی ایجاد می کنید.

مرحله 1: کلاس FakeDataSource را ایجاد کنید

در این مرحله شما می خواهید کلاسی به نام FakeDataSouce ایجاد کنید که یک تست دوگانه از LocalDataSource و RemoteDataSource خواهد بود.

  1. در مجموعه منبع آزمایشی ، روی 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 شکایت خواهد کرد که روش‌های لازم را برای TasksDataSource .

  1. از منوی رفع سریع استفاده کنید و Implement Members را انتخاب کنید.


  1. همه روش ها را انتخاب کرده و OK را فشار دهید.

مرحله 3: متد getTasks را در FakeDataSource پیاده سازی کنید

FakeDataSource نوع خاصی از تست دوگانه است که به آن Fake گفته می شود. جعلی یک آزمایش دوگانه است که دارای یک پیاده‌سازی «کار» کلاس است، اما به گونه‌ای پیاده‌سازی شده است که برای آزمایش خوب است اما برای تولید نامناسب است. پیاده سازی "کار" به این معنی است که کلاس خروجی های واقعی را با ورودی های داده شده تولید می کند.

برای مثال، منبع داده جعلی شما به شبکه متصل نمی‌شود یا چیزی را در پایگاه داده ذخیره نمی‌کند، بلکه فقط از یک لیست درون حافظه استفاده می‌کند. این "همانطور که ممکن است انتظار داشته باشید" عمل می کند زیرا روش های دریافت یا ذخیره وظایف نتایج مورد انتظار را نشان می دهد، اما هرگز نمی توانید از این پیاده سازی در تولید استفاده کنید، زیرا در سرور یا پایگاه داده ذخیره نمی شود.

یک FakeDataSource

  • به شما امکان می دهد کد را در DefaultTasksRepository بدون نیاز به تکیه بر پایگاه داده یا شبکه واقعی آزمایش کنید.
  • یک پیاده سازی "به اندازه کافی واقعی" برای آزمایش ها ارائه می دهد.
  1. سازنده FakeDataSource را برای ایجاد یک var به نام tasks تغییر دهید که MutableList<Task>? با مقدار پیش فرض یک لیست خالی قابل تغییر.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


این لیستی از وظایفی است که به عنوان یک پایگاه داده یا پاسخ سرور "جعل" می شوند. در حال حاضر، هدف آزمایش روش getTasks مخزن است . این روش‌های getTasks ، deleteAllTasks و saveTask منبع داده را فراخوانی می‌کند.

یک نسخه جعلی از این روش ها بنویسید:

  1. getTasks را بنویسید: اگر tasks null نیستند، یک نتیجه Success را برگردانید. اگر 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

این شبیه به نحوه عملکرد منابع داده محلی و راه دور واقعی است.

در این مرحله، می‌خواهید از تکنیکی به نام تزریق وابستگی دستی استفاده کنید تا بتوانید از تست جعلی که ایجاد کرده‌اید استفاده کنید.

مسئله اصلی این است که شما یک 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 Dependency Injection به شما این امکان را می دهد که با ارسال آن به سازنده، دو برابر تست را تعویض کنید.

بدون تزریق

تزریق

مرحله 1: از Injection وابستگی سازنده در DefaultTasksRepository استفاده کنید

  1. سازنده DefaultTaskRepository را از دریافت یک Application به دریافت هر دو منبع داده و توزیع کننده Coroutine تغییر دهید (که شما همچنین باید آن را برای تست های خود تعویض کنید - این با جزئیات بیشتر در بخش درس سوم در مورد کوروتین ها توضیح داده شده است).

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. همچنین متغیرهای نمونه قدیمی را حذف کنید. شما آنها را در سازنده تعریف می کنید:

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 کلیک راست کرده و Generate و سپس Test را انتخاب کنید.
  2. دستورات را برای ایجاد DefaultTasksRepositoryTest در مجموعه منبع آزمایشی دنبال کنید.
  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() Test را بنویسید

زمان نوشتن یک تست 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 را اضافه کنید

خطای Coroutine انتظار می رود زیرا getTasks یک تابع suspend است و برای فراخوانی آن باید یک Coroutine راه اندازی کنید. برای آن، شما به یک محدوده کاری نیاز دارید. برای حل این خطا، باید چند وابستگی gradle را برای مدیریت راه‌اندازی کوروتین‌ها در تست‌های خود اضافه کنید.

  1. با استفاده از testImplementation ، وابستگی های مورد نیاز برای تست کوروتین ها را به مجموعه منبع تست اضافه کنید.

app/build.gradle

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

همگام سازی را فراموش نکنید!

kotlinx-coroutines-test test کتابخانه تست coroutines است که به طور خاص برای آزمایش کوروتین ها در نظر گرفته شده است. برای اجرای تست های خود، از تابع runBlockingTest استفاده کنید. این تابعی است که توسط کتابخانه تست coroutines ارائه شده است. یک بلوک از کد را می گیرد و سپس این بلوک کد را در یک زمینه ویژه کاری اجرا می کند که به صورت همزمان و بلافاصله اجرا می شود، به این معنی که اقدامات به ترتیب قطعی انجام می شود. این اساساً باعث می‌شود که کوروتین‌های شما مانند موارد غیرکوروتین اجرا شوند، بنابراین برای آزمایش کد در نظر گرفته شده است.

هنگامی که یک تابع suspend فراخوانی می کنید، از runBlockingTest در کلاس های آزمایشی خود استفاده کنید. شما در مورد نحوه عملکرد 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 جدید خود را اجرا کنید و تأیید کنید که کار می کند و خطا برطرف شده است!

شما به تازگی نحوه تست واحد یک مخزن را دیدید. در این مراحل بعدی، مجدداً از تزریق وابستگی استفاده می‌کنید و تست دوگانه دیگری ایجاد می‌کنید – این بار برای نشان دادن نحوه نوشتن تست‌های واحد و ادغام برای مدل‌های view خود.

تست های واحد فقط باید کلاس یا روشی را که به آن علاقه دارید آزمایش کند. این به عنوان تست در انزوا شناخته می شود، که در آن شما به وضوح "واحد" خود را ایزوله می کنید و فقط کدی را که بخشی از آن واحد است آزمایش می کنید.

بنابراین TasksViewModelTest فقط باید کد TasksViewModel را آزمایش کند - نباید در پایگاه داده، شبکه یا کلاس های مخزن تست شود. بنابراین برای مدل‌های view خود، درست مانند آنچه که برای مخزن خود انجام دادید، یک مخزن جعلی ایجاد می‌کنید و برای استفاده از آن در آزمایش‌های خود از تزریق وابستگی استفاده می‌کنید.

در این کار، تزریق وابستگی را برای مشاهده مدل ها اعمال می کنید.

مرحله 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 ایجاد کنید. باید شامل تمام متدهای عمومی (سطح API عمومی) DefaultTasksRepository باشد.

  1. DefaultTasksRepository را باز کرده و روی نام کلاس کلیک راست کنید . سپس Refactor -> Extract -> Interface را انتخاب کنید.

  1. برای جداسازی فایل Extract را انتخاب کنید.

  1. در پنجره Extract Interface ، نام رابط را به TasksRepository تغییر دهید.
  2. در قسمت Members to form interface ، همه اعضا به جز دو عضو همراه و متدهای خصوصی را بررسی کنید.


  1. روی Refactor کلیک کنید. رابط TasksRepository جدید باید در بسته داده/منبع ظاهر شود.

و DefaultTasksRepository اکنون TasksRepository را پیاده سازی می کند.

  1. برنامه خود را اجرا کنید (نه تست ها) تا مطمئن شوید که همه چیز هنوز در حالت کار است.

مرحله 2. FakeTestRepository را ایجاد کنید

اکنون که رابط را در اختیار دارید، می توانید دو تست DefaultTaskRepository را ایجاد کنید.

  1. در مجموعه منبع آزمایشی ، در data/source فایل Kotlin و کلاس FakeTestRepository.kt را ایجاد کنید و از رابط TasksRepository گسترش دهید.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

به شما گفته می شود که باید متدهای رابط را پیاده سازی کنید.

  1. نشانگر را روی خطا نگه دارید تا منوی پیشنهاد را مشاهده کنید، سپس کلیک کرده و Implement Members را انتخاب کنید.
  1. همه روش ها را انتخاب کرده و OK را فشار دهید.

مرحله 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. ObserverTasks - با استفاده از 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 استفاده کنید.

این فرآیند با مدل‌های view کمی متفاوت است زیرا شما آنها را مستقیماً نمی‌سازید. مثلا:

class TasksFragment : Fragment() {

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

}


همانطور که در کد بالا وجود دارد، شما از ویژگی delegate viewModel's استفاده می کنید که مدل view را ایجاد می کند. برای تغییر نحوه ساخت مدل view، باید ViewModelProvider.Factory را اضافه کرده و از آن استفاده کنید. اگر با ViewModelProvider.Factory آشنا نیستید، می‌توانید در اینجا درباره آن اطلاعات بیشتری کسب کنید .

مرحله 1. یک ViewModelFactory در TasksViewModel بسازید و از آن استفاده کنید

شما با به روز رسانی کلاس ها و تست های مربوط به صفحه Tasks شروع می کنید.

  1. TasksViewModel باز کنید.
  2. سازنده 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 
}

از آنجایی که سازنده را تغییر دادید، اکنون باید از یک کارخانه برای ساخت 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 ها را تغییر می دهید. اکنون که کارخانه را دارید، از آن در هر کجا که مدل view خود را می سازید استفاده کنید.

  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 استفاده کنید

اکنون به جای استفاده از مخزن واقعی در تست های مدل view خود، می توانید از مخزن جعلی استفاده کنید.

  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. تست های خود را اجرا کنید، مطمئن شوید که همه آنها هنوز کار می کنند!

By using constructor dependency injection, you've now removed the DefaultTasksRepository as a dependency and replaced it with your FakeTestRepository in the tests.

Step 3. Also Update TaskDetail Fragment and ViewModel

Make the exact same changes for the TaskDetailFragment and TaskDetailViewModel . This will prepare the code for when you write TaskDetail tests next.

  1. Open TaskDetailViewModel .
  2. Update the 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. At the bottom of the TaskDetailViewModel file, outside the class, add a 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. Update TasksFragment to use the factory.

TasksFragment.kt

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

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. Run your code and make sure everything is working.

You are now able to use a FakeTestRepository instead of the real repository in TasksFragment and TasksDetailFragment .

Next you'll write integration tests to test your fragment and view-model interactions. You'll find out if your view model code appropriately updates your UI. To do this you use

  • the ServiceLocator pattern
  • the Espresso and Mockito libraries

Integration tests test the interaction of several classes to make sure they behave as expected when used together. These tests can be run either locally ( test source set) or as instrumentation tests ( androidTest source set).

In your case you'll be taking each fragment and writing integration tests for the fragment and view model to test the main features of the fragment.

Step 1. Add Gradle Dependencies

  1. Add the following gradle dependencies.

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"

These dependencies include:

  • junit:junit —JUnit, which is necessary for writing basic test statements.
  • androidx.test:core —Core AndroidX test library
  • kotlinx-coroutines-test —The coroutines testing library
  • androidx.fragment:fragment-testing —AndroidX test library for creating fragments in tests and changing their state.

Since you'll be using these libraries in your androidTest source set, use androidTestImplementation to add them as dependencies.

Step 2. Make a TaskDetailFragmentTest class

The TaskDetailFragment shows information about a single task.

You'll start by writing a fragment test for the TaskDetailFragment since it has fairly basic functionality compared to the other fragments.

  1. Open taskdetail.TaskDetailFragment .
  2. Generate a test for TaskDetailFragment , as you've done before. Accept the default choices and put it in the androidTest source set (NOT the test source set).

  1. Add the following annotations to the TaskDetailFragmentTest class.

TaskDetailFragmentTest.kt

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

}

The purpose of these annotation is:

Step 3. Launch a fragment from a test

In this task, you're going to launch TaskDetailFragment using the AndroidX Testing library . FragmentScenario is a class from AndroidX Test that wraps around a fragment and gives you direct control over the fragment's lifecycle for testing. To write tests for fragments, you create a FragmentScenario for the fragment you're testing ( TaskDetailFragment ).

  1. Copy this test into 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)

    }

This code above:

This is not a finished test yet, because it's not asserting anything. For now, run the test and observe what happens.

  1. This is an instrumented test, so make sure the emulator or your device is visible.
  2. Run the test.

A few things should happen.

  • First, because this is an instrumented test, the test will run on either your physical device (if connected) or an emulator.
  • It should launch the fragment.
  • Notice how it doesn't navigate through any other fragment or have any menus associated with the activity - it is just the fragment.

Finally, look closely and notice that the fragment says "No data" as it doesn't successfully load up the task data.

Your test both needs to load up the TaskDetailFragment (which you've done) and assert the data was loaded correctly. Why is there no data? This is because you created a task, but you didn't save it to the repository.

    @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)

    }

You have this FakeTestRepository , but you need some way to replace your real repository with your fake one for your fragment . You'll do this next!

In this task, you'll provide your fake repository to your fragment using a ServiceLocator . This will allow you to write your fragment and view model integration tests.

You can't use constructor dependency injection here, as you did before, when you needed to provide a dependency to the view model or repository. Constructor dependency injection requires that you construct the class. Fragments and activities are examples of classes that you don't construct and generally don't have access to the constructor of.

Since you don't construct the fragment, you can't use constructor dependency injection to swap the repository test double ( FakeTestRepository ) to the fragment. Instead, use the Service Locator pattern. The Service Locator pattern is an alternative to Dependency Injection. It involves creating a singleton class called the "Service Locator", whose purpose is to provide dependencies, both for the regular and test code. In the regular app code (the main source set), all of these dependencies are the regular app dependencies. For the tests, you modify the Service Locator to provide test double versions of the dependencies.

Not using Service Locator


Using a Service Locator

For this codelab app, do the following:

  1. Create a Service Locator class that is able to construct and store a repository. By default it constructs a "normal" repository.
  2. Refactor your code so that when you need a repository, use the Service Locator.
  3. In your testing class, call a method on the Service Locator which swaps out the "normal" repository with your test double.

Step 1. Create the ServiceLocator

Let's make a ServiceLocator class. It'll live in the main source set with the rest of the app code because it's used by the main application code.

Note: The ServiceLocator is a singleton, so use the Kotlin object keyword for the class.

  1. Create the file ServiceLocator.kt in the top level of the main source set.
  2. Define an object called ServiceLocator .
  3. Create database and repository instance variables and set both to null .
  4. Annotate the repository with @Volatile because it could get used by multiple threads ( @Volatile is explained in detail here ).

Your code should look as a shown below.

object ServiceLocator {

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

}

Right now the only thing your ServiceLocator needs to do is know how to return a TasksRepository . It'll return a pre-existing DefaultTasksRepository or make and return a new DefaultTasksRepository , if needed.

Define the following functions:

  1. provideTasksRepository —Either provides an already existing repository or creates a new one. This method should be synchronized on this to avoid, in situations with multiple threads running, ever accidentally creating two repository instances.
  2. createTasksRepository —Code for creating a new repository. Will call createTaskLocalDataSource and create a new TasksRemoteDataSource .
  3. createTaskLocalDataSource —Code for creating a new local data source. Will call createDataBase .
  4. createDataBase —Code for creating a new database.

The completed code is below.

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
    }
}

Step 2. Use ServiceLocator in Application

You're going to make a change to your main application code (not your tests) so that you create the repository in one place, your ServiceLocator .

It's important that you only ever make one instance of the repository class. To ensure this, you'll use the Service locator in my Application class.

  1. At the top level of your package hierarchy, open TodoApplication and create a val for your repository and assign it a repository that is obtained using 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())
    }
}

Now that you have created a repository in the application, you can remove the old getRepository method in DefaultTasksRepository .

  1. Open DefaultTasksRepository and delete the companion object.

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
            }
        }
    }
}

Now everywhere you were using getRepository , use the application's taskRepository instead. This ensures that instead of making the repository directly, you are getting whatever repository the ServiceLocator provided.

  1. Open TaskDetailFragement and find the call to getRepository at the top of the class.
  2. Replace this call with a call that gets the repository from 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. Do the same for 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. For StatisticsViewModel and AddEditTaskViewModel , update the code that acquires the repository to use the repository from the TodoApplication .

TasksFragment.kt

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



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. Run your application (not the test)!

Since you only refactored, the app should run the same without issue.

Step 3. Create FakeAndroidTestRepository

You already have a FakeTestRepository in the test source set. You cannot share test classes between the test and androidTest source sets by default. So, you need to make a duplicate FakeTestRepository class in the androidTest source set, and call it FakeAndroidTestRepository .

  1. Right-click the androidTest source set and make a data package. Right-click again and make a source package.
  2. Make a new class in this source package called FakeAndroidTestRepository.kt .
  3. Copy the following code to that class.

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() }
    }
}

Step 4. Prepare your ServiceLocator for Tests

Okay, time to use the ServiceLocator to swap in test doubles when testing. To do that, you need to add some code to your ServiceLocator code.

  1. Open ServiceLocator.kt .
  2. Mark the setter for tasksRepository as @VisibleForTesting . This annotation is a way to express that the reason the setter is public is because of testing.

ServiceLocator.kt

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

Whether you run your test alone or in a group of tests, your tests should run exactly the same. What this means is that your tests should have no behavior that is dependent on one another (which means avoiding sharing objects between tests).

Since the ServiceLocator is a singleton, it has the possibility of being accidentally shared between tests. To help avoid this, create a method that properly resets the ServiceLocator state between tests.

  1. Add an instance variable called lock with the Any value.

ServiceLocator.kt

private val lock = Any()
  1. Add a testing-specific method called resetRepository which clears out the database and sets both the repository and database to 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
        }
    }

Step 5. Use your ServiceLocator

In this step, you use the ServiceLocator .

  1. Open TaskDetailFragmentTest .
  2. Declare a lateinit TasksRepository variable.
  3. Add a setup and a tear down method to set up a FakeAndroidTestRepository before each test and clean it up after each test.

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

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

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. Wrap the function body of activeTaskDetails_DisplayedInUi() in runBlockingTest .
  2. Save activeTask in the repository before launching the fragment.
repository.saveTask(activeTask)

The final test looks like this code below.

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. Annotate the whole class with @ExperimentalCoroutinesApi .

When finished, the code will look like this.

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. Run the activeTaskDetails_DisplayedInUi() test.

Much like before, you should see the fragment, except this time, because you properly set up the repository, it now shows the task information.


In this step, you'll use the Espresso UI testing library to complete your first integration test. You have structured your code so you can add tests with assertions for your UI. To do that, you'll use the Espresso testing library .

Espresso helps you:

  • Interact with views, like clicking buttons, sliding a bar, or scrolling down a screen.
  • Assert that certain views are on screen or are in a certain state (such as containing particular text, or that a checkbox is checked, etc.).

Step 1. Note Gradle Dependency

You'll already have the main Espresso dependency since it is included in Android projects by default.

app/build.gradle

dependencies {

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

androidx.test.espresso:espresso-core —This core Espresso dependency is included by default when you make a new Android project. It contains the basic testing code for most views and actions on them.

Step 2. Turn off animations

Espresso tests run on a real device and thus are instrumentation tests by nature. One issue that arises is animations: If an animation lags and you try to test if a view is on screen, but it's still animating, Espresso can accidentally fail a test. This can make Espresso tests flaky.

For Espresso UI testing, it's best practice to turn animations off (also your test will run faster!):

  1. On your testing device, go to Settings > Developer options .
  2. Disable these three settings: Window animation scale , Transition animation scale , and Animator duration scale .

Step 3. Look at an Espresso test

Before you write an Espresso test, take a look at some Espresso code.

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

What this statement does is find the checkbox view with the id task_detail_complete_checkbox , clicks it, then asserts that it is checked.

The majority of Espresso statements are made up of four parts:

1. Static Espresso method

onView

onView is an example of a static Espresso method that starts an Espresso statement. onView is one of the most common ones, but there are other options, such as onData .

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId is an example of a ViewMatcher which gets a view by its ID. There are other view matchers which you can look up in the documentation .

3. ViewAction

perform(click())

The perform method which takes a ViewAction . A ViewAction is something that can be done to the view, for example here, it's clicking the view.

4. ViewAssertion

check(matches(isChecked()))

check which takes a ViewAssertion . ViewAssertion s check or asserts something about the view. The most common ViewAssertion you'll use is the matches assertion. To finish the assertion, use another ViewMatcher , in this case isChecked .

Note that you don't always call both perform and check in an Espresso statement. You can have statements that just make an assertion using check or just do a ViewAction using perform .

  1. Open TaskDetailFragmentTest.kt .
  2. Update the activeTaskDetails_DisplayedInUi test.

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())))
    }

Here are the import statements, if needed:

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. Everything after the // THEN comment uses Espresso. Examine the test structure and the use of withId and check to make assertions about how the detail page should look.
  2. Run the test and confirm it passes.

Step 4. Optional, Write your own Espresso Test

Now write a test yourself.

  1. Create a new test called completedTaskDetails_DisplayedInUi and copy this skeleton code.

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. Looking at the previous test, complete this test.
  2. Run and confirm the test passes.

The finished completedTaskDetails_DisplayedInUi should look like this code.

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()))
    }

In this last step you'll learn how to test the Navigation component , using a different type of test double called a mock, and the testing library Mockito .

In this codelab you've used a test double called a fake. Fakes are one of many types of test doubles. Which test double should you use for testing the Navigation component ?

Think about how navigation happens. Imagine pressing one of the tasks in the TasksFragment to navigate to a task detail screen.

Here's code in TasksFragment that navigates to a task detail screen when it is pressed.

TasksFragment.kt

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


The navigation occurs because of a call to the navigate method. If you needed to write an assert statement, there isn't a straightforward way to test whether you've navigated to TaskDetailFragment . Navigating is a complicated action that doesn't result in a clear output or state change, beyond initializing TaskDetailFragment .

What you can assert is that the navigate method was called with the correct action parameter. This is exactly what a mock test double does—it checks whether specific methods were called.

Mockito is a framework for making test doubles. While the word mock is used in the API and name, it is not for just making mocks. It can also make stubs and spies.

You will be using Mockito to make a mock NavigationController which can assert that the navigate method was called correctly.

Step 1. Add Gradle Dependencies

  1. Add the gradle dependencies.

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 —This is the Mockito dependency.
  • dexmaker-mockito —This library is required to use Mockito in an Android project. Mockito needs to generate classes at runtime. On Android, this is done using dex byte code, and so this library enables Mockito to generate objects during runtime on Android.
  • androidx.test.espresso:espresso-contrib —This library is made up of external contributions (hence the name) which contain testing code for more advanced views, such as DatePicker and RecyclerView . It also contains Accessibility checks and class called CountingIdlingResource that is covered later.

Step 2. Create TasksFragmentTest

  1. Open TasksFragment .
  2. Right-click on the TasksFragment class name and select Generate then Test . Create a test in the androidTest source set.
  3. Copy this code to the 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()
    }

}

This code looks similar to the TaskDetailFragmentTest code you wrote. It sets up and tears down a FakeAndroidTestRepository . Add a navigation test to test that when you click on a task in the task list, it takes you to the correct TaskDetailFragment .

  1. Add the test 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. Use Mockito's mock function to create a mock.

TasksFragmentTest.kt

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

To mock in Mockito, pass in the class you want to mock.

Next, you need to associate your NavController with the fragment. onFragment lets you call methods on the fragment itself.

  1. Make your new mock the fragment's NavController .
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. Add the code to click on the item in the RecyclerView that has the text "TITLE1".
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActions is part of the espresso-contrib library and lets you perform Espresso actions on a RecyclerView .

  1. Verify that navigate was called, with the correct argument.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Mockito's verify method is what makes this a mock—you're able to confirm the mocked navController called a specific method ( navigate ) with a parameter ( actionTasksFragmentToTaskDetailFragment with the ID of "id1").

The complete test looks like this:

@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. Run your test!

In summary, to test navigation you can:

  1. Use Mockito to create a NavController mock.
  2. Attach that mocked NavController to the fragment.
  3. Verify that navigate was called with the correct action and parameter(s).

Step 3. Optional, write clickAddTaskButton_navigateToAddEditFragment

To see if you can write a navigation test yourself, try this task.

  1. Write the test clickAddTaskButton_navigateToAddEditFragment which checks that if you click on the + FAB, you navigate to the AddEditTaskFragment .

The answer is below.

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)
            )
        )
    }

Click here to see a diff between the code you started and the final code.

To download the code for the finished codelab, you can use the git command below:

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


یا می‌توانید مخزن را به‌عنوان یک فایل Zip دانلود کنید، آن را از حالت فشرده خارج کنید و در Android Studio باز کنید.

زیپ را دانلود کنید

This codelab covered how to set up manual dependency injection, a service locator, and how to use fakes and mocks in your Android Kotlin apps. In particular:

  • What you want to test and your testing strategy determine the kinds of test you are going to implement for your app. Unit tests are focused and fast. Integration tests verify interaction between parts of your program. End-to-end tests verify features, have the highest fidelity, are often instrumented, and may take longer to run.
  • The architecture of your app influences how hard it is to test.
  • TDD or Test Driven Development is a strategy where you write the tests first, then create the feature to pass the tests.
  • To isolate parts of your app for testing, you can use test doubles. A test double is a version of a class crafted specifically for testing. For example, you fake getting data from a database or the internet.
  • Use dependency injection to replace a real class with a testing class, for example, a repository or a networking layer.
  • Use i nstrumented testing ( androidTest ) to launch UI components.
  • When you can't use constructor dependency injection, for example to launch a fragment, you can often use a service locator. The Service Locator pattern is an alternative to Dependency Injection. It involves creating a singleton class called the "Service Locator", whose purpose is to provide dependencies, both for the regular and test code.

دوره بی ادبی:

مستندات توسعه دهنده اندروید:

Videos:

Other:

برای پیوند به دیگر کدلب ها در این دوره، صفحه فرود Advanced Android in Kotlin Codelabs را ببینید.