Introducción a los dobles de prueba y la inyección de dependencias

Este codelab es parte del curso Aspectos avanzados de Android en Kotlin. Aprovecharás al máximo este curso si trabajas con los codelabs de forma secuencial, aunque no es obligatorio. Todos los codelabs del curso se indican en la página de destino de los codelabs de Aspectos avanzados de Android en Kotlin.

Introducción

Este segundo codelab de pruebas se centra en las pruebas dobles: cuándo usarlas en Android y cómo implementarlas con la inyección de dependencias, el patrón de Service Locator y las bibliotecas. De esta manera, aprenderás a escribir lo siguiente:

  • Pruebas de unidades del repositorio
  • Pruebas de integración de fragmentos y ViewModel
  • Pruebas de navegación de fragmentos

Conocimientos que ya deberías tener

Debes estar familiarizado con lo siguiente:

  • El lenguaje de programación Kotlin
  • Conceptos de pruebas que se abordan en el primer codelab: Cómo escribir y ejecutar pruebas de unidades en Android, y cómo usar JUnit, Hamcrest, pruebas de AndroidX, Robolectric y pruebas de LiveData
  • Las siguientes bibliotecas principales de Android Jetpack: ViewModel, LiveData y el componente de Navigation
  • Arquitectura de la aplicación, siguiendo el patrón de la Guía de arquitectura de apps y los codelabs de Android Fundamentals
  • Conceptos básicos de las corrutinas en Android

Qué aprenderás

  • Cómo planificar una estrategia de pruebas
  • Cómo crear y usar dobles de prueba, es decir, simulaciones y objetos simulados
  • Cómo usar la inserción manual de dependencias en Android para pruebas de integración y unidades
  • Cómo aplicar el patrón de localizador de servicios
  • Cómo probar repositorios, fragmentos, ViewModels y el componente Navigation

Usarás las siguientes bibliotecas y conceptos de código:

Actividades

  • Escribe pruebas de unidades para un repositorio con un doble de prueba y una inyección de dependencias.
  • Escribe pruebas de unidades para un modelo de vistas con un doble de prueba y una inyección de dependencias.
  • Escribe pruebas de integración para fragmentos y sus ViewModels con el framework de pruebas de IU de Espresso.
  • Escribe pruebas de navegación con Mockito y Espresso.

En esta serie de codelabs, trabajarás con la app de notas de tareas pendientes. Esta app te permite escribir tareas para completar y mostrarlas en una lista. Luego, puedes marcarlas como completadas o no, filtrarlas o borrarlas.

Esta app está escrita en Kotlin, tiene algunas pantallas, usa componentes de Jetpack y sigue la arquitectura de una Guía de arquitectura de apps. Si aprendes a probar esta app, podrás probar apps que usen las mismas bibliotecas y arquitectura.

Descarga el código

Para comenzar, descarga el código:

Download Zip

Como alternativa, puedes clonar el repositorio de GitHub para el código:

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

Tómate un momento para familiarizarte con el código siguiendo las instrucciones que se indican a continuación.

Paso 1: Ejecuta la app de ejemplo

Una vez que hayas descargado la app de tareas, ábrela en Android Studio y ejecútala. Debería compilarse. Explora la app haciendo lo siguiente:

  • Crea una tarea nueva con el botón de acción flotante de signo más. Primero, ingresa un título y, luego, información adicional sobre la tarea. Guárdalo con el botón de acción flotante de marca de verificación verde.
  • En la lista de tareas, haz clic en el título de la tarea que acabas de completar y mira la pantalla de detalles para ver el resto de la descripción.
  • En la lista o en la pantalla de detalles, marca la casilla de verificación de esa tarea para establecer su estado como Completada.
  • Vuelve a la pantalla de tareas, abre el menú de filtros y filtra las tareas por estado Activa y Completada.
  • Abre el panel lateral de navegación y haz clic en Estadísticas.
  • Regresa a la pantalla de descripción general y, en el menú del panel de navegación, selecciona Borrar completadas para borrar todas las tareas con el estado Completada.

Paso 2: Explora el código de la app de ejemplo

La app de tareas pendientes se basa en la popular muestra de pruebas y arquitectura de Architecture Blueprints (con la versión de arquitectura reactiva de la muestra). La app sigue la arquitectura de una Guía de arquitectura de apps. Usa ViewModels con Fragments, un repositorio y Room. Si conoces alguno de los siguientes ejemplos, esta app tiene una arquitectura similar:

Es más importante que comprendas la arquitectura general de la app que tener un conocimiento profundo de la lógica en cualquier capa.

Aquí tienes el resumen de los paquetes que encontrarás:

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

.addedittask

Pantalla para agregar o editar una tarea: Código de la capa de la IU para agregar o editar una tarea.

.data

La capa de datos: Se ocupa de la capa de datos de las tareas. Contiene el código de la base de datos, la red y el repositorio.

.statistics

La pantalla de estadísticas: Código de la capa de la IU para la pantalla de estadísticas.

.taskdetail

La pantalla de detalles de la tarea: Código de la capa de IU para una sola tarea.

.tasks

La pantalla de tareas: Código de la capa de IU para la lista de todas las tareas.

.util

Clases de utilidad: Clases compartidas que se usan en varias partes de la app, p.ej., para el diseño de actualización por deslizamiento que se usa en varias pantallas.

Capa de datos (.data)

Esta app incluye una capa de red simulada, en el paquete remote, y una capa de base de datos, en el paquete local. Para simplificar, en este proyecto, la capa de redes se simula con solo un HashMap con una demora, en lugar de realizar solicitudes de red reales.

El DefaultTasksRepository coordina o media entre la capa de red y la capa de la base de datos, y es lo que devuelve datos a la capa de la IU.

Capa de la IU ( .addedittask, .statistics, .taskdetail, .tasks)

Cada uno de los paquetes de la capa de IU contiene un fragmento y un modelo de vista, junto con cualquier otra clase que se requiera para la IU (como un adaptador para la lista de tareas). TaskActivity es la actividad que contiene todos los fragmentos.

Navegación

El componente Navigation controla la navegación de la app. Se define en el archivo nav_graph.xml. La navegación se activa en los modelos de vista con la clase Event. Los modelos de vista también determinan qué argumentos pasar. Los fragmentos observan los Event y realizan la navegación real entre pantallas.

En este codelab, aprenderás a probar repositorios, modelos de vistas y fragmentos con dobles de prueba y la inserción de dependencias. Antes de analizar qué son, es importante comprender el razonamiento que guiará qué y cómo escribirás estas pruebas.

En esta sección, se abarcan algunas prácticas recomendadas para las pruebas en general, ya que se aplican a Android.

La pirámide de pruebas

Cuando piensas en una estrategia de pruebas, hay tres aspectos relacionados que debes tener en cuenta:

  • Alcance: ¿Qué parte del código abarca la prueba? Las pruebas se pueden ejecutar en un solo método, en toda la aplicación o en algún punto intermedio.
  • Velocidad: ¿Qué tan rápido se ejecuta la prueba? Las velocidades de las pruebas pueden variar de milisegundos a varios minutos.
  • Fidelidad: ¿Qué tan "real" es la prueba? Por ejemplo, si parte del código que estás probando necesita realizar una solicitud de red, ¿el código de prueba realmente realiza esta solicitud de red o simula el resultado? Si la prueba realmente se comunica con la red, significa que tiene mayor fidelidad. La desventaja es que la prueba podría tardar más en ejecutarse, podría generar errores si la red no funciona o podría ser costosa de usar.

Existen compensaciones inherentes entre estos aspectos. Por ejemplo, la velocidad y la fidelidad son un equilibrio: cuanto más rápida es la prueba, generalmente, menos fidelidad tiene, y viceversa. Una forma común de dividir las pruebas automatizadas es en estas tres categorías:

  • Pruebas de unidades: Son pruebas muy enfocadas que se ejecutan en una sola clase, por lo general, un solo método en esa clase. Si falla una prueba de unidades, puedes saber exactamente en qué parte del código se encuentra el problema. Tienen baja fidelidad, ya que, en el mundo real, tu app implica mucho más que la ejecución de un método o una clase. Son lo suficientemente rápidas como para ejecutarse cada vez que cambias el código. Por lo general, serán pruebas que se ejecuten de forma local (en el conjunto de fuentes test). Ejemplo: Prueba de métodos únicos en ViewModels y repositorios
  • Pruebas de integración: Estas pruebas verifican la interacción de varias clases para asegurarse de que se comporten según lo esperado cuando se usan juntas. Una forma de estructurar las pruebas de integración es hacer que prueben una sola función, como la capacidad de guardar una tarea. Prueban un alcance de código más amplio que las pruebas de unidades, pero aún están optimizadas para ejecutarse rápidamente, en lugar de tener fidelidad completa. Se pueden ejecutar de forma local o como pruebas de instrumentación, según la situación. Ejemplo: Probar toda la funcionalidad de un solo par de fragmento y modelo de vista
  • Pruebas de extremo a extremo (E2E): Prueban una combinación de funciones que trabajan juntas. Prueban grandes partes de la app, simulan el uso real de cerca y, por lo tanto, suelen ser lentas. Tienen la mayor fidelidad y te indican que tu aplicación funciona correctamente en su totalidad. En general, estas pruebas serán pruebas instrumentadas (en el conjunto de orígenes androidTest)
    Ejemplo: Iniciar toda la app y probar algunas funciones juntas.

La proporción sugerida de estas pruebas a menudo se representa con una pirámide, en la que la gran mayoría de las pruebas son pruebas de unidades.

Arquitectura y pruebas

Tu capacidad para probar tu app en todos los niveles de la pirámide de pruebas está intrínsecamente vinculada a la arquitectura de la app. Por ejemplo, una aplicación con una arquitectura extremadamente deficiente podría colocar toda su lógica dentro de un solo método. Es posible que puedas escribir una prueba de extremo a extremo para esto, ya que estas pruebas tienden a probar grandes porciones de la app, pero ¿qué sucede con la escritura de pruebas de unidades o de integración? Con todo el código en un solo lugar, es difícil probar solo el código relacionado con una sola unidad o función.

Un mejor enfoque sería dividir la lógica de la aplicación en varios métodos y clases, lo que permitiría probar cada parte de forma aislada. La arquitectura es una forma de dividir y organizar tu código, lo que permite realizar pruebas de unidades y de integración más fácilmente. La app de tareas pendientes que probarás sigue una arquitectura particular:



En esta lección, verás cómo probar partes de la arquitectura anterior de forma aislada:

  1. Primero, probarás las unidades del repositorio.
  2. Luego, usarás un doble de prueba en el modelo de vistas, lo que es necesario para realizar pruebas de unidades y pruebas de integración del modelo de vistas.
  3. A continuación, aprenderás a escribir pruebas de integración para fragmentos y sus modelos de vista.
  4. Por último, aprenderás a escribir pruebas de integración que incluyan el componente Navigation.

En la próxima lección, se abordarán las pruebas de extremo a extremo.

Cuando escribes una prueba de unidades para una parte de una clase (un método o una pequeña colección de métodos), tu objetivo es probar solo el código de esa clase.

Probar solo el código en una clase o clases específicas puede ser complicado. Veamos un ejemplo. Abre la clase data.source.DefaultTaskRepository en el conjunto de fuentes main. Este es el repositorio de la app y la clase para la que escribirás pruebas de unidades a continuación.

Tu objetivo es probar solo el código de esa clase. Sin embargo, DefaultTaskRepository depende de otras clases, como LocalTaskDataSource y RemoteTaskDataSource, para funcionar. Otra forma de decir esto es que LocalTaskDataSource y RemoteTaskDataSource son dependencias de DefaultTaskRepository.

Por lo tanto, cada método en DefaultTaskRepository llama a métodos en clases de fuentes de datos, que a su vez llaman a métodos en otras clases para guardar información en una base de datos o comunicarse con la red.



Por ejemplo, observa este método en 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 es una de las llamadas más "básicas" que puedes hacer a tu repositorio. Este método incluye la lectura de una base de datos SQLite y la realización de llamadas de red (la llamada a updateTasksFromRemoteDataSource). Esto implica mucho más código que solo el código del repositorio.

Estas son algunas razones más específicas por las que es difícil probar el repositorio:

  • Debes pensar en crear y administrar una base de datos para realizar incluso las pruebas más simples de este repositorio. Esto plantea preguntas como "¿debería ser una prueba local o instrumentada?" y si deberías usar AndroidX Test para obtener un entorno de Android simulado.
  • Algunas partes del código, como el código de redes, pueden tardar mucho en ejecutarse o, incluso, fallar ocasionalmente, lo que genera pruebas inestables de larga duración.
  • Tus pruebas podrían perder la capacidad de diagnosticar qué código es el responsable de una falla en la prueba. Tus pruebas podrían comenzar a probar código que no es de repositorio, por lo que, por ejemplo, tus supuestas pruebas de unidades de "repositorio" podrían fallar debido a un problema en parte del código dependiente, como el código de la base de datos.

Dobles de prueba

La solución a esto es que, cuando pruebes el repositorio, no uses el código real de red o de base de datos, sino que uses un doble de prueba. Un doble de prueba es una versión de una clase creada específicamente para las pruebas. Está diseñada para reemplazar la versión real de una clase en las pruebas. Es similar a cómo un doble de riesgo es un actor que se especializa en acrobacias y reemplaza al actor real en acciones peligrosas.

Estos son algunos tipos de dobles de prueba:

Falso

Es un doble de prueba que tiene una implementación "en funcionamiento" de la clase, pero se implementa de una manera que la hace adecuada para las pruebas, pero no para la producción.

Mock

Es un doble de prueba que hace un seguimiento de los métodos que se llamaron. Luego, aprueba o rechaza una prueba según si se llamaron correctamente a sus métodos.

Stub

Es un doble de prueba que no incluye lógica y solo devuelve lo que programas para que devuelva. Por ejemplo, se podría programar un StubTaskRepository para que devuelva ciertas combinaciones de tareas de getTasks.

Dummy

Es un doble de prueba que se pasa, pero no se usa, como si solo necesitaras proporcionarlo como parámetro. Si tuvieras un NoOpTaskRepository, solo implementarías el TaskRepository sin código en ninguno de los métodos.

Espía

Un doble de prueba que también realiza un seguimiento de cierta información adicional; por ejemplo, si creaste un SpyTaskRepository, podría realizar un seguimiento de la cantidad de veces que se llamó al método addTask.

Para obtener más información sobre los simuladores de prueba, consulta Testing on the Toilet: Know Your Test Doubles.

Las pruebas dobles más comunes que se usan en Android son los objetos simulados y los objetos ficticios.

En esta tarea, crearás un doble de prueba FakeDataSource para probar la unidad DefaultTasksRepository desacoplada de las fuentes de datos reales.

Paso 1: Crea la clase FakeDataSource

En este paso, crearás una clase llamada FakeDataSouce, que será un doble de prueba de un LocalDataSource y un RemoteDataSource.

  1. En el conjunto de fuentes test, haz clic con el botón derecho y selecciona New -> Package.

  1. Crea un paquete de datos con un paquete de fuente dentro.
  2. Crea una clase nueva llamada FakeDataSource en el paquete data/source.

Paso 2: Implementa la interfaz de TasksDataSource

Para poder usar tu nueva clase FakeDataSource como un doble de prueba, debe poder reemplazar las otras fuentes de datos. Esas fuentes de datos son TasksLocalDataSource y TasksRemoteDataSource.

  1. Observa cómo ambos implementan la interfaz TasksDataSource.
class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }
  1. Haz que FakeDataSource implemente TasksDataSource:
class FakeDataSource : TasksDataSource {

}

Android Studio mostrará un mensaje de error que indica que no implementaste los métodos requeridos para TasksDataSource.

  1. Usa el menú de corrección rápida y selecciona Implementar miembros.


  1. Selecciona todos los métodos y presiona Aceptar.

Paso 3: Implementa el método getTasks en FakeDataSource

FakeDataSource es un tipo específico de doble de prueba llamado simulación. Un objeto simulado es un doble de prueba que tiene una implementación "en funcionamiento" de la clase, pero se implementa de una manera que lo hace adecuado para las pruebas, pero no para la producción. La implementación "en funcionamiento" significa que la clase producirá resultados realistas a partir de las entradas.

Por ejemplo, tu fuente de datos falsa no se conectará a la red ni guardará nada en una base de datos, sino que solo usará una lista en la memoria. Esto "funcionará como esperas", ya que los métodos para obtener o guardar tareas devolverán los resultados esperados, pero nunca podrías usar esta implementación en producción, ya que no se guarda en el servidor ni en una base de datos.

A FakeDataSource

  • te permite probar el código en DefaultTasksRepository sin necesidad de depender de una base de datos o una red reales.
  • proporciona una implementación "suficientemente real" para las pruebas.
  1. Cambia el constructor FakeDataSource para crear un var llamado tasks que sea un MutableList<Task>? con un valor predeterminado de una lista mutable vacía.
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


Esta es la lista de tareas que simulan ser una respuesta de base de datos o servidor. Por ahora, el objetivo es probar el método getTasks del repositorio . Esto llama a los métodos getTasks, deleteAllTasks y saveTask de la fuente de datos .

Escribe una versión falsa de estos métodos:

  1. Escribe getTasks: Si tasks no es null, devuelve un resultado Success. Si tasks es null, devuelve un resultado Error.
  2. Write deleteAllTasks: Borra la lista de tareas mutables.
  3. Escribe saveTask: Agrega la tarea a la lista.

Esos métodos, implementados para FakeDataSource, se ven como el siguiente código.

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

Estas son las sentencias de importación, si las necesitas:

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

Esto es similar a cómo funcionan las fuentes de datos locales y remotas reales.

En este paso, usarás una técnica llamada inserción de dependencias manual para que puedas usar el doble de prueba falso que acabas de crear.

El problema principal es que tienes un FakeDataSource, pero no está claro cómo lo usas en las pruebas. Debe reemplazar TasksRemoteDataSource y TasksLocalDataSource, pero solo en las pruebas. Tanto TasksRemoteDataSource como TasksLocalDataSource son dependencias de DefaultTasksRepository, lo que significa que DefaultTasksRepositories requiere o "depende" de estas clases para ejecutarse.

En este momento, las dependencias se construyen dentro del método init de 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
}

Debido a que creas y asignas taskLocalDataSource y tasksRemoteDataSource dentro de DefaultTasksRepository, estos se codifican de forma rígida. No hay forma de intercambiar tu doble de prueba.

En su lugar, lo que debes hacer es proporcionar estas fuentes de datos a la clase, en lugar de codificarlas de forma rígida. Proporcionar dependencias se conoce como inyección de dependencias. Existen diferentes formas de proporcionar dependencias y, por lo tanto, diferentes tipos de inyección de dependencias.

La inserción de dependencias en el constructor te permite intercambiar el doble de prueba pasándolo al constructor.

Sin inyección

Inyección

Paso 1: Usa la inserción de dependencias del constructor en DefaultTasksRepository

  1. Cambia el constructor de DefaultTaskRepository para que tome un Application y las fuentes de datos, y el dispatcher de corrutinas (que también deberás intercambiar para tus pruebas, lo que se describe con más detalle en la sección de la tercera lección sobre corrutinas).

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. Como pasaste las dependencias, quita el método init. Ya no es necesario que crees las dependencias.
  2. También borra las variables de instancia anteriores. Los defines en el constructor:

DefaultTasksRepository.kt

// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
  1. Por último, actualiza el método getRepository para usar el nuevo constructor:

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

Ahora estás usando la inserción de dependencias del constructor.

Paso 2: Usa tu FakeDataSource en las pruebas

Ahora que tu código usa la inyección de dependencias del constructor, puedes usar tu fuente de datos falsa para probar tu DefaultTasksRepository.

  1. Haz clic con el botón derecho en el nombre de la clase DefaultTasksRepository y selecciona Generar y, luego, Prueba.
  2. Sigue las indicaciones para crear DefaultTasksRepositoryTest en el conjunto de fuentes test.
  3. En la parte superior de la nueva clase DefaultTasksRepositoryTest, agrega las siguientes variables de miembro para representar los datos en tus fuentes de datos simulados.

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. Crea tres variables, dos variables miembro FakeDataSource (una para cada fuente de datos de tu repositorio) y una variable para el DefaultTasksRepository que probarás.

DefaultTasksRepositoryTest.kt

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

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

Crea un método para configurar e inicializar un DefaultTasksRepository que se pueda probar. Este DefaultTasksRepository usará tu doble de prueba, FakeDataSource.

  1. Crea un método llamado createRepository y anótalo con @Before.
  2. Crea instancias de tus fuentes de datos falsas con las listas remoteTasks y localTasks.
  3. Crea una instancia de tu tasksRepository con las dos fuentes de datos falsas que acabas de crear y Dispatchers.Unconfined.

El método final debería verse como el siguiente código.

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

Paso 3: Escribe la prueba de getTasks() de DefaultTasksRepository

Es hora de escribir una prueba de DefaultTasksRepository.

  1. Escribe una prueba para el método getTasks del repositorio. Verifica que, cuando llames a getTasks con true (lo que significa que debería volver a cargar desde la fuente de datos remota), se devuelvan datos de la fuente de datos remota (en lugar de la fuente de datos local).

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

Recibirás un error cuando llames a getTasks:

Paso 4: Agrega runBlockingTest

Se espera el error de corrutina porque getTasks es una función suspend y debes iniciar una corrutina para llamarla. Para ello, necesitas un alcance de corrutinas. Para resolver este error, deberás agregar algunas dependencias de Gradle para controlar el lanzamiento de corrutinas en tus pruebas.

  1. Agrega las dependencias necesarias para probar corrutinas al conjunto de fuentes de prueba con testImplementation.

app/build.gradle

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

¡No olvides sincronizar!

kotlinx-coroutines-test es la biblioteca de pruebas de corrutinas, diseñada específicamente para probar corrutinas. Para ejecutar tus pruebas, usa la función runBlockingTest. Esta es una función que proporciona la biblioteca de pruebas de corrutinas. Toma un bloque de código y, luego, lo ejecuta en un contexto de corrutina especial que se ejecuta de forma síncrona e inmediata, lo que significa que las acciones ocurrirán en un orden determinístico. Esto hace que tus corrutinas se ejecuten como si no fueran corrutinas, por lo que está diseñado para probar código.

Usa runBlockingTest en tus clases de prueba cuando llames a una función suspend. Aprenderás más sobre cómo funciona runBlockingTest y cómo probar corrutinas en el siguiente codelab de esta serie.

  1. Agrega @ExperimentalCoroutinesApi arriba de la clase. Esto expresa que sabes que estás usando una API de corrutinas experimental (runBlockingTest) en la clase. De lo contrario, recibirás una advertencia.
  2. De nuevo en tu DefaultTasksRepositoryTest, agrega runBlockingTest para que tome toda tu prueba como un "bloque" de código.

Esta prueba final se ve como el siguiente código.

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. Ejecuta tu nueva prueba de getTasks_requestsAllTasksFromRemoteDataSource y confirma que funciona y que el error desapareció.

Acabas de ver cómo realizar pruebas de unidades en un repositorio. En los próximos pasos, volverás a usar la inyección de dependencias y crearás otro doble de prueba, esta vez para mostrar cómo escribir pruebas de unidades y de integración para tus modelos de vistas.

Las pruebas de unidades solo deben probar la clase o el método que te interesa. Esto se conoce como pruebas de aislamiento, en las que aíslas claramente tu "unidad" y solo pruebas el código que forma parte de esa unidad.

Por lo tanto, TasksViewModelTest solo debe probar el código de TasksViewModel, no debe probar las clases de base de datos, red o repositorio. Por lo tanto, para tus modelos de vistas, al igual que lo hiciste con tu repositorio, crearás un repositorio falso y aplicarás la inyección de dependencias para usarlo en tus pruebas.

En esta tarea, aplicarás la inyección de dependencias a los modelos de vistas.

Paso 1: Cómo crear una interfaz de TasksRepository

El primer paso para usar la inyección de dependencias del constructor es crear una interfaz común que compartan la clase falsa y la real.

¿Cómo se ve esto en la práctica? Observa TasksRemoteDataSource, TasksLocalDataSource y FakeDataSource, y nota que todos comparten la misma interfaz: TasksDataSource. Esto te permite indicar en el constructor de DefaultTasksRepository que tomas un TasksDataSource.

DefaultTasksRepository.kt

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

Esto es lo que nos permite intercambiar tu FakeDataSource.

A continuación, crea una interfaz para DefaultTasksRepository, como lo hiciste para las fuentes de datos. Debe incluir todos los métodos públicos (superficie de la API pública) de DefaultTasksRepository.

  1. Abre DefaultTasksRepository y haz clic con el botón derecho en el nombre de la clase. Luego, selecciona Refactor -> Extract -> Interface.

  1. Elige Extraer en un archivo independiente.

  1. En la ventana Extract Interface, cambia el nombre de la interfaz a TasksRepository.
  2. En la sección Members to form interface, marca todos los miembros excepto los dos miembros complementarios y los métodos privados.


  1. Haz clic en Refactor. La nueva interfaz TasksRepository debería aparecer en el paquete data/source .

Además, DefaultTasksRepository ahora implementa TasksRepository.

  1. Ejecuta tu app (no las pruebas) para asegurarte de que todo siga funcionando correctamente.

Paso 2: Crea FakeTestRepository

Ahora que tienes la interfaz, puedes crear el doble de prueba DefaultTaskRepository.

  1. En el conjunto de fuentes test, en data/source, crea el archivo y la clase de Kotlin FakeTestRepository.kt y extiéndelos desde la interfaz TasksRepository.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

Se te indicará que debes implementar los métodos de la interfaz.

  1. Coloca el cursor sobre el error hasta que veas el menú de sugerencias y, luego, haz clic en Implement members y selecciónalo.
  1. Selecciona todos los métodos y presiona Aceptar.

Paso 3: Implementa métodos de FakeTestRepository

Ahora tienes una clase FakeTestRepository con métodos "no implementados". De manera similar a como implementaste FakeDataSource, FakeTestRepository se respaldará con una estructura de datos, en lugar de lidiar con una mediación complicada entre fuentes de datos locales y remotas.

Ten en cuenta que tu FakeTestRepository no necesita usar FakeDataSources ni nada parecido; solo necesita devolver resultados falsos realistas a partir de las entradas. Usarás un LinkedHashMap para almacenar la lista de tareas y un MutableLiveData para tus tareas observables.

  1. En FakeTestRepository, agrega una variable LinkedHashMap que represente la lista actual de tareas y un MutableLiveData para tus tareas observables.

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

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

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


    // Rest of class
}

Implementa los siguientes métodos:

  1. getTasks: Este método debe tomar el tasksServiceData y convertirlo en una lista con tasksServiceData.values.toList() y, luego, devolverlo como un resultado Success.
  2. refreshTasks: Actualiza el valor de observableTasks para que sea el que devuelve getTasks().
  3. observeTasks: Crea una corrutina con runBlocking y ejecuta refreshTasks. Luego, devuelve observableTasks.

A continuación, se muestra el código de esos métodos.

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

}

Paso 4: Agrega un método para probar addTasks

Cuando realices pruebas, es mejor tener algunos Tasks ya en tu repositorio. Podrías llamar a saveTask varias veces, pero, para que sea más fácil, agrega un método de ayuda específicamente para las pruebas que te permita agregar tareas.

  1. Agrega el método addTasks, que toma un vararg de tareas, agrega cada una a HashMap y, luego, actualiza las tareas.

FakeTestRepository.kt

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

En este punto, tienes un repositorio falso para realizar pruebas con algunos de los métodos clave implementados. A continuación, úsalo en tus pruebas.

En esta tarea, usarás una clase falsa dentro de un ViewModel. Usa la inyección de dependencias del constructor para incorporar las dos fuentes de datos a través de la inyección de dependencias del constructor agregando una variable TasksRepository al constructor de TasksViewModel.

Este proceso es un poco diferente con los modelos de vistas, ya que no los construyes directamente. Por ejemplo:

class TasksFragment : Fragment() {

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

}


Como en el código anterior, usas el delegado de propiedad viewModel's que crea el modelo de vista. Para cambiar la forma en que se construye el modelo de vista, deberás agregar y usar un ViewModelProvider.Factory. Si no conoces ViewModelProvider.Factory, puedes obtener más información aquí.

Paso 1: Crea y usa un ViewModelFactory en TasksViewModel

Comienza por actualizar las clases y las pruebas relacionadas con la pantalla Tasks.

  1. Abrir TasksViewModel
  2. Cambia el constructor de TasksViewModel para que tome TasksRepository en lugar de construirlo dentro de la clase.

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 
}

Como cambiaste el constructor, ahora debes usar una fábrica para construir TasksViewModel. Coloca la clase de fábrica en el mismo archivo que TasksViewModel, pero también puedes colocarla en su propio archivo.

  1. En la parte inferior del archivo TasksViewModel, fuera de la clase, agrega un TasksViewModelFactory que tome un TasksRepository simple.

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


Esta es la forma estándar de cambiar la forma en que se construyen los ViewModel. Ahora que tienes la fábrica, úsala dondequiera que construyas tu modelo de vista.

  1. Actualiza TasksFragment para usar la fábrica.

TasksFragment.kt

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

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. Ejecuta el código de tu app y asegúrate de que todo siga funcionando.

Paso 2: Cómo usar FakeTestRepository dentro de TasksViewModelTest

Ahora, en lugar de usar el repositorio real en las pruebas del modelo de vista, puedes usar el repositorio falso.

  1. Abre TasksViewModelTest.
  2. Agrega una propiedad FakeTestRepository en el 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. Actualiza el método setupViewModel para crear un FakeTestRepository con tres tareas y, luego, construye el tasksViewModel con este repositorio.

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. Como ya no usas el código AndroidX Test ApplicationProvider.getApplicationContext, también puedes quitar la anotación @RunWith(AndroidJUnit4::class).
  2. Ejecuta tus pruebas y asegúrate de que todas sigan funcionando.

Con la inyección de dependencias del constructor, quitaste DefaultTasksRepository como dependencia y lo reemplazaste por tu FakeTestRepository en las pruebas.

Paso 3: También actualiza el fragmento y el ViewModel de TaskDetail

Realiza los mismos cambios para TaskDetailFragment y TaskDetailViewModel. Esto preparará el código para cuando escribas pruebas de TaskDetail a continuación.

  1. Abrir TaskDetailViewModel
  2. Actualiza el 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. En la parte inferior del archivo TaskDetailViewModel, fuera de la clase, agrega un 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. Actualiza TasksFragment para usar la fábrica.

TasksFragment.kt

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

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. Ejecuta tu código y asegúrate de que todo funcione correctamente.

Ahora puedes usar un FakeTestRepository en lugar del repositorio real en TasksFragment y TasksDetailFragment.

A continuación, escribirás pruebas de integración para probar las interacciones de tu fragmento y ViewModel. Descubrirás si el código de tu modelo de vista actualiza la IU de forma adecuada. Para ello, usa

  • El patrón ServiceLocator
  • las bibliotecas de Espresso y Mockito

Las pruebas de integración prueban la interacción de varias clases para asegurarse de que se comporten según lo esperado cuando se usan juntas. Estas pruebas se pueden ejecutar de forma local (conjunto de fuentes test) o como pruebas de instrumentación (conjunto de fuentes androidTest).

En tu caso, tomarás cada fragmento y escribirás pruebas de integración para el fragmento y el modelo de vista para probar las funciones principales del fragmento.

Paso 1: Agrega dependencias de Gradle

  1. Agrega las siguientes dependencias de 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"

Estas dependencias incluyen lo siguiente:

  • junit:junit: JUnit, que es necesario para escribir instrucciones de prueba básicas.
  • androidx.test:core: Biblioteca principal de pruebas de AndroidX
  • kotlinx-coroutines-test: Biblioteca de pruebas de corrutinas
  • androidx.fragment:fragment-testing: Biblioteca de prueba de AndroidX para crear fragmentos en pruebas y cambiar su estado.

Como usarás estas bibliotecas en tu conjunto de fuentes androidTest, usa androidTestImplementation para agregarlas como dependencias.

Paso 2: Cómo crear una clase TaskDetailFragmentTest

El objeto TaskDetailFragment muestra información sobre una sola tarea.

Comenzarás escribiendo una prueba de fragmento para TaskDetailFragment, ya que tiene una funcionalidad bastante básica en comparación con los otros fragmentos.

  1. Abrir taskdetail.TaskDetailFragment
  2. Genera una prueba para TaskDetailFragment, como lo hiciste antes. Acepta las opciones predeterminadas y colócalo en el conjunto de orígenes androidTest (NO en el conjunto de orígenes test).

  1. Agrega las siguientes anotaciones a la clase TaskDetailFragmentTest.

TaskDetailFragmentTest.kt

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

}

El propósito de estas anotaciones es el siguiente:

  • @MediumTest: Marca la prueba como una prueba de integración de "tiempo de ejecución medio" (en comparación con las pruebas de unidades @SmallTest y las pruebas de extremo a extremo grandes @LargeTest). Esto te ayuda a agrupar y elegir el tamaño de la prueba que deseas ejecutar.
  • @RunWith(AndroidJUnit4::class): Se usa en cualquier clase que use AndroidX Test.

Paso 3: Cómo iniciar un fragmento desde una prueba

En esta tarea, iniciarás TaskDetailFragment con la biblioteca de pruebas de AndroidX. FragmentScenario es una clase de AndroidX Test que encapsula un fragmento y te brinda control directo sobre el ciclo de vida del fragmento para realizar pruebas. Para escribir pruebas de fragmentos, crea un FragmentScenario para el fragmento que estás probando (TaskDetailFragment).

  1. Copia esta prueba en 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)

    }

Este código anterior:

Esta aún no es una prueba terminada, ya que no afirma nada. Por ahora, ejecuta la prueba y observa qué sucede.

  1. Esta es una prueba de instrumentación, así que asegúrate de que el emulador o tu dispositivo estén visibles.
  2. Ejecuta la prueba.

Deberían ocurrir algunas cosas.

  • En primer lugar, como se trata de una prueba instrumentada, la prueba se ejecutará en tu dispositivo físico (si está conectado) o en un emulador.
  • Debería iniciar el fragmento.
  • Observa cómo no navega por ningún otro fragmento ni tiene ningún menú asociado a la actividad: solo es el fragmento.

Por último, observa con atención y notarás que el fragmento dice "No hay datos", ya que no carga correctamente los datos de la tarea.

La prueba debe cargar el TaskDetailFragment (lo que ya hiciste) y confirmar que los datos se cargaron correctamente. ¿Por qué no hay datos? Esto se debe a que creaste una tarea, pero no la guardaste en el repositorio.

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

    }

Tienes este FakeTestRepository, pero necesitas alguna forma de reemplazar tu repositorio real por el falso para tu fragmento. Lo harás a continuación.

En esta tarea, proporcionarás tu repositorio falso a tu fragmento con un ServiceLocator. Esto te permitirá escribir tus pruebas de integración de fragmentos y modelos de vistas.

Aquí no puedes usar la inyección de dependencias del constructor, como hiciste antes, cuando necesitabas proporcionar una dependencia al ViewModel o al repositorio. La inserción de dependencias en el constructor requiere que construyas la clase. Los fragmentos y las actividades son ejemplos de clases que no construyes y a cuyo constructor generalmente no tienes acceso.

Como no construyes el fragmento, no puedes usar la inserción de dependencias del constructor para intercambiar el doble de prueba del repositorio (FakeTestRepository) al fragmento. En su lugar, usa el patrón Service Locator. El patrón del localizador de servicios es una alternativa a la inyección de dependencias. Implica crear una clase singleton llamada "Service Locator", cuyo propósito es proporcionar dependencias, tanto para el código normal como para el de prueba. En el código de la app normal (el conjunto de fuentes main), todas estas dependencias son las dependencias de la app normal. Para las pruebas, modificas el localizador de servicios para proporcionar versiones de simulacros de prueba de las dependencias.

No se usa el localizador de servicios


Cómo usar un localizador de servicios

Para esta app del codelab, haz lo siguiente:

  1. Crea una clase de Service Locator que pueda construir y almacenar un repositorio. De forma predeterminada, construye un repositorio "normal".
  2. Refactoriza tu código para que, cuando necesites un repositorio, uses el localizador de servicios.
  3. En tu clase de prueba, llama a un método en el localizador de servicios que intercambie el repositorio "normal" con tu doble de prueba.

Paso 1: Crea el ServiceLocator

Creemos una clase ServiceLocator. Se ubicará en el conjunto de fuentes principal con el resto del código de la app, ya que lo usa el código de la aplicación principal.

Nota: ServiceLocator es un singleton, por lo que debes usar la palabra clave object de Kotlin para la clase.

  1. Crea el archivo ServiceLocator.kt en el nivel superior del conjunto de orígenes principal.
  2. Define un object llamado ServiceLocator.
  3. Crea variables de instancia database y repository, y establece ambas en null.
  4. Anota el repositorio con @Volatile porque varios subprocesos podrían usarlo (@Volatile se explica en detalle aquí).

El código debería verse como se muestra a continuación.

object ServiceLocator {

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

}

Por el momento, lo único que debe hacer tu ServiceLocator es saber cómo devolver un TasksRepository. Devolverá un DefaultTasksRepository existente o creará y devolverá uno nuevo, si es necesario.DefaultTasksRepository

Define las siguientes funciones:

  1. provideTasksRepository: Proporciona un repositorio existente o crea uno nuevo. Este método debe ser synchronized en this para evitar, en situaciones con varios subprocesos en ejecución, crear accidentalmente dos instancias del repositorio.
  2. createTasksRepository: Código para crear un repositorio nuevo. Llamará a createTaskLocalDataSource y creará un nuevo TasksRemoteDataSource.
  3. createTaskLocalDataSource: Es el código para crear una nueva fuente de datos local. Llamará al createDataBase.
  4. createDataBase: Es el código para crear una base de datos nueva.

A continuación, se incluye el código completo.

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

Paso 2: Cómo usar ServiceLocator en Application

Realizarás un cambio en el código principal de la aplicación (no en las pruebas) para crear el repositorio en un solo lugar, tu ServiceLocator.

Es importante que solo crees una instancia de la clase del repositorio. Para garantizar esto, usarás el localizador de servicios en la clase Application.

  1. En el nivel superior de la jerarquía de paquetes, abre TodoApplication y crea un val para tu repositorio, y asígnale un repositorio que se obtenga con 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())
    }
}

Ahora que creaste un repositorio en la aplicación, puedes quitar el método getRepository anterior en DefaultTasksRepository.

  1. Abre DefaultTasksRepository y borra el objeto complementario.

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

Ahora, en todos los lugares donde usabas getRepository, usa el taskRepository de la aplicación. Esto garantiza que, en lugar de crear el repositorio directamente, obtengas el repositorio que proporcionó ServiceLocator.

  1. Abre TaskDetailFragement y busca la llamada a getRepository en la parte superior de la clase.
  2. Reemplaza esta llamada por una que obtenga el repositorio de 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. Haz lo mismo con 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. En el caso de StatisticsViewModel y AddEditTaskViewModel, actualiza el código que adquiere el repositorio para usar el repositorio de TodoApplication.

TasksFragment.kt

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



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. Ejecuta tu aplicación (no la prueba).

Dado que solo refactorizaste el código, la app debería ejecutarse sin problemas.

Paso 3: Create FakeAndroidTestRepository

Ya tienes un FakeTestRepository en el conjunto de fuentes de prueba. De forma predeterminada, no puedes compartir clases de prueba entre los conjuntos de orígenes test y androidTest. Por lo tanto, debes crear una clase FakeTestRepository duplicada en el conjunto de fuentes androidTest y llamarla FakeAndroidTestRepository.

  1. Haz clic con el botón derecho en el conjunto de fuentes androidTest y crea un paquete de datos. Vuelve a hacer clic con el botón derecho y crea un paquete de fuente .
  2. Crea una clase nueva en este paquete fuente llamada FakeAndroidTestRepository.kt.
  3. Copia el siguiente código en esa clase.

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

Paso 4: Prepara tu ServiceLocator para las pruebas

Bien, es hora de usar ServiceLocator para intercambiar dobles de prueba durante las pruebas. Para ello, debes agregar código a tu código de ServiceLocator.

  1. Abrir ServiceLocator.kt
  2. Marca el método setter para tasksRepository como @VisibleForTesting. Esta anotación es una forma de expresar que el motivo por el que el setter es público se debe a las pruebas.

ServiceLocator.kt

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

Ya sea que ejecutes la prueba sola o en un grupo de pruebas, estas deben ejecutarse exactamente de la misma manera. Esto significa que tus pruebas no deben tener ningún comportamiento que dependa de otro (lo que implica evitar compartir objetos entre pruebas).

Dado que ServiceLocator es un singleton, existe la posibilidad de que se comparta accidentalmente entre las pruebas. Para evitar esto, crea un método que restablezca correctamente el estado de ServiceLocator entre las pruebas.

  1. Agrega una variable de instancia llamada lock con el valor Any.

ServiceLocator.kt

private val lock = Any()
  1. Agrega un método específico para pruebas llamado resetRepository que borre la base de datos y establezca el repositorio y la base de datos como nulos.

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

Paso 5: Usa tu ServiceLocator

En este paso, usarás ServiceLocator.

  1. Abrir TaskDetailFragmentTest
  2. Declara una variable lateinit TasksRepository.
  3. Agrega un método de configuración y un método de desmontaje para configurar un FakeAndroidTestRepository antes de cada prueba y limpiarlo después de cada prueba.

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

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

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. Encapsula el cuerpo de la función activeTaskDetails_DisplayedInUi() en runBlockingTest.
  2. Guarda activeTask en el repositorio antes de iniciar el fragmento.
repository.saveTask(activeTask)

La prueba final se ve como el siguiente código.

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. Anota toda la clase con @ExperimentalCoroutinesApi.

Cuando termines, el código se verá de la siguiente manera.

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. Ejecuta la prueba activeTaskDetails_DisplayedInUi().

Al igual que antes, deberías ver el fragmento, pero esta vez, como configuraste el repositorio correctamente, ahora muestra la información de la tarea.


En este paso, usarás la biblioteca de pruebas de IU de Espresso para completar tu primera prueba de integración. Estructuraste tu código para poder agregar pruebas con aserciones para tu IU. Para ello, usarás la biblioteca de pruebas de Espresso.

Espresso te ayuda a hacer lo siguiente:

  • Interactuar con vistas, como hacer clic en botones, deslizar una barra o desplazarse hacia abajo en una pantalla
  • Afirma que ciertas vistas están en la pantalla o en un estado determinado (por ejemplo, que contienen texto específico o que una casilla de verificación está marcada, etc.).

Paso 1: Nota sobre la dependencia de Gradle

Ya tendrás la dependencia principal de Espresso, ya que se incluye en los proyectos de Android de forma predeterminada.

app/build.gradle

dependencies {

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

androidx.test.espresso:espresso-core: Esta dependencia principal de Espresso se incluye de forma predeterminada cuando creas un nuevo proyecto de Android. Contiene el código de prueba básico para la mayoría de las vistas y las acciones en ellas.

Paso 2: Cómo desactivar las animaciones

Las pruebas de Espresso se ejecutan en un dispositivo real y, por lo tanto, son pruebas de instrumentación por naturaleza. Un problema que surge son las animaciones: si una animación se retrasa y tratas de probar si una vista está en la pantalla, pero aún se está animando, Espresso puede fallar accidentalmente una prueba. Esto puede hacer que las pruebas de Espresso sean inestables.

Para las pruebas de IU de Espresso, se recomienda desactivar las animaciones (además, la prueba se ejecutará más rápido):

  1. En el dispositivo de prueba, ve a Configuración > Opciones para desarrolladores.
  2. Inhabilita estos tres parámetros de configuración: Escala de animación de ventana, Escala de animación de transición y Escala de duración de animador.

Paso 3: Cómo ver una prueba de Espresso

Antes de escribir una prueba de Espresso, observa un poco de código de Espresso.

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

Lo que hace esta instrucción es buscar la vista de casilla de verificación con el ID task_detail_complete_checkbox, hacer clic en ella y, luego, confirmar que está marcada.

La mayoría de las sentencias de Espresso se componen de cuatro partes:

1. Método estático de Espresso

onView

onView es un ejemplo de un método estático de Espresso que inicia una instrucción de Espresso. onView es una de las más comunes, pero hay otras opciones, como onData.

2. ViewMatcher

withId(R.id.task_detail_title_text)

withId es un ejemplo de un ViewMatcher que obtiene una vista por su ID. Hay otros comparadores de vistas que puedes consultar en la documentación.

3. ViewAction

perform(click())

El método perform que toma un ViewAction. Una ViewAction es algo que se puede hacer en la vista, por ejemplo, hacer clic en ella.

4. ViewAssertion

check(matches(isChecked()))

check, que toma un ViewAssertion ViewAssertions verifica o afirma algo sobre la vista. La ViewAssertion más común que usarás es la aserción matches. Para finalizar la aserción, usa otro ViewMatcher, en este caso isChecked.

Ten en cuenta que no siempre llamas a perform y check en una instrucción de Espresso. Puedes tener instrucciones que solo realicen una aserción con check o que solo realicen una ViewAction con perform.

  1. Abrir TaskDetailFragmentTest.kt
  2. Actualiza la prueba 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())))
    }

Estas son las sentencias de importación, si las necesitas:

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. Todo lo que aparece después del comentario // THEN usa Espresso. Examina la estructura de la prueba y el uso de withId, y verifica que se hagan aserciones sobre cómo debería verse la página de detalles.
  2. Ejecuta la prueba y confirma que se complete con éxito.

Paso 4: Opcional: Escribe tu propia prueba de Espresso

Ahora escribe una prueba por tu cuenta.

  1. Crea una prueba nueva llamada completedTaskDetails_DisplayedInUi y copia este código de esqueleto.

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. Observa la prueba anterior y completa esta prueba.
  2. Ejecuta la prueba y confirma que se complete con éxito.

El completedTaskDetails_DisplayedInUi terminado debería verse como este código.

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

En este último paso, aprenderás a probar el componente de Navigation con un tipo diferente de doble de prueba llamado simulacro y la biblioteca de pruebas Mockito.

En este codelab, usaste un doble de prueba llamado falso. Los objetos simulados son uno de los muchos tipos de dobles de prueba. ¿Qué doble de prueba deberías usar para probar el componente de Navigation?

Piensa en cómo se produce la navegación. Imagina que presionas una de las tareas en TasksFragment para navegar a una pantalla de detalles de la tarea.

Aquí tienes código en TasksFragment que navega a una pantalla de detalles de la tarea cuando se presiona.

TasksFragment.kt

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


La navegación se produce debido a una llamada al método navigate. Si necesitas escribir una instrucción assert, no hay una forma sencilla de probar si navegaste a TaskDetailFragment. Navegar es una acción complicada que no genera un resultado claro ni un cambio de estado, más allá de la inicialización de TaskDetailFragment.

Lo que puedes afirmar es que se llamó al método navigate con el parámetro de acción correcto. Esto es exactamente lo que hace un doble de prueba simulado: verifica si se llamaron métodos específicos.

Mockito es un framework para crear dobles de prueba. Si bien la palabra "simulación" se usa en la API y en el nombre, no se usa solo para crear simulaciones. También puede crear stubs y spies.

Usarás Mockito para crear un objeto NavigationController simulado que pueda confirmar que se llamó al método de navegación correctamente.

Paso 1: Agrega dependencias de Gradle

  1. Agrega las dependencias de 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: Es la dependencia de Mockito.
  • dexmaker-mockito: Esta biblioteca es necesaria para usar Mockito en un proyecto de Android. Mockito necesita generar clases en el tiempo de ejecución. En Android, esto se hace con código de bytes dex, por lo que esta biblioteca permite que Mockito genere objetos durante el tiempo de ejecución en Android.
  • androidx.test.espresso:espresso-contrib: Esta biblioteca se compone de contribuciones externas (de ahí su nombre) que contienen código de prueba para vistas más avanzadas, como DatePicker y RecyclerView. También contiene verificaciones de accesibilidad y una clase llamada CountingIdlingResource que se explica más adelante.

Paso 2: Crea CreateTasksFragmentTest

  1. Abre TasksFragment.
  2. Haz clic con el botón derecho en el nombre de la clase TasksFragment y selecciona Generar y, luego, Prueba. Crea una prueba en el conjunto de orígenes androidTest.
  3. Copia este código en 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()
    }

}

Este código es similar al código de TaskDetailFragmentTest que escribiste. Configura y desconfigura un FakeAndroidTestRepository. Agrega una prueba de navegación para verificar que, cuando haces clic en una tarea de la lista, se te dirija al TaskDetailFragment correcto.

  1. Agrega la prueba 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. Usa la función mock de Mockito para crear un objeto simulado.

TasksFragmentTest.kt

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

Para simular en Mockito, pasa la clase que quieres simular.

A continuación, debes asociar tu NavController con el fragmento. onFragment te permite llamar a métodos en el fragmento.

  1. Haz que tu nuevo objeto simulado sea el NavController del fragmento.
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. Agrega el código para hacer clic en el elemento del RecyclerView que tiene el texto "TITLE1".
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActions forma parte de la biblioteca espresso-contrib y te permite realizar acciones de Espresso en un RecyclerView.

  1. Verifica que se haya llamado a navigate con el argumento correcto.
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

El método verify de Mockito es lo que hace que esto sea una simulación: puedes confirmar que el objeto navController simulado llamó a un método específico (navigate) con un parámetro (actionTasksFragmentToTaskDetailFragment con el ID "id1").

La prueba completa se ve de la siguiente manera:

@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. Ejecuta la prueba.

En resumen, para probar la navegación, puedes hacer lo siguiente:

  1. Usa Mockito para crear un objeto simulado NavController.
  2. Adjunta ese NavController simulado al fragmento.
  3. Verifica que se haya llamado a navigate con la acción y los parámetros correctos.

Paso 3: Opcional, write clickAddTaskButton_navigateToAddEditFragment

Para ver si puedes escribir una prueba de navegación por tu cuenta, intenta realizar esta tarea.

  1. Escribe la prueba clickAddTaskButton_navigateToAddEditFragment que verifica que, si haces clic en el botón de acción flotante +, se te redirecciona a AddEditTaskFragment.

La respuesta se encuentra a continuación.

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

Haz clic aquí para ver una comparación entre el código con el que comenzaste y el código final.

Para descargar el código del codelab terminado, puedes usar el siguiente comando de git:

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


También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Download Zip

En este codelab, se explicó cómo configurar la inyección de dependencia manual, un localizador de servicios y cómo usar objetos simulados y falsos en tus apps para Android escritas en Kotlin. En particular:

  • Lo que deseas probar y tu estrategia de pruebas determinan los tipos de pruebas que implementarás para tu app. Las pruebas de unidades son enfocadas y rápidas. Las pruebas de integración verifican la interacción entre las partes de tu programa. Las pruebas de extremo a extremo verifican las funciones, tienen la mayor fidelidad, a menudo se instrumentan y pueden tardar más en ejecutarse.
  • La arquitectura de tu app influye en la dificultad de las pruebas.
  • El TDD o desarrollo basado en pruebas es una estrategia en la que primero escribes las pruebas y, luego, creas la función para que las pruebas pasen.
  • Para aislar partes de tu app y realizar pruebas, puedes usar dobles de prueba. Un doble de prueba es una versión de una clase creada específicamente para las pruebas. Por ejemplo, simulas obtener datos de una base de datos o de Internet.
  • Usa la inserción de dependencias para reemplazar una clase real por una clase de prueba, por ejemplo, un repositorio o una capa de redes.
  • Usa pruebas instrumentadas (androidTest) para iniciar componentes de IU.
  • Cuando no puedes usar la inyección de dependencias del constructor, por ejemplo, para iniciar un fragmento, a menudo puedes usar un localizador de servicios. El patrón del localizador de servicios es una alternativa a la inyección de dependencias. Implica crear una clase singleton llamada "Service Locator", cuyo propósito es proporcionar dependencias, tanto para el código normal como para el de prueba.

Curso de Udacity:

Documentación para desarrolladores de Android:

Videos:

Otro:

Para obtener vínculos a otros codelabs de este curso, consulta la página de destino de los codelabs de Aspectos avanzados de Android en Kotlin.