Wprowadzenie do obiektów testowych i wstrzykiwania zależności

Te warsztaty są częścią kursu Zaawansowany Android w Kotlinie. Najwięcej korzyści z tego kursu uzyskasz, jeśli przejdziesz wszystkie ćwiczenia w kolejności, ale nie jest to obowiązkowe. Wszystkie ćwiczenia z tego kursu znajdziesz na stronie docelowej ćwiczeń z zaawansowanego Androida w Kotlinie.

Wprowadzenie

Ten drugi moduł z serii poświęcony testowaniu dotyczy obiektów zastępczych: kiedy ich używać w Androidzie i jak je wdrażać za pomocą wstrzykiwania zależności, wzorca Service Locator i bibliotek. Dzięki temu dowiesz się, jak pisać:

  • Testy jednostkowe repozytorium
  • Testy integracji fragmentów i modelu widoku
  • Testy nawigacji po fragmentach

Co warto wiedzieć

Musisz znać:

Czego się nauczysz

  • Jak zaplanować strategię testowania
  • Jak tworzyć i używać obiektów zastępczych, czyli atrap i mocków
  • Jak używać ręcznego wstrzykiwania zależności na Androidzie do testów jednostkowych i integracyjnych
  • Jak stosować wzorzec lokalizatora usług
  • Testowanie repozytoriów, fragmentów, modeli widoku i komponentu Navigation

Będziesz korzystać z tych bibliotek i koncepcji kodu:

Jakie zadania wykonasz

  • Napisz testy jednostkowe dla repozytorium, używając testu zastępczego i wstrzykiwania zależności.
  • Napisz testy jednostkowe dla modelu widoku za pomocą testu podwójnego i wstrzykiwania zależności.
  • Pisać testy integracji fragmentów i ich modeli widoku za pomocą platformy testowej interfejsu Espresso.
  • Pisać testy nawigacji za pomocą Mockito i Espresso.

W tej serii ćwiczeń będziesz pracować z aplikacją TO-DO Notes. Umożliwia ona zapisywanie zadań do wykonania i wyświetlanie ich na liście. Możesz je oznaczać jako ukończone lub nieukończone, filtrować lub usuwać.

Ta aplikacja jest napisana w języku Kotlin, ma kilka ekranów, korzysta z komponentów Jetpack i jest zgodna z architekturą opisaną w przewodniku po architekturze aplikacji. Dzięki temu, że dowiesz się, jak testować tę aplikację, będziesz w stanie testować aplikacje, które korzystają z tych samych bibliotek i architektury.

Pobieranie kodu

Aby rozpocząć, pobierz kod:

Pobierz plik ZIP

Możesz też sklonować repozytorium GitHub, aby uzyskać kod:

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

Poświęć chwilę na zapoznanie się z kodem, wykonując poniższe instrukcje.

Krok 1. Uruchom przykładową aplikację

Po pobraniu aplikacji TO-DO otwórz ją w Android Studio i uruchom. Powinien się skompilować. Poznaj aplikację, wykonując te czynności:

  • Utwórz nowe zadanie za pomocą pływającego przycisku czynności. Najpierw wpisz tytuł, a potem dodatkowe informacje o zadaniu. Zapisz go za pomocą pływającego przycisku czynności z zielonym znacznikiem wyboru.
  • Na liście zadań kliknij tytuł zadania, które właśnie zostało ukończone, i sprawdź ekran szczegółów, aby zobaczyć resztę opisu.
  • Na liście lub na ekranie szczegółów zaznacz pole wyboru tego zadania, aby ustawić jego stan na Ukończone.
  • Wróć do ekranu zadań, otwórz menu filtra i filtruj zadania według stanu AktywneUkończone.
  • Otwórz panel nawigacji i kliknij Statystyki.
  • Wróć do ekranu przeglądu i w menu panelu nawigacyjnego wybierz Wyczyść ukończone, aby usunąć wszystkie zadania ze stanem Ukończone.

Krok 2. Zapoznaj się z kodem przykładowej aplikacji

Aplikacja TO-DO jest oparta na popularnym przykładzie testowym i architektonicznym Architecture Blueprints (w wersji architektury reaktywnej). Aplikacja jest zgodna z architekturą opisaną w przewodniku po architekturze aplikacji. Korzysta z obiektów ViewModel z fragmentami, repozytorium i biblioteką Room. Jeśli znasz któryś z poniższych przykładów, ta aplikacja ma podobną architekturę:

Ważniejsze jest zrozumienie ogólnej architektury aplikacji niż dogłębne poznanie logiki na dowolnej warstwie.

Oto podsumowanie pakietów, które znajdziesz:

Pakiet: com.example.android.architecture.blueprints.todoapp

.addedittask

Ekran dodawania lub edytowania zadania: kod warstwy interfejsu użytkownika do dodawania lub edytowania zadania.

.data

Warstwa danych: dotyczy warstwy danych zadań. Zawiera kod bazy danych, sieci i repozytorium.

.statistics

Ekran statystyk: kod warstwy interfejsu ekranu statystyk.

.taskdetail

Ekran szczegółów zadania: kod warstwy interfejsu pojedynczego zadania.

.tasks

Ekran zadań: kod warstwy interfejsu użytkownika dla listy wszystkich zadań.

.util

Klasy narzędziowe: klasy udostępnione używane w różnych częściach aplikacji, np. w układzie odświeżania przez przesunięcie, który jest używany na wielu ekranach.

Warstwa danych (.data)

Ta aplikacja zawiera symulowaną warstwę sieciową w pakiecie remote i warstwę bazy danych w pakiecie local. W tym projekcie warstwa sieci jest symulowana za pomocą funkcji HashMap z opóźnieniem, a nie za pomocą rzeczywistych żądań sieciowych.

DefaultTasksRepository koordynuje lub pośredniczy między warstwą sieciową a warstwą bazy danych i zwraca dane do warstwy interfejsu.

Warstwa interfejsu ( .addedittask, .statistics, .taskdetail, .tasks)

Każdy z pakietów warstwy interfejsu zawiera fragment i model widoku oraz inne klasy wymagane w interfejsie (np. adapter listy zadań). TaskActivity to aktywność, która zawiera wszystkie fragmenty.

Nawigacja

Nawigacja w aplikacji jest kontrolowana przez komponent nawigacji. Jest on zdefiniowany w pliku nav_graph.xml. Nawigacja jest wywoływana w modelach widoku za pomocą klasy Event. Modele widoku określają też, jakie argumenty mają być przekazywane. Fragmenty obserwują Event i wykonują rzeczywistą nawigację między ekranami.

Z tego ćwiczenia w Codelabs dowiesz się, jak testować repozytoria, modele widoków i fragmenty za pomocą obiektów testowych i wstrzykiwania zależności. Zanim dowiesz się, jakie to są testy, warto zrozumieć, jakie przesłanki będą Cię kierować przy ich pisaniu.

W tej sekcji omówimy kilka sprawdzonych metod testowania w ogóle, które mają zastosowanie w przypadku Androida.

Piramida testów

Gdy myślisz o strategii testowania, musisz wziąć pod uwagę 3 powiązane ze sobą aspekty:

  • Zakres – jak dużą część kodu obejmuje test? Testy mogą być przeprowadzane na pojedynczej metodzie, w całej aplikacji lub w jej części.
  • Szybkość – jak szybko przebiega test? Testowanie może trwać od milisekund do kilku minut.
  • Wierność – jak bardzo test odzwierciedla rzeczywistość? Jeśli np. część testowanego kodu musi wysłać żądanie sieciowe, czy kod testowy faktycznie wysyła to żądanie, czy tylko symuluje wynik? Jeśli test faktycznie komunikuje się z siecią, oznacza to, że jest bardziej wiarygodny. W zamian test może trwać dłużej, powodować błędy w przypadku awarii sieci lub być kosztowny w użyciu.

Między tymi aspektami istnieją nieodłączne kompromisy. Na przykład szybkość i dokładność są ze sobą powiązane – im szybszy test, tym mniejsza dokładność i odwrotnie. Automatyczne testy można podzielić na 3 kategorie:

  • Testy jednostkowe – są to bardzo szczegółowe testy, które są przeprowadzane na jednej klasie, zwykle na jednej metodzie w tej klasie. Jeśli test jednostkowy się nie powiedzie, możesz dokładnie określić, w którym miejscu kodu występuje problem. Testy te mają niską wierność, ponieważ w rzeczywistości aplikacja obejmuje znacznie więcej niż wykonanie jednej metody lub klasy. Działają one wystarczająco szybko, aby można je było uruchamiać za każdym razem, gdy zmieniasz kod. Najczęściej będą to testy przeprowadzane lokalnie (w test zbiorze źródeł). Przykład: testowanie pojedynczych metod w modelach widoku i repozytoriach.
  • Testy integracyjne – testują interakcję kilku klas, aby sprawdzić, czy działają zgodnie z oczekiwaniami, gdy są używane razem. Jednym ze sposobów strukturyzacji testów integracyjnych jest testowanie pojedynczej funkcji, np. możliwości zapisania zadania. Testują one większy zakres kodu niż testy jednostkowe, ale są zoptymalizowane pod kątem szybkości działania, a nie pełnej wierności. W zależności od sytuacji można je uruchamiać lokalnie lub jako testy z instrumentacją. Przykład: testowanie wszystkich funkcji pojedynczej pary fragmentu i modelu widoku.
  • Testy kompleksowe – testowanie kombinacji funkcji działających razem. Testują one duże części aplikacji, dokładnie symulują rzeczywiste użycie, a dlatego zwykle działają wolno. Mają one największą dokładność i informują, czy aplikacja działa jako całość. Zasadniczo będą to testy z instrumentacją (w androidTest źródle)
    Przykład: uruchomienie całej aplikacji i przetestowanie kilku funkcji jednocześnie.

Sugerowany odsetek tych testów jest często przedstawiany w postaci piramidy, w której zdecydowana większość to testy jednostkowe.

Architektura i testowanie

Możliwość testowania aplikacji na różnych poziomach piramidy testów jest nieodłącznie związana z architekturą aplikacji. Na przykład bardzo źle zaprojektowana aplikacja może umieszczać całą logikę w jednej metodzie. Możesz napisać testy kompleksowe, ponieważ zwykle sprawdzają one duże części aplikacji. Ale co z testami jednostkowymi lub integracyjnymi? Gdy cały kod znajduje się w jednym miejscu, trudno jest przetestować tylko kod związany z jedną jednostką lub funkcją.

Lepszym rozwiązaniem byłoby podzielenie logiki aplikacji na wiele metod i klas, co umożliwiłoby testowanie każdego elementu osobno. Architektura to sposób podziału i organizacji kodu, który ułatwia testy jednostkowe i integracyjne. Aplikacja TO-DO, którą będziesz testować, ma określoną architekturę:



W tym szkoleniu dowiesz się, jak testować poszczególne części powyższej architektury w odpowiedniej izolacji:

  1. Najpierw przetestujesz jednostkowo repozytorium.
  2. Następnie w modelu widoku użyjesz testowego zamiennika, który jest niezbędny do testowania jednostkowegotestowania integracyjnego modelu widoku.
  3. Następnie dowiesz się, jak pisać testy integracyjne fragmentów i ich modeli widoku.
  4. Na koniec dowiesz się, jak pisać testy integracyjne, które obejmują komponent Navigation.

Testy kompleksowe omówimy w następnej lekcji.

Gdy piszesz test jednostkowy dla części klasy (metody lub małej kolekcji metod), Twoim celem jest testowanie tylko kodu w tej klasie.

Testowanie kodu tylko w określonej klasie lub klasach może być trudne. Przeanalizujmy poniższy przykład. Otwórz data.source.DefaultTaskRepository klasę w main zestawie źródłowym. Jest to repozytorium aplikacji i klasa, dla której w następnym kroku napiszesz testy jednostkowe.

Twoim celem jest przetestowanie tylko kodu w tej klasie. Jednak DefaultTaskRepository zależy od innych klas, takich jak LocalTaskDataSourceRemoteTaskDataSource. Inaczej mówiąc, LocalTaskDataSourceRemoteTaskDataSourcezależnościami interfejsu DefaultTaskRepository.

Dlatego każda metoda w DefaultTaskRepository wywołuje metody w klasach źródeł danych, które z kolei wywołują metody w innych klasach, aby zapisywać informacje w bazie danych lub komunikować się z siecią.



 Na przykład zapoznaj się z tą metodą w 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 to jedno z najbardziej „podstawowych” wywołań, jakie możesz wykonać w repozytorium. Ta metoda obejmuje odczytywanie z bazy danych SQLite i wykonywanie wywołań sieciowych (wywołanie updateTasksFromRemoteDataSource). Wymaga to znacznie więcej kodu niż tylko kod repozytorium.

Oto bardziej szczegółowe powody, dla których testowanie repozytorium jest trudne:

  • Aby przeprowadzić nawet najprostsze testy w tym repozytorium, musisz się zastanowić nad utworzeniem bazy danych i zarządzaniem nią. Pojawiają się wtedy pytania, np. „czy powinien to być test lokalny czy test z instrumentacją?” oraz „czy do uzyskania symulowanego środowiska Androida należy użyć AndroidX Test?”.
  • Niektóre części kodu, np. kod sieciowy, mogą działać długo, a czasami nawet ulec awarii, co powoduje długotrwałe i niestabilne testy.
  • Testy mogą utracić możliwość diagnozowania, który kod jest przyczyną niepowodzenia testu. Testy mogą zacząć testować kod spoza repozytorium, więc na przykład testy jednostkowe „repozytorium” mogą się nie powieść z powodu problemu w kodzie zależnym, np. w kodzie bazy danych.

Obiekty testowe

Rozwiązaniem tego problemu jest to, że podczas testowania repozytorium nie należy używać prawdziwego kodu sieciowego ani kodu bazy danych, ale zamiast tego używać testowego obiektu zastępczego. Obiekt testowy to wersja klasy utworzona specjalnie na potrzeby testowania. Ma ona zastępować prawdziwą wersję klasy w testach. To podobnie jak w przypadku dublera, który jest aktorem specjalizującym się w wykonywaniu wyczynów kaskaderskich i zastępuje prawdziwego aktora w niebezpiecznych scenach.

Oto niektóre rodzaje obiektów testowych:

Fałszywe

Jest to obiekt testowy, który ma „działającą” implementację klasy, ale jest ona zaimplementowana w taki sposób, że nadaje się do testów, ale nie do środowiska produkcyjnego.

Mock

Obiekt testowy, który śledzi, które z jego metod zostały wywołane. Następnie przechodzi test lub nie, w zależności od tego, czy jego metody zostały wywołane prawidłowo.

Stub

Obiekt testowy, który nie zawiera logiki i zwraca tylko to, co zaprogramujesz. StubTaskRepository można zaprogramować tak, aby zwracał określone kombinacje zadań z getTasks.

Dummy

Obiekt testowy, który jest przekazywany, ale nieużywany, np. gdy musisz go tylko podać jako parametr. Jeśli masz NoOpTaskRepository, w dowolnej metodzie zaimplementujesz tylko TaskRepositorybrakiem kodu.

Spy

Obiekt testowy, który śledzi też dodatkowe informacje, np. jeśli utworzysz SpyTaskRepository, może śledzić, ile razy wywołano metodę addTask.

Więcej informacji o obiektach testowych znajdziesz w artykule Testing on the Toilet: Know Your Test Doubles (po angielsku).

Najczęściej używane w Androidzie obiekty testowe to FakesMocks.

W tym zadaniu utworzysz FakeDataSource obiekt testowy, aby przeprowadzić test jednostkowy DefaultTasksRepository niezależnie od rzeczywistych źródeł danych.

Krok 1. Utwórz klasę FakeDataSource

W tym kroku utworzysz klasę o nazwie FakeDataSouce, która będzie podwójnym testem klasy LocalDataSource i RemoteDataSource.

  1. W zestawie źródeł test kliknij prawym przyciskiem myszy Nowy –> Pakiet.

  1. Utwórz pakiet data z pakietem source wewnątrz.
  2. Utwórz nową klasę o nazwie FakeDataSource w pakiecie data/source.

Krok 2. Zaimplementuj interfejs TasksDataSource

Aby można było użyć nowej klasy FakeDataSource jako obiektu testowego, musi ona być w stanie zastąpić inne źródła danych. Źródła danych to TasksLocalDataSourceTasksRemoteDataSource.

  1. Zwróć uwagę, że oba te elementy implementują interfejs TasksDataSource.
class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }
  1. Spraw, aby FakeDataSource implementował TasksDataSource:
class FakeDataSource : TasksDataSource {

}

Android Studio zgłosi, że nie zaimplementowano wymaganych metod dla TasksDataSource.

  1. Skorzystaj z menu szybkiej poprawki i wybierz Wdróż funkcję wspierania.


  1. Wybierz wszystkie metody i kliknij OK.

Krok 3. Zaimplementuj metodę getTasks w klasie FakeDataSource

FakeDataSource to szczególny rodzaj obiektu testowego, zwany obiektem zastępczym. Obiekt zastępczy to obiekt testowy, który ma „działającą” implementację klasy, ale jest zaimplementowany w taki sposób, że nadaje się do testów, ale nie do środowiska produkcyjnego. „Działająca” implementacja oznacza, że klasa będzie generować realistyczne dane wyjściowe na podstawie danych wejściowych.

Na przykład fałszywe źródło danych nie będzie łączyć się z siecią ani zapisywać niczego w bazie danych – zamiast tego będzie używać listy w pamięci. Będzie to „działać zgodnie z oczekiwaniami”, ponieważ metody pobierania i zapisywania zadań będą zwracać oczekiwane wyniki, ale nigdy nie będzie można użyć tej implementacji w środowisku produkcyjnym, ponieważ nie jest ona zapisywana na serwerze ani w bazie danych.

FakeDataSource

  • umożliwia testowanie kodu w DefaultTasksRepository bez konieczności korzystania z prawdziwej bazy danych lub sieci.
  • zapewnia „wystarczająco realistyczną” implementację na potrzeby testów.
  1. Zmień konstruktor FakeDataSource, aby utworzyć var o nazwie tasks, który jest MutableList<Task>? z domyślną wartością pustej listy modyfikowalnej.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


To lista zadań, które „udają” bazę danych lub odpowiedź serwera. Na razie celem jest przetestowanie metody getTasks repozytorium. Wywołuje to metody źródła danych getTasks, deleteAllTaskssaveTask.

Napisz fałszywą wersję tych metod:

  1. Zapisz getTasks: jeśli tasks nie jest równe null, zwróć wynik Success. Jeśli tasks ma wartość null, zwróć wynik Error.
  2. Zapisz deleteAllTasks: wyczyść listę zadań, które można zmieniać.
  3. Napisz saveTask: dodaj zadanie do listy.

Te metody zaimplementowane dla FakeDataSource wyglądają jak poniższy kod.

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

W razie potrzeby możesz użyć tych instrukcji importu:

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

Działa to podobnie jak rzeczywiste lokalne i zdalne źródła danych.

W tym kroku użyjesz techniki ręcznego wstrzykiwania zależności, aby móc używać utworzonego właśnie fałszywego obiektu testowego.

Główny problem polega na tym, że masz FakeDataSource, ale nie wiadomo, jak używasz go w testach. Musi zastąpić atrybuty TasksRemoteDataSourceTasksLocalDataSource, ale tylko w testach. Zarówno TasksRemoteDataSource, jak i TasksLocalDataSource są zależnościami DefaultTasksRepository, co oznacza, że DefaultTasksRepositories wymaga tych klas do działania.

Obecnie zależności są tworzone w metodzie init klasy 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
}

Ponieważ tworzysz i przypisujesz taskLocalDataSourcetasksRemoteDataSourceDefaultTasksRepository, są one w zasadzie zakodowane na stałe. Nie ma możliwości zastąpienia testu podwójnego.

Zamiast tego przekaż te źródła danych do klasy, zamiast je na stałe w niej zakodować. Dostarczanie zależności jest znane jako wstrzykiwanie zależności. Zależności można podawać na różne sposoby, dlatego istnieją różne typy wstrzykiwania zależności.

Wstrzykiwanie zależności w konstruktorze umożliwia zastąpienie obiektu testowego przez przekazanie go do konstruktora.

Brak wstrzykiwania

Wstrzykiwanie

Krok 1. Użyj wstrzykiwania zależności w konstruktorze w klasie DefaultTasksRepository

  1. Zmień konstruktor DefaultTaskRepository, aby zamiast Application przyjmował źródła danych i dyspozytor korutyny (który musisz też zamienić w testach – więcej informacji znajdziesz w trzeciej lekcji o korutynach).

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. Ponieważ zależności zostały przekazane, usuń metodę init. Nie musisz już tworzyć zależności.
  2. Usuń też stare zmienne instancji. Definiujesz je w konstruktorze:

DefaultTasksRepository.kt

// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
  1. Na koniec zaktualizuj metodę getRepository, aby używała nowego konstruktora:

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

Korzystasz teraz z wstrzykiwania zależności przez konstruktor.

Krok 2. Użyj FakeDataSource w testach

Teraz, gdy Twój kod korzysta z wstrzykiwania zależności w konstruktorze, możesz użyć fałszywego źródła danych do przetestowania funkcji DefaultTasksRepository.

  1. Kliknij prawym przyciskiem myszy nazwę klasy DefaultTasksRepository i wybierz Generate (Wygeneruj), a następnie Test (Testuj).
  2. Postępuj zgodnie z instrukcjami, aby utworzyć DefaultTasksRepositoryTesttestowym zestawie źródeł.
  3. U góry nowej klasy DefaultTasksRepositoryTest dodaj zmienne członkowskie poniżej, aby reprezentować dane w fałszywych źródłach danych.

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. Utwórz 3 zmienne: 2 zmienne FakeDataSource (po jednej dla każdego źródła danych w repozytorium) i zmienną dla DefaultTasksRepository, którą będziesz testować.

DefaultTasksRepositoryTest.kt

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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

Utwórz metodę konfigurowania i inicjowania testowalnego DefaultTasksRepository. Ten DefaultTasksRepository będzie używać testowego dublera FakeDataSource.

  1. Utwórz metodę o nazwie createRepository i dodaj do niej adnotację @Before.
  2. Utwórz instancje fałszywych źródeł danych, korzystając z list remoteTaskslocalTasks.
  3. Utwórz instancję tasksRepository, używając 2 utworzonych przez siebie fałszywych źródeł danych i Dispatchers.Unconfined.

Ostateczna metoda powinna wyglądać tak, jak pokazano poniżej.

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

Krok 3. Napisz test funkcji DefaultTasksRepository getTasks()

Czas napisać test DefaultTasksRepository.

  1. Napisz test dla metody getTasks w repozytorium. Sprawdź, czy po wywołaniu funkcji getTasks z parametrem true (co oznacza, że powinna ona ponownie załadować dane ze zdalnego źródła danych) zwraca dane ze zdalnego źródła danych (a nie z lokalnego źródła danych).

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

Podczas wywoływania funkcji getTasks: pojawi się błąd.

Krok 4. Dodaj runBlockingTest

Błąd korutyny jest oczekiwany, ponieważ getTasks to funkcja suspend i aby ją wywołać, musisz uruchomić korutynę. W tym celu potrzebujesz zakresu korutyny. Aby rozwiązać ten błąd, musisz dodać zależności Gradle do obsługi uruchamiania korutyn w testach.

  1. Dodaj wymagane zależności do testowania współprogramów do zestawu źródeł testowych za pomocą testImplementation.

app/build.gradle

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

Nie zapomnij o synchronizacji!

kotlinx-coroutines-test to biblioteka testów coroutines, która jest przeznaczona specjalnie do testowania coroutines. Aby uruchomić testy, użyj funkcji runBlockingTest. Jest to funkcja udostępniana przez bibliotekę testową coroutines. Przyjmuje blok kodu, a potem uruchamia go w specjalnym kontekście współprogramu, który działa synchronicznie i natychmiastowo, co oznacza, że działania będą wykonywane w określonej kolejności. Sprawia to, że Twoje korutyny działają jak zwykłe funkcje, więc jest to przydatne do testowania kodu.

Używaj runBlockingTest w klasach testowych, gdy wywołujesz funkcję suspend. Więcej informacji o tym, jak działa runBlockingTest, i o testowaniu korutyn znajdziesz w następnym module z tej serii.

  1. Dodaj @ExperimentalCoroutinesApi nad klasą. Oznacza to, że w klasie używasz eksperymentalnego interfejsu API do tworzenia współprogramów (runBlockingTest). W przeciwnym razie otrzymasz ostrzeżenie.
  2. Wróć do DefaultTasksRepositoryTest i dodaj runBlockingTest, aby cały test był traktowany jako „blok” kodu.

Ostatni test wygląda tak jak kod poniżej.

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. Uruchom nowy test getTasks_requestsAllTasksFromRemoteDataSource i sprawdź, czy działa, a błąd zniknął.

Właśnie pokazaliśmy, jak przeprowadzić test jednostkowy repozytorium. W kolejnych krokach ponownie użyjesz wstrzykiwania zależności i utworzysz kolejny obiekt testowy. Tym razem pokażemy, jak pisać testy jednostkowe i integracyjne dla modeli widoków.

Testy jednostkowe powinny tylko testować klasę lub metodę, która Cię interesuje. Jest to tzw. testowanie w izolacji, w którym wyraźnie izolujesz „jednostkę” i testujesz tylko kod, który jest jej częścią.

Dlatego TasksViewModelTest powinien testować tylko kod TasksViewModel – nie powinien testować klas bazy danych, sieci ani repozytorium. Dlatego w przypadku modeli widoku, podobnie jak w przypadku repozytorium, utworzysz fałszywe repozytorium i zastosujesz wstrzykiwanie zależności, aby używać go w testach.

W tym zadaniu zastosujesz wstrzykiwanie zależności do modeli widoku.

Krok 1. Tworzenie interfejsu TasksRepository

Pierwszym krokiem do użycia wstrzykiwania zależności w konstruktorze jest utworzenie wspólnego interfejsu, który będzie współdzielony przez klasę testową i rzeczywistą.

Jak to wygląda w praktyce? Spójrz na TasksRemoteDataSource, TasksLocalDataSource i FakeDataSource i zauważ, że wszystkie mają ten sam interfejs: TasksDataSource. Dzięki temu w konstruktorze klasy DefaultTasksRepository możesz określić, że przyjmuje ona obiekt TasksDataSource.

DefaultTasksRepository.kt

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

Dzięki temu możemy zastąpić FakeDataSource.

Następnie utwórz interfejs dla DefaultTasksRepository, tak jak w przypadku źródeł danych. Musi zawierać wszystkie publiczne metody (publiczny interfejs API) klasy DefaultTasksRepository.

  1. Otwórz DefaultTasksRepository i kliknij prawym przyciskiem myszy nazwę zajęć. Następnie wybierz Refactor -> Extract -> Interface (Refaktoryzacja –> Wyodrębnij –> Interfejs).

  1. Kliknij Wyodrębnij do osobnego pliku.

  1. W oknie Wyodrębnij interfejs zmień nazwę interfejsu na TasksRepository.
  2. W sekcji Members to form interface (Członkowie do utworzenia interfejsu) zaznacz wszystkich członków z wyjątkiem 2 członków towarzyszących i metod prywatnych.


  1. Kliknij Refactor (Refaktoryzuj). Nowy interfejs TasksRepository powinien pojawić się w pakiecie data/source .

DefaultTasksRepository teraz implementuje TasksRepository.

  1. Uruchom aplikację (nie testy), aby sprawdzić, czy wszystko działa prawidłowo.

Krok 2. Tworzenie obiektu FakeTestRepository

Teraz, gdy masz już interfejs, możesz utworzyć DefaultTaskRepository test double.

  1. W zestawie źródeł test w folderze data/source utwórz plik Kotlin i klasę FakeTestRepository.kt oraz rozszerz interfejs TasksRepository.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

Pojawi się komunikat, że musisz zaimplementować metody interfejsu.

  1. Najedź kursorem na błąd, aż pojawi się menu sugestii, a następnie kliknij i wybierz Implement members (Zaimplementuj członków).
  1. Wybierz wszystkie metody i kliknij OK.

Krok 3. Implementowanie metod FakeTestRepository

Masz teraz klasę FakeTestRepository z metodami „not implemented”. Podobnie jak w przypadku FakeDataSource, FakeTestRepository będzie obsługiwany przez strukturę danych, zamiast zajmować się skomplikowanym pośrednictwem między lokalnymi i zdalnymi źródłami danych.

Pamiętaj, że Twój model FakeTestRepository nie musi używać FakeDataSource ani niczego podobnego. Wystarczy, że będzie zwracać realistyczne fałszywe dane wyjściowe na podstawie danych wejściowych. Do przechowywania listy zadań użyjesz LinkedHashMap, a do zadań obserwowanych – MutableLiveData.

  1. FakeTestRepository dodaj zarówno zmienną LinkedHashMap reprezentującą bieżącą listę zadań, jak i MutableLiveData dla obserwowalnych zadań.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

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

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


    // Rest of class
}

Zaimplementuj te metody:

  1. getTasks – ta metoda powinna pobrać tasksServiceData i przekształcić go w listę za pomocą tasksServiceData.values.toList(), a następnie zwrócić ją jako wynik Success.
  2. refreshTasks– aktualizuje wartość observableTasks na wartość zwracaną przez getTasks().
  3. observeTasks— Tworzy korutynę za pomocą runBlocking i uruchamia refreshTasks, a następnie zwraca observableTasks.

Poniżej znajdziesz kod tych metod.

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

}

Krok 4. Dodaj metodę testowania do funkcji addTasks

Podczas testowania lepiej mieć w repozytorium już jakieś Tasks. Możesz wywoływać funkcję saveTask kilka razy, ale aby to ułatwić, dodaj metodę pomocniczą specjalnie do testów, która umożliwia dodawanie zadań.

  1. Dodaj metodę addTasks, która przyjmuje vararg zadań, dodaje każde z nich do HashMap, a następnie odświeża zadania.

FakeTestRepository.kt

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

W tym momencie masz już fałszywe repozytorium do testowania z kilkoma zaimplementowanymi kluczowymi metodami. Następnie użyj go w testach.

W tym zadaniu użyjesz fałszywej klasy w ViewModel. Użyj wstrzykiwania zależności w konstruktorze, aby przekazać 2 źródła danych za pomocą wstrzykiwania zależności w konstruktorze, dodając zmienną TasksRepository do konstruktora TasksViewModel.

W przypadku modeli widoku ten proces wygląda nieco inaczej, ponieważ nie tworzysz ich bezpośrednio. Na przykład:

class TasksFragment : Fragment() {

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

}


Podobnie jak w powyższym kodzie używasz viewModel's delegata właściwości, który tworzy model widoku. Aby zmienić sposób tworzenia modelu widoku, musisz dodać i użyć ViewModelProvider.Factory. Jeśli nie znasz ViewModelProvider.Factory, więcej informacji znajdziesz tutaj.

Krok 1. Tworzenie i używanie ViewModelFactory w TasksViewModel

Zacznij od zaktualizowania klas i testów związanych z ekranem Tasks.

  1. Otwórz TasksViewModel.
  2. Zmień konstruktor klasy TasksViewModel, aby przyjmował obiekt TasksRepository zamiast go tworzyć w klasie.

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 
}

Konstruktor został zmieniony, więc do utworzenia obiektu TasksViewModel musisz teraz użyć fabryki. Umieść klasę fabryczną w tym samym pliku co TasksViewModel, ale możesz też umieścić ją w osobnym pliku.

  1. U dołu pliku TasksViewModel, poza klasą, dodaj TasksViewModelFactory, który przyjmuje zwykły 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)
}


To standardowy sposób zmiany sposobu tworzenia ViewModel. Teraz, gdy masz już fabrykę, używaj jej wszędzie tam, gdzie tworzysz model widoku.

  1. Zaktualizuj aplikację TasksFragment, aby korzystać z fabryki.

TasksFragment.kt

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

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. Uruchom kod aplikacji i sprawdź, czy wszystko nadal działa.

Krok 2. Używanie FakeTestRepository w klasie TasksViewModelTest

Teraz zamiast prawdziwego repozytorium w testach modelu widoku możesz używać fałszywego repozytorium.

  1. Otwórz TasksViewModelTest.
  2. Dodaj właściwość FakeTestRepositoryTasksViewModelTest.

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. Zaktualizuj metodę setupViewModel, aby utworzyć FakeTestRepository z 3 zadaniami, a następnie skonstruuj tasksViewModel za pomocą tego repozytorium.

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. Ponieważ nie używasz już kodu AndroidX Test ApplicationProvider.getApplicationContext, możesz też usunąć adnotację @RunWith(AndroidJUnit4::class).
  2. Przeprowadź testy i sprawdź, czy wszystko działa.

Dzięki wstrzykiwaniu zależności w konstruktorze usunęliśmy DefaultTasksRepository jako zależność i zastąpiliśmy go w testach elementem FakeTestRepository.

Krok 3. Aktualizowanie też fragmentu TaskDetail i widoku modelu

Wprowadź dokładnie te same zmiany w przypadku atrybutów TaskDetailFragmentTaskDetailViewModel. Przygotuje to kod do napisania TaskDetail testów.

  1. Otwórz TaskDetailViewModel.
  2. Zaktualizuj konstruktor:

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. U dołu pliku TaskDetailViewModel, poza klasą, dodaj 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. Zaktualizuj aplikację TasksFragment, aby korzystać z fabryki.

TasksFragment.kt

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

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. Uruchom kod i sprawdź, czy wszystko działa.

Teraz możesz używać FakeTestRepository zamiast prawdziwego repozytorium w przypadku TasksFragmentTasksDetailFragment.

Następnie napiszesz testy integracyjne, aby sprawdzić interakcje fragmentu i modelu widoku. Sprawdzisz, czy kod modelu widoku prawidłowo aktualizuje interfejs. W tym celu użyj

  • wzorzec ServiceLocator,
  • biblioteki Espresso i Mockito,

Testy integracyjne sprawdzają interakcję kilku klas, aby upewnić się, że działają one zgodnie z oczekiwaniami, gdy są używane razem. Testy te można uruchamiać lokalnie (test zestaw źródeł) lub jako testy instrumentacyjne (androidTest zestaw źródeł).

W Twoim przypadku będziesz brać każdy fragment i pisać testy integracyjne dla fragmentu i modelu widoku, aby przetestować główne funkcje fragmentu.

Krok 1. Dodawanie zależności Gradle

  1. Dodaj te zależności 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"

Zależności te obejmują:

  • junit:junit– JUnit, który jest niezbędny do pisania podstawowych instrukcji testowych.
  • androidx.test:core– podstawowa biblioteka testowa AndroidX
  • kotlinx-coroutines-test– biblioteka do testowania współprogramów;
  • androidx.fragment:fragment-testing– biblioteka testowa AndroidX do tworzenia fragmentów w testach i zmiany ich stanu.

Ponieważ będziesz używać tych bibliotek w androidTestzestawie źródeł, dodaj je jako zależności za pomocą androidTestImplementation.

Krok 2. Utwórz klasę TaskDetailFragmentTest

TaskDetailFragment – zawiera informacje o jednym zadaniu.

Zacznij od napisania testu fragmentu TaskDetailFragment, ponieważ ma on dość podstawową funkcjonalność w porównaniu z innymi fragmentami.

  1. Otwórz taskdetail.TaskDetailFragment.
  2. Wygeneruj test dla TaskDetailFragment, tak jak wcześniej. Zaakceptuj domyślne wybory i umieść je w zestawie źródeł androidTest (NIE w zestawie źródeł test).

  1. Dodaj do klasy TaskDetailFragmentTest te adnotacje:

TaskDetailFragmentTest.kt

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

}

Celem tych adnotacji jest:

  • @MediumTest – oznacza test jako test integracji o „średnim czasie działania” (w przeciwieństwie do testów jednostkowych @SmallTest i dużych testów kompleksowych @LargeTest). Ułatwia to grupowanie i wybieranie rozmiaru testu do przeprowadzenia.
  • @RunWith(AndroidJUnit4::class) – używane w każdej klasie korzystającej z AndroidX Test.

Krok 3. Uruchamianie fragmentu z testu

W tym zadaniu uruchomisz TaskDetailFragment za pomocą biblioteki AndroidX Testing. FragmentScenario to klasa z AndroidX Test, która otacza fragment i zapewnia bezpośrednią kontrolę nad cyklem życia fragmentu na potrzeby testowania. Aby napisać testy fragmentów, utwórz FragmentScenario dla testowanego fragmentu (TaskDetailFragment).

  1. Skopiuj ten test do usługi 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)

    }

Ten kod powyżej:

To nie jest jeszcze ukończony test, ponieważ nie zawiera żadnych asercji. Na razie przeprowadź test i obserwuj, co się stanie.

  1. Jest to test z użyciem instrumentacji, więc upewnij się, że emulator lub urządzenie są widoczne.
  2. Uruchom test.

Powinno się wydarzyć kilka rzeczy.

  • Po pierwsze, ponieważ jest to test z instrumentacją, zostanie on przeprowadzony na urządzeniu fizycznym (jeśli jest podłączone) lub na emulatorze.
  • Powinien uruchomić fragment.
  • Zwróć uwagę, że nie przechodzi on do żadnego innego fragmentu ani nie ma żadnych menu powiązanych z działaniem – jest to tylko fragment.

Na koniec przyjrzyj się uważnie i zauważ, że fragment zawiera komunikat „Brak danych”, ponieważ nie udało się wczytać danych zadania.

Test musi wczytać plik TaskDetailFragment (co już zostało zrobione) i sprawdzić, czy dane zostały wczytane prawidłowo. Dlaczego nie ma danych? Wynika to z tego, że zadanie zostało utworzone, ale nie zostało zapisane w repozytorium.

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

    }

Masz ten FakeTestRepository, ale potrzebujesz sposobu na zastąpienie prawdziwego repozytorium fałszywym w przypadku fragmentu. Zrobisz to w następnym kroku.

W tym zadaniu udostępnisz fałszywe repozytorium fragmentowi za pomocą ServiceLocator. Umożliwi Ci to pisanie testów integracyjnych fragmentu i modelu widoku.

Nie możesz tu używać wstrzykiwania zależności w konstruktorze, jak to było wcześniej, gdy trzeba było przekazać zależność do modelu widoku lub repozytorium. Wstrzykiwanie zależności przez konstruktor wymaga utworzenia klasy. Fragmenty i aktywności to przykłady klas, których nie tworzysz i do których konstruktora zwykle nie masz dostępu.

Fragmentu nie tworzysz samodzielnie, więc nie możesz użyć wstrzykiwania zależności w konstruktorze, aby zamienić w nim testowy obiekt zastępczy repozytorium (FakeTestRepository). Zamiast tego użyj wzorca lokalizatora usług. Wzorzec lokalizatora usług jest alternatywą dla wstrzykiwania zależności. Polega ona na utworzeniu klasy singleton o nazwie „Service Locator”, której zadaniem jest dostarczanie zależności zarówno do zwykłego kodu, jak i do kodu testowego. W zwykłym kodzie aplikacji (zestaw źródeł main) wszystkie te zależności są zwykłymi zależnościami aplikacji. W przypadku testów zmodyfikuj lokalizator usług, aby udostępniał wersje zastępcze zależności.

Nie korzystasz z lokalizatora usług


Korzystanie z lokalizatora usług

W przypadku aplikacji z tego ćwiczenia wykonaj te czynności:

  1. Utwórz klasę lokalizatora usług, która może tworzyć i przechowywać repozytorium. Domyślnie tworzy „normalne” repozytorium.
  2. Przeprowadź refaktoryzację kodu, aby w razie potrzeby używać lokalizatora usług.
  3. W klasie testowej wywołaj metodę w lokalizatorze usług, która zastąpi „normalne” repozytorium podwójnym testem.

Krok 1. Tworzenie obiektu ServiceLocator

Utwórzmy zajęcia ServiceLocator. Będzie on znajdować się w głównym zestawie źródeł wraz z pozostałą częścią kodu aplikacji, ponieważ jest używany przez główny kod aplikacji.

Uwaga: ServiceLocator to singleton, więc w przypadku klasy użyj słowa kluczowego object w Kotlinie.

  1. Utwórz plik ServiceLocator.kt w głównym zestawie źródeł.
  2. Zdefiniuj object o nazwie ServiceLocator.
  3. Utwórz zmienne instancji databaserepository i ustaw dla nich wartość null.
  4. Dodaj do repozytorium adnotację @Volatile, ponieważ może być ono używane przez wiele wątków (szczegółowe wyjaśnienie symbolu @Volatile znajdziesz tutaj).

Kod powinien wyglądać tak, jak pokazano poniżej.

object ServiceLocator {

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

}

Obecnie jedyne, co musi robić Twój ServiceLocator, to zwracać TasksRepository. W razie potrzeby zwróci istniejący obiekt DefaultTasksRepository lub utworzy i zwróci nowy obiekt DefaultTasksRepository.

Zdefiniuj te funkcje:

  1. provideTasksRepository – udostępnia istniejące repozytorium lub tworzy nowe. Aby uniknąć przypadkowego utworzenia dwóch instancji repozytorium w sytuacjach, w których działa wiele wątków, ta metoda powinna być synchronizedthis.
  2. createTasksRepository – kod do tworzenia nowego repozytorium. Wywoła createTaskLocalDataSource i utworzy nowy TasksRemoteDataSource.
  3. createTaskLocalDataSource – kod do tworzenia nowego lokalnego źródła danych. Zadzwonię pod numer createDataBase.
  4. createDataBase – kod do tworzenia nowej bazy danych.

Gotowy kod znajdziesz poniżej.

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

Krok 2. Używanie klasy ServiceLocator w aplikacji

Wprowadzisz zmianę w głównym kodzie aplikacji (nie w testach), aby utworzyć repozytorium w jednym miejscu, czyli w ServiceLocator.

Ważne jest, aby utworzyć tylko jedną instancję klasy repozytorium. Aby to zapewnić, użyjesz lokalizatora usług w klasie Application.

  1. Na najwyższym poziomie hierarchii pakietów otwórz TodoApplication i utwórz val dla repozytorium, a następnie przypisz mu repozytorium uzyskane za pomocą 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())
    }
}

Po utworzeniu repozytorium w aplikacji możesz usunąć starą metodę getRepositoryDefaultTasksRepository.

  1. Otwórz DefaultTasksRepository i usuń obiekt towarzyszący.

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

Teraz wszędzie, gdzie używasz getRepository, używaj taskRepository aplikacji. Dzięki temu zamiast tworzyć repozytorium bezpośrednio, otrzymasz repozytorium udostępnione przez ServiceLocator.

  1. Otwórz TaskDetailFragement i znajdź wywołanie getRepository u góry klasy.
  2. Zastąp to wywołanie wywołaniem, które pobiera repozytorium z domeny 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. To samo zrób w przypadku 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. W przypadku StatisticsViewModelAddEditTaskViewModel zaktualizuj kod, który pobiera repozytorium, aby używać repozytorium z TodoApplication.

TasksFragment.kt

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



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. Uruchom aplikację (nie test)!

Po refaktoryzacji aplikacja powinna działać bez problemów.

Krok 3. Tworzenie repozytorium FakeAndroidTestRepository

W zestawie źródeł testowych masz już FakeTestRepository. Domyślnie nie możesz udostępniać klas testowych między zestawami źródeł testandroidTest. Musisz więc utworzyć zduplikowaną klasę FakeTestRepository w zestawie źródeł androidTest i nadać jej nazwę FakeAndroidTestRepository.

  1. Kliknij prawym przyciskiem myszy zestaw źródłowy androidTest i utwórz pakiet danych. Ponownie kliknij prawym przyciskiem myszy i utwórz pakiet źródłowy .
  2. Utwórz w tym pakiecie źródłowym nową klasę o nazwie FakeAndroidTestRepository.kt.
  3. Skopiuj do tych zajęć poniższy kod.

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

Krok 4. Przygotowywanie klasy ServiceLocator do testów

Czas użyć ServiceLocator, aby podczas testowania zamienić testowe obiekty zastępcze. Aby to zrobić, musisz dodać kod do kodu ServiceLocator.

  1. Otwórz ServiceLocator.kt.
  2. Oznacz setter dla tasksRepository jako @VisibleForTesting. Ta adnotacja informuje, że powodem, dla którego setter jest publiczny, są testy.

ServiceLocator.kt

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

Niezależnie od tego, czy test jest przeprowadzany samodzielnie, czy w grupie testów, powinien działać dokładnie tak samo. Oznacza to, że testy nie powinny mieć żadnych zachowań, które są od siebie zależne (co oznacza unikanie udostępniania obiektów między testami).

Ponieważ ServiceLocator jest singletonem, może zostać przypadkowo udostępniony między testami. Aby tego uniknąć, utwórz metodę, która prawidłowo resetuje stan ServiceLocator między testami.

  1. Dodaj zmienną instancji o nazwie lock z wartością Any.

ServiceLocator.kt

private val lock = Any()
  1. Dodaj metodę przeznaczoną do testowania o nazwie resetRepository, która czyści bazę danych i ustawia zarówno repozytorium, jak i bazę danych na wartość 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
        }
    }

Krok 5. Używanie ServiceLocator

W tym kroku użyjesz ServiceLocator.

  1. Otwórz TaskDetailFragmentTest.
  2. Zadeklaruj zmienną lateinit TasksRepository.
  3. Dodaj metodę konfiguracji i metodę zamykania, aby skonfigurować FakeAndroidTestRepository przed każdym testem i wyczyścić go po każdym teście.

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

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

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. Owiń treść funkcji activeTaskDetails_DisplayedInUi()runBlockingTest.
  2. Zanim uruchomisz fragment, zapisz activeTask w repozytorium.
repository.saveTask(activeTask)

Ostateczny test wygląda tak jak poniższy kod.

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. Dodaj adnotacje do całej klasy za pomocą @ExperimentalCoroutinesApi.

Po zakończeniu kod będzie wyglądać tak.

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

Podobnie jak wcześniej, powinien być widoczny fragment, ale tym razem, ponieważ repozytorium zostało prawidłowo skonfigurowane, wyświetlają się informacje o zadaniu.


W tym kroku użyjesz biblioteki testów interfejsu Espresso, aby przeprowadzić pierwszy test integracyjny. Kod jest uporządkowany w taki sposób, że możesz dodawać testy z asercjami interfejsu. W tym celu użyjesz biblioteki testowej Espresso.

Espresso pomaga:

  • interakcji z widokami, np. klikania przycisków, przesuwania paska lub przewijania ekranu w dół;
  • Sprawdzanie, czy określone widoki są widoczne na ekranie lub czy są w określonym stanie (np. czy zawierają określony tekst lub czy pole wyboru jest zaznaczone itp.).

Krok 1. Uwaga dotycząca zależności Gradle

Główna zależność Espresso jest już dostępna, ponieważ jest domyślnie uwzględniana w projektach na Androida.

app/build.gradle

dependencies {

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

androidx.test.espresso:espresso-core – ta podstawowa zależność Espresso jest domyślnie uwzględniana podczas tworzenia nowego projektu na Androida. Zawiera podstawowy kod testowy dla większości widoków i działań w nich.

Krok 2. Wyłączanie animacji

Testy Espresso są przeprowadzane na rzeczywistym urządzeniu, więc z natury są testami z instrumentacją. Jednym z problemów są animacje: jeśli animacja się opóźnia i próbujesz sprawdzić, czy widok jest na ekranie, ale animacja nadal trwa, Espresso może przypadkowo zakończyć test niepowodzeniem. Może to powodować niestabilność testów Espresso.

W przypadku testów interfejsu Espresso zalecamy wyłączenie animacji (testy będą też działać szybciej):

  1. Na urządzeniu testowym otwórz Ustawienia > Opcje programisty.
  2. Wyłącz te 3 ustawienia: Skala animacji okna, Skala animacji przejściaSkala długości animacji.

Krok 3. Przykład testu Espresso

Zanim napiszesz test Espresso, zapoznaj się z kodem Espresso.

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

To polecenie znajduje widok pola wyboru o identyfikatorze task_detail_complete_checkbox, klika go, a następnie sprawdza, czy jest zaznaczone.

Większość instrukcji Espresso składa się z 4 części:

1. Metoda statycznego espresso

onView

onView to przykład statycznej metody Espresso, która rozpoczyna instrukcję Espresso. onView jest jednym z najpopularniejszych, ale są też inne opcje, np. onData.

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId to przykład ViewMatcher, który pobiera widok według identyfikatora. W dokumentacji znajdziesz inne narzędzia do dopasowywania widoków.

3. ViewAction

perform(click())

Metoda perform, która przyjmuje argument ViewAction. ViewAction to czynność, którą można wykonać w widoku, np. kliknąć go.

4. ViewAssertion

check(matches(isChecked()))

check, co zajmuje ViewAssertion. ViewAssertions sprawdza lub potwierdza coś w widoku. Najczęściej używanym ViewAssertion jest asercja matches. Aby zakończyć asercję, użyj innego znaku ViewMatcher, w tym przypadku isChecked.

Pamiętaj, że w instrukcji Espresso nie zawsze wywołujesz oba elementy performcheck. Możesz mieć instrukcje, które tylko potwierdzają coś za pomocą check lub tylko wykonują działanie ViewAction za pomocą perform.

  1. Otwórz TaskDetailFragmentTest.kt.
  2. Zaktualizuj test 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())))
    }

W razie potrzeby możesz użyć tych instrukcji importowania:

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. Wszystko, co znajduje się po komentarzu // THEN, korzysta z Espresso. Sprawdź strukturę testu i użycie symbolu withId oraz sprawdź, czy są w nim stwierdzenia dotyczące wyglądu strony szczegółów.
  2. Uruchom test i sprawdź, czy został zaliczony.

Krok 4. Opcjonalnie: napisz własny test Espresso

Teraz napisz test samodzielnie.

  1. Utwórz nowy test o nazwie completedTaskDetails_DisplayedInUi i skopiuj ten kod szkieletowy.

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. Na podstawie poprzedniego testu ukończ ten test.
  2. Uruchom test i potwierdź, że został zaliczony.

Gotowy completedTaskDetails_DisplayedInUi powinien wyglądać jak ten kod.

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

W tym ostatnim kroku dowiesz się, jak przetestować komponent nawigacji za pomocą innego rodzaju obiektu testowego, zwanego atrapą, oraz biblioteki testowej Mockito.

W tym module wykorzystaliśmy testowy obiekt zastępczy zwany fałszywym obiektem. Obiekty zastępcze to jeden z wielu rodzajów testowych duplikatów. Jakiego obiektu testowego należy użyć do testowania komponentu Navigation?

Zastanów się, jak odbywa się nawigacja. Wyobraź sobie, że klikasz jedno z zadań w sekcji TasksFragment, aby przejść do ekranu szczegółów zadania.

Oto kod w TasksFragment, który po naciśnięciu przenosi użytkownika na ekran szczegółów zadania.

TasksFragment.kt

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


Nawigacja następuje z powodu wywołania metody navigate. Jeśli musisz napisać instrukcję assert, nie ma prostego sposobu na sprawdzenie, czy nastąpiło przejście do TaskDetailFragment. Nawigacja to złożona czynność, która nie daje jasnego wyniku ani zmiany stanu poza zainicjowaniem TaskDetailFragment.

Możesz sprawdzić, czy metoda navigate została wywołana z prawidłowym parametrem działania. Właśnie to robi obiekt testowy typu mock – sprawdza, czy wywołano określone metody.

Mockito to platforma do tworzenia obiektów testowych. Chociaż w interfejsie API i nazwie użyto słowa „mock”, nie służy on tylko do tworzenia wersji demonstracyjnych. Może też tworzyć atrapy i szpiegi.

Użyjesz Mockito, aby utworzyć obiekt zastępczy NavigationController, który może potwierdzić, że metoda navigate została wywołana prawidłowo.

Krok 1. Dodawanie zależności Gradle

  1. Dodaj zależności 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 – to zależność Mockito.
  • dexmaker-mockito – ta biblioteka jest wymagana do używania Mockito w projekcie na Androida. Mockito musi generować klasy w czasie działania. Na Androidzie odbywa się to za pomocą kodu bajtowego dex, dlatego ta biblioteka umożliwia Mockito generowanie obiektów w czasie działania na Androidzie.
  • androidx.test.espresso:espresso-contrib – ta biblioteka składa się z zewnętrznych kontrybucji (stąd nazwa), które zawierają kod testowy dla bardziej zaawansowanych widoków, takich jak DatePickerRecyclerView. Zawiera też testy ułatwień dostępu i klasę o nazwie CountingIdlingResource, o której piszemy w dalszej części.

Krok 2. Create TasksFragmentTest

  1. Otwórz pokój TasksFragment.
  2. Kliknij prawym przyciskiem myszy nazwę klasy TasksFragment i wybierz kolejno WygenerujTestuj. Utwórz test w zestawie źródeł androidTest.
  3. Skopiuj ten kod do 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()
    }

}

Ten kod jest podobny do kodu TaskDetailFragmentTest, który został przez Ciebie napisany. Konfiguruje i usuwa FakeAndroidTestRepository. Dodaj test nawigacji, aby sprawdzić, czy po kliknięciu zadania na liście zadań następuje przejście do odpowiedniego TaskDetailFragment.

  1. Dodaj 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. Aby utworzyć mock, użyj funkcji mock Mockito.

TasksFragmentTest.kt

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

Aby utworzyć obiekt pozorny w Mockito, przekaż klasę, którą chcesz zamodelować.

Następnie musisz powiązać urządzenie NavController z fragmentem. onFragment umożliwia wywoływanie metod w samym fragmencie.

  1. Ustaw nowy mock jako NavController fragmentu.
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. Dodaj kod, aby kliknąć element w RecyclerView, który zawiera tekst „TITLE1”.
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActions jest częścią biblioteki espresso-contrib i umożliwia wykonywanie działań Espresso na widoku RecyclerView.

  1. Sprawdź, czy wywołano funkcję navigate z prawidłowym argumentem.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Metoda verify w Mockito sprawia, że jest to mock – możesz potwierdzić, że wywołano w nim konkretną metodę (navigate) z parametrem (actionTasksFragmentToTaskDetailFragment z identyfikatorem „id1”).navController

Kompletny test wygląda tak:

@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. Przeprowadź test.

Podsumowując, aby przetestować nawigację, możesz:

  1. Użyj Mockito, aby utworzyć obiekt zastępczy NavController.
  2. Dołącz do fragmentu zasób NavController.
  3. Sprawdź, czy funkcja navigate została wywołana z prawidłowym działaniem i parametrami.

Krok 3. Opcjonalnie, write clickAddTaskButton_navigateToAddEditFragment

Aby sprawdzić, czy możesz samodzielnie przeprowadzić test nawigacji, wykonaj to zadanie.

  1. Napisz test clickAddTaskButton_navigateToAddEditFragment, który sprawdza, czy po kliknięciu przycisku FAB + następuje przejście do elementu AddEditTaskFragment.

Odpowiedź znajdziesz poniżej.

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

Kliknij tutaj, aby zobaczyć różnicę między kodem początkowym a końcowym.

Aby pobrać kod ukończonego ćwiczenia, możesz użyć tego polecenia git:

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


Możesz też pobrać repozytorium jako plik ZIP, rozpakować go i otworzyć w Android Studio.

Pobierz plik ZIP

W tym laboratorium dowiedzieliśmy się, jak skonfigurować ręczne wstrzykiwanie zależności i lokalizator usług oraz jak używać atrap i mocków w aplikacjach na Androida napisanych w Kotlinie. W szczególności:

  • Rodzaj testów, które chcesz przeprowadzić w swojej aplikacji, zależy od tego, co chcesz przetestować, i od Twojej strategii testowania. Testy jednostkowe są ukierunkowane i szybkie. Testy integracji weryfikują interakcje między częściami programu. Testy kompleksowe weryfikują funkcje, mają najwyższą wierność, są często instrumentowane i mogą trwać dłużej.
  • Architektura aplikacji wpływa na to, jak trudne jest jej testowanie.
  • TDD, czyli programowanie sterowane testami, to strategia, w której najpierw piszesz testy, a potem tworzysz funkcję, która je przechodzi.
  • Aby wyodrębnić części aplikacji do testowania, możesz użyć obiektów zastępczych. Obiekt testowy to wersja klasy utworzona specjalnie na potrzeby testowania. Możesz na przykład symulować pobieranie danych z bazy danych lub internetu.
  • Użyj wstrzykiwania zależności, aby zastąpić prawdziwą klasę klasą testową, np. repozytorium lub warstwą sieciową.
  • Użyj testów z instrumentacją (androidTest), aby uruchomić komponenty interfejsu.
  • Jeśli nie możesz użyć wstrzykiwania zależności w konstruktorze, np. aby uruchomić fragment, często możesz użyć lokalizatora usług. Wzorzec lokalizatora usług to alternatywa dla wstrzykiwania zależności. Polega ona na utworzeniu klasy singleton o nazwie „Service Locator”, której zadaniem jest dostarczanie zależności zarówno do zwykłego kodu, jak i do kodu testowego.

Kurs Udacity:

Dokumentacja dla deweloperów aplikacji na Androida:

Materiały wideo:

Inne:

Linki do innych ćwiczeń z tego kursu znajdziesz na stronie docelowej ćwiczeń z zaawansowanego Androida w Kotlinie.