Cómo agregar un repositorio y una DI manual

1. Antes de comenzar

Introducción

En el codelab anterior, aprendiste cómo obtener datos de un servicio web haciendo que ViewModel recupere las URLs de fotos de Marte de la red mediante un servicio de API. Si bien este enfoque funciona y es fácil de implementar, no escala bien a medida que tu app crece y necesita trabajar con más de una fuente de datos. Para solucionar este problema, las prácticas recomendadas de la arquitectura de Android sugieren separar la capa de la IU y la de datos.

En este codelab, refactorizarás la app de Mars Photos en capas de IU y datos independientes. Aprenderás a implementar el patrón de repositorio y a usar la inserción de dependencias. La inserción de dependencias crea una estructura de codificación más flexible que ayuda con el desarrollo y las pruebas.

Requisitos previos

  • Poder recuperar JSON de un servicio web de REST y analizar esos datos en objetos Kotlin mediante las bibliotecas Retrofit y Serialization (kotlinx.serialization)
  • Tener conocimientos sobre cómo usar un servicio web de REST
  • Capacidad para implementar corrutinas en tu app

Qué aprenderás

  • Patrón de repositorio
  • Inserción de dependencias

Qué compilarás

  • Modifica la app de Mars Photos para separarla en una capa de IU y una de datos.
  • Cuando separes la capa de datos, implementarás el patrón de repositorio.
  • Usarás la inserción de dependencias para crear una base de código vinculada de manera flexible.

Requisitos

  • Una computadora con un navegador web moderno, como la versión más reciente de Chrome

Obtén el código de partida

Para comenzar, descarga el código de partida:

Descargar ZIP

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

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout repo-starter

Puedes explorar el código en el repositorio de GitHub de Mars Photos.

2. Separa la capa de IU y la de datos

¿Por qué hay diferentes capas?

Separar el código en diferentes capas hace que tu app sea más escalable, sólida y fácil de probar. Tener varias capas con límites claramente definidos también facilita que varios desarrolladores trabajen en la misma app sin impactar entre ellos de manera negativa.

La arquitectura para apps recomendada de Android indica que una app debe tener al menos una capa de IU y una de datos.

En este codelab, te concentrarás en la capa de datos y realizarás cambios para que tu app siga las prácticas recomendadas.

¿Qué es una capa de datos?

La capa de datos es responsable de la lógica empresarial de tu app y de buscar y guardar datos para esta. La capa de datos expone los datos a la capa de la IU con el patrón unidireccional de datos. Los datos pueden provenir de varias fuentes, como una solicitud de red, una base de datos local o un archivo del dispositivo.

Una app puede tener más de una fuente de datos. Cuando se abre la app, esta recupera datos de una base de datos local del dispositivo, que es la primera fuente. Mientras la app se está ejecutando, envía una solicitud de red a la segunda fuente para recuperar datos más recientes.

Al tener los datos en una capa separada del código de la IU, puedes realizar cambios en una parte del código sin afectar a otra. Este enfoque es parte de un principio de diseño denominado separación de problemas. Una sección de código se enfoca en su propia preocupación y encapsula su funcionamiento interno a partir de otro código. El encapsulamiento es una forma de ocultar el funcionamiento interno del código de otras secciones. Cuando una sección de código debe interactuar con otra, lo hace a través de una interfaz.

El objetivo de la capa de la IU es mostrar los datos proporcionados. La IU ya no recupera los datos, ya que de eso se encarga la capa de datos.

La capa de datos consta de uno o más repositorios. Los repositorios contienen cero o más fuentes de datos.

71d12ea3a6d3a43e.png

Las prácticas recomendadas requieren que la app tenga un repositorio para cada tipo de fuente de datos que esta use.

En este codelab, la app tiene una fuente de datos, por lo que tiene un repositorio después de refactorizar el código. Para esta app, el repositorio que recupera datos de Internet completa las responsabilidades de la fuente de datos. Para ello, envía una solicitud de red a una API. Si la codificación de la fuente de datos es más compleja o se agregan fuentes de datos adicionales, las responsabilidades de la fuente de datos se encapsulan en clases de fuente de datos separadas, y el repositorio es responsable de administrarlas todas.

¿Qué es un repositorio?

En general, en una clase de repositorio hace lo siguiente:

  • Expone datos al resto de la app.
  • Centraliza los cambios en los datos.
  • Resuelve los conflictos entre varias fuentes de datos.
  • Abstrae las fuentes de datos del resto de la app.
  • Contiene la lógica empresarial.

La app de Mars Photos tiene una sola fuente de datos, que es la llamada a la API de la red. No tiene una lógica empresarial, ya que solo recupera datos. Los datos se exponen a la app a través de la clase de repositorio, que abstrae la fuente de los datos.

ff7a7cd039402747.png

3. Crea la capa de datos

Primero, debes crear la clase de repositorio. En la guía para desarrolladores de Android, se indica que las clases de repositorio llevan el nombre de los datos de los que son responsables. La convención de nombres de repositorios es tipo de datos + Repository. En tu app, es MarsPhotosRepository.

Cómo crear el repositorio

  1. Haz clic con el botón derecho en com.example.marsphotos y selecciona New > Package.
  2. En el cuadro de diálogo, ingresa data.
  3. Haz clic con el botón derecho en el paquete data y selecciona New > Kotlin Class/File.
  4. En el cuadro de diálogo, selecciona Interface y asígnale el nombre MarsPhotosRepository a la interfaz.
  5. Dentro de la interfaz MarsPhotosRepository, agrega una función abstracta llamada getMarsPhotos(), que muestra una lista de objetos MarsPhoto. Se llama desde una corrutina, por lo que debes declararla con suspend.
import com.example.marsphotos.model.MarsPhoto

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}
  1. Debajo de la declaración de la interfaz, crea una clase llamada NetworkMarsPhotosRepository para implementar la interfaz MarsPhotosRepository.
  2. Agrega la interfaz MarsPhotosRepository a la declaración de la clase.

Como no anulaste el método abstracto de la interfaz, aparecerá un mensaje de error. Lo resolverás en el siguiente paso.

Captura de pantalla de Android Studio que muestra la interfaz de MarsPhotosRepository y la clase NetworkMarsPhotosRepository

  1. Dentro de la clase NetworkMarsPhotosRepository, anula la función abstracta getMarsPhotos(). Esta función muestra los datos de la llamada a MarsApi.retrofitService.getPhotos().
import com.example.marsphotos.network.MarsApi

class NetworkMarsPhotosRepository() : MarsPhotosRepository {
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return MarsApi.retrofitService.getPhotos()
   }
}

A continuación, debes actualizar el código ViewModel para usar el repositorio para obtener los datos como se sugiere en las prácticas recomendadas de Android.

  1. Abre el archivo ui/screens/MarsViewModel.kt.
  2. Desplázate hacia abajo hasta el método getMarsPhotos().
  3. Reemplaza la línea "val listResult = MarsApi.retrofitService.getPhotos()" por el siguiente código:
import com.example.marsphotos.data.NetworkMarsPhotosRepository

val marsPhotosRepository = NetworkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()

5313985852c151aa.png

  1. Ejecuta la app. Verás que los resultados que se muestran son los mismos que los anteriores.

En lugar de que ViewModel envíe directamente la solicitud de red de los datos, el repositorio proporciona los datos. El elemento ViewModel ya no hace referencia directa al código MarsApi. diagrama de flujo para mostrar cómo se accedió a la capa de datos directamente desde Viewmodel antes. Ahora tenemos el repositorio de fotos de Marte

Este enfoque ayuda a que el código recupere los datos de manera flexible de ViewModel. Usar acoplamiento bajo permite realizar cambios en ViewModel o el repositorio sin que estos se impacten negativamente entre sí, siempre que el repositorio tenga una función llamada getMarsPhotos().

Ahora podemos realizar cambios en la implementación dentro del repositorio sin afectar al llamador. En el caso de las apps más grandes, este cambio puede admitir varios llamadores.

4. Inserción de dependencias

Muchas veces, las clases requieren objetos de otras clases para funcionar. Cuando una clase requiere de otra, la clase requerida se denomina dependencia.

En los siguientes ejemplos, el objeto Car depende de un objeto Engine.

Una clase puede obtener estos objetos obligatorios de dos maneras. Una forma es que la clase cree una instancia del objeto requerido.

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car {

    private val engine = GasEngine()

    fun start() {
        engine.start()
    }
}

fun main() {
    val car = Car()
    car.start()
}

La otra forma es pasar el objeto requerido como argumento.

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = GasEngine()
    val car = Car(engine)
    car.start()
}

Es fácil crear una instancia para los objetos requeridos, pero este enfoque hace que el código sea inflexible y más difícil de probar, ya que la clase y el objeto requeridos están estrechamente vinculados.

La clase que realiza la llamada debe llamar al constructor del objeto, que es un detalle de implementación. Si cambia el constructor, también debe cambiar el código de llamada.

Para que el código sea más flexible y adaptable, una clase no debe crear instancias de los objetos de los que depende. Se debe crear una instancia de los objetos de los que depende fuera de la clase y, luego, pasarlos. Este enfoque crea un código más flexible, ya que la clase ya no está codificada en un objeto en particular. La implementación del objeto requerido puede cambiar sin necesidad de modificar el código de llamada.

Siguiendo con el ejemplo anterior, si se necesita un ElectricEngine, este se puede crear y pasar a la clase Car. No es necesario modificar de ninguna manera la clase Car.

interface Engine {
    fun start()
}

class ElectricEngine : Engine {
    override fun start() {
        println("ElectricEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = ElectricEngine()
    val car = Car(engine)
    car.start()
}

El paso de los objetos requeridos se denomina inserción de dependencias (DI). También se conoce como inversión de control.

Con una DI, una dependencia se proporciona en el tiempo de ejecución, en lugar de codificarse en la clase que realiza la llamada.

Cómo implementar la inserción de dependencias:

  • Ayuda con la reutilización del código. El código no depende de un objeto específico, lo que permite una mayor flexibilidad.
  • Facilita la refactorización. El código está vinculado de manera flexible, por lo que la refactorización de una sección de código no afecta a otra.
  • Ayuda con las pruebas. Los objetos de prueba se pueden pasar durante la prueba.

Un ejemplo de cómo DI puede ayudar con las pruebas es cuando prueba el código de llamada de red. En este caso, quieres probar si la llamada de red se realiza y si se muestran datos. Si tienes que pagar cada vez que envías una solicitud de red durante una prueba, puedes optar por omitir la prueba de este código, ya que puede ser costosa. Ahora, imagina que podemos falsificar la solicitud de red para realizar pruebas. ¿Cuánto más dinero y felicidad obtendrías? Para realizar pruebas, puedes pasar un objeto de prueba al repositorio que muestre datos falsos cuando se llama sin realizar una llamada de red real. 1ea410d6670b7670.png

Queremos que ViewModel se pueda probar, pero actualmente depende de un repositorio que realiza llamadas de red reales. Cuando se realizan pruebas con el repositorio de producción real, se envían muchas llamadas de red. A fin de solucionar este problema, en lugar de que ViewModel cree el repositorio, necesitamos una forma de decidir y pasar una instancia de repositorio para usarla en la producción y realizar pruebas de forma dinámica.

Este proceso se realiza mediante la implementación de un contenedor de aplicación que proporciona el repositorio a MarsViewModel.

Un contenedor es un objeto que contiene las dependencias que requiere la app. Estas dependencias se usan en toda la aplicación, por lo que deben estar en un lugar común que todas las actividades puedan usar. Puedes crear una subclase de la clase Application y almacenar una referencia al contenedor.

Cómo crear un contenedor de la aplicación

  1. Haz clic con el botón derecho en el paquete data y selecciona New > Kotlin Class/File.
  2. En el cuadro de diálogo, selecciona Interface y asígnale el nombre AppContainer a la interfaz.
  3. Dentro de la interfaz AppContainer, agrega una propiedad abstracta llamada marsPhotosRepository de tipo MarsPhotosRepository.

a47a6b9d9cc5df58.png

  1. Debajo de la definición de la interfaz, crea una clase llamada DefaultAppContainer que implemente la interfaz AppContainer.
  2. Desde network/MarsApiService.kt, mueve el código de las variables BASE_URL, retrofit y retrofitService a la clase DefaultAppContainer, para que se ubiquen dentro del contenedor que mantiene las dependencias.
import retrofit2.Retrofit
import com.example.marsphotos.network.MarsApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType

class DefaultAppContainer : AppContainer {

    private const val BASE_URL =
        "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit: Retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(BASE_URL)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

}
  1. Para la variable BASE_URL, quita la palabra clave const. Es necesario quitar const, porque BASE_URL ya no es una variable de nivel superior y ahora es una propiedad de la clase DefaultAppContainer. Refactorízalo a baseUrl en mayúsculas y minúsculas.
  2. Para la variable retrofitService, agrega un modificador de visibilidad private. Se agrega el modificador private porque la variable marsPhotosRepository solo se usa dentro de la clase de la propiedad retrofitService, por lo que no es necesario acceder a ella fuera de la clase.
  3. La clase DefaultAppContainer implementa la interfaz AppContainer, por lo que debemos anular la propiedad marsPhotosRepository. Después de la variable retrofitService, agrega el siguiente código:
override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetworkMarsPhotosRepository(retrofitService)
}

La clase DefaultAppContainer completada debería verse de la siguiente manera:

class DefaultAppContainer : AppContainer {

    private val baseUrl =
        "https://android-kotlin-fun-mars-server.appspot.com"

    /**
     * Use the Retrofit builder to build a retrofit object using a kotlinx.serialization converter
     */
    private val retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}
  1. Abre el archivo data/MarsPhotosRepository.kt. Pasa retrofitService a NetworkMarsPhotosRepository y modifica la clase NetworkMarsPhotosRepository.
  2. En la declaración de la clase NetworkMarsPhotosRepository, agrega el parámetro constructor marsApiService como se muestra en el siguiente código.
import com.example.marsphotos.network.MarsApiService

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
  1. En la clase NetworkMarsPhotosRepository, en la función getMarsPhotos(), cambia la sentencia return para recuperar datos de marsApiService.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
  1. Quita la siguiente importación del archivo MarsPhotosRepository.kt.
// Remove
import com.example.marsphotos.network.MarsApi

Desde el archivo network/MarsApiService.kt, quitamos todo el código del objeto. Ahora podemos borrar la declaración de objeto restante porque ya no es necesaria.

  1. Borra el siguiente código:
object MarsApi {

}

5. Adjunta el contenedor de la aplicación a la app

En los pasos de esta sección, se conecta el objeto de la aplicación al contenedor de la aplicación, como se muestra en la siguiente figura.

6ff9e55cfa8f23e4.png

  1. Haz clic con el botón derecho en com.example.marsphotos y selecciona New > Kotlin Class/File.
  2. En el cuadro de diálogo, ingresa MarsPhotosApplication. Esta clase se hereda del objeto de la aplicación, por lo que debes agregarla a la declaración de la clase.
import android.app.Application

class MarsPhotosApplication : Application() {
}
  1. Dentro de la clase MarsPhotosApplication, declara una variable llamada container del tipo AppContainer para almacenar el objeto DefaultAppContainer. La variable se inicializa durante la llamada a onCreate(), por lo que debe marcarse con el modificador lateinit.
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

lateinit var container: AppContainer
override fun onCreate() {
    super.onCreate()
    container = DefaultAppContainer()
}
  1. El archivo MarsPhotosApplication.kt completo debería verse como el siguiente código:
package com.example.marsphotos

import android.app.Application
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}
  1. Debes actualizar el manifiesto de Android para que la app use la clase de aplicación que acabas de definir. Abre el archivo manifests/AndroidManifest.xml.

2ca07697492c53c5.png

  1. En la sección application, agrega el atributo android:name con un valor de nombre de clase de la aplicación ".MarsPhotosApplication".
<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

6. Agrega el repositorio a ViewModel

Una vez que completes estos pasos, ViewModel podrá llamar al objeto del repositorio para recuperar datos de Marte.

7425864315cb5e6f.png

  1. Abre el archivo ui/screens/MarsViewModel.kt.
  2. En la declaración de clase para MarsViewModel, agrega un parámetro de constructor privado marsPhotosRepository de tipo MarsPhotosRepository. El valor del parámetro del constructor proviene del contenedor de la aplicación porque la app ahora usa la inserción de dependencias.
import com.example.marsphotos.data.MarsPhotosRepository

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
  1. En la función getMarsPhotos(), quita la siguiente línea de código, ya que ahora se propaga marsPhotosRepository en la llamada del constructor.
val marsPhotosRepository = NetworkMarsPhotosRepository()
  1. Debido a que el framework de Android no permite que se pasen valores a ViewModel en el constructor cuando se crean, implementamos un objeto ViewModelProvider.Factory, lo que nos permite evitar esta limitación.

El patrón de fábrica es un patrón de creación de objetos. El objeto MarsViewModel.Factory usa el contenedor de la aplicación para recuperar el marsPhotosRepository y, luego, pasa este repositorio al ViewModel cuando se crea el objeto ViewModel.

  1. Debajo de la función getMarsPhotos(), escribe el código del objeto complementario.

Un objeto complementario ayuda porque permite tener una sola instancia de un objeto que todos utilizan, sin necesidad de crear una nueva instancia de un objeto costoso. Este es un detalle de implementación, y la separación nos permite realizar cambios sin afectar otras partes del código de la app.

APPLICATION_KEY forma parte del objeto ViewModelProvider.AndroidViewModelFactory.Companion y se usa para buscar el objeto MarsPhotosApplication de la app, que tiene la propiedad container que se usa para recuperar el repositorio utilizado en la inserción de dependencia.

import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.marsphotos.MarsPhotosApplication

companion object {
   val Factory: ViewModelProvider.Factory = viewModelFactory {
       initializer {
           val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
           val marsPhotosRepository = application.container.marsPhotosRepository
           MarsViewModel(marsPhotosRepository = marsPhotosRepository)
       }
   }
}
  1. Abre el archivo theme/MarsPhotosApp.kt, dentro de la función MarsPhotosApp(), y actualiza viewModel() para usar la configuración de fábrica.
Surface(
            // ...
        ) {
            val marsViewModel: MarsViewModel =
   viewModel(factory = MarsViewModel.Factory)
            // ...
        }

Esta variable marsViewModel se propaga con la llamada a la función viewModel() a la que se le pasa el MarsViewModel.Factory del objeto complementario como argumento para crear el ViewModel.

  1. Ejecuta la app para confirmar que aún se comporta como antes.

Felicitaciones por refactorizar la app de Mars Photos para usar un repositorio y una inserción de dependencias. Al implementar una capa de datos con un repositorio, la IU y el código de fuente de datos se separaron para seguir las prácticas recomendadas de Android.

Cuando usas la inserción de dependencias, es más fácil probar ViewModel. Tu app ahora es más flexible y robusta, y está lista para escalar.

Después de realizar estas mejoras, es hora de aprender a probarlas. Las pruebas hacen que tu código se comporte como se espera y reducen la probabilidad de introducir errores a medida que trabajas en él.

7. Configura las pruebas locales

En las secciones anteriores, implementaste un repositorio para abstraer la interacción directa con el servicio de la API de REST fuera del ViewModel. Esta práctica te permite probar pequeños fragmentos de código de propósito limitado. Las pruebas de pequeños fragmentos de código de funcionalidad limitada son más fáciles de compilar, implementar y comprender que las pruebas escritas para grandes fragmentos de código que tienen varias funcionalidades.

También implementaste el repositorio aprovechando las interfaces, la herencia y la inserción de dependencias. En las próximas secciones, descubrirás por qué estas recomendaciones de arquitectura facilitan las pruebas. Además, usaste corrutinas de Kotlin para crear la solicitud de red. Probar el código que usa corrutinas requiere pasos adicionales para justificar la ejecución asíncrona del código. Estos pasos se explican más adelante en este codelab.

Cómo agregar dependencias de prueba locales

Agrega las siguientes dependencias a app/build.gradle.kts.

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")

Cómo crear el directorio de prueba local

  1. Para crear un directorio de prueba local, haz clic con el botón derecho en el directorio src de la vista del proyecto y selecciona New > Directory > test/java.
  2. Crea un paquete nuevo en el directorio de prueba llamado com.example.marsphotos.

8. Crea dependencias y datos falsos para pruebas

En esta sección, aprenderás cómo la inserción de dependencias puede ayudarte a escribir pruebas locales. Anteriormente en el codelab, creaste un repositorio que depende de un servicio de API. Luego, modificaste el ViewModel para que dependiera del repositorio.

Cada prueba local debe probar solo un aspecto. Por ejemplo, cuando pruebas la funcionalidad del modelo de vista, no deseas probar la funcionalidad del repositorio ni del servicio de la API. Del mismo modo, cuando pruebes el repositorio, no querrás probar el servicio de la API.

Si usas interfaces y, luego, usas la inserción de dependencias para incluir clases que heredan contenido de esas interfaces, puedes simular la funcionalidad de esas dependencias con clases falsas creadas solo con el fin de realizar pruebas. Incorporar clases y fuentes de datos falsas para realizar pruebas permite probar el código de forma aislada, con repetibilidad y coherencia.

Lo primero que necesitas son datos falsos para usarlos en las clases falsas que crees más tarde.

  1. En el directorio de prueba, crea un paquete en com.example.marsphotos llamado fake.
  2. Crea un nuevo objeto Kotlin en el directorio fake llamado FakeDataSource.
  3. En este objeto, crea una propiedad establecida como lista de objetos MarsPhoto. La lista no tiene que ser larga, pero debe contener al menos dos objetos.
object FakeDataSource {

   const val idOne = "img1"
   const val idTwo = "img2"
   const val imgOne = "url.1"
   const val imgTwo = "url.2"
   val photosList = listOf(
       MarsPhoto(
           id = idOne,
           imgSrc = imgOne
       ),
       MarsPhoto(
           id = idTwo,
           imgSrc = imgTwo
       )
   )
}

Como se mencionó antes en este codelab, el repositorio depende del servicio de API. Para crear una prueba de repositorio, debe haber un servicio de API falso que muestre los datos falsos que acabas de crear. Cuando este servicio de API falso se pasa al repositorio, se llama a sus métodos y el repositorio recibe los datos falsos.

  1. En el paquete fake, crea una nueva clase llamada FakeMarsApiService.
  2. Configura la clase FakeMarsApiService para heredar de la interfaz MarsApiService.
class FakeMarsApiService : MarsApiService {
}
  1. Anula la función getPhotos().
override suspend fun getPhotos(): List<MarsPhoto> {
}
  1. Muestra la lista de fotos falsas del método getPhotos().
override suspend fun getPhotos(): List<MarsPhoto> {
   return FakeDataSource.photosList
}

Recuerda que, si aún tienes dudas sobre el propósito de esta clase, no te preocupes. Los usos de esta clase falsa se explican con más detalle en la siguiente sección.

9. Escribe una prueba de repositorio

En esta sección, probarás el método getMarsPhotos() de la clase NetworkMarsPhotosRepository. También se aclara el uso de clases falsas y se muestra cómo probar corrutinas.

  1. En el directorio falso, crea una clase nueva llamada NetworkMarsRepositoryTest.
  2. Crea un método nuevo en la clase que acabas de crear, llamado networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(), y anótalo con @Test.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}

Para probar el repositorio, necesitarás una instancia de NetworkMarsPhotosRepository. Recuerda que esta clase depende de la interfaz MarsApiService. Aquí es donde aprovechas el servicio de API falso de la sección anterior.

  1. Crea una instancia de NetworkMarsPhotosRepository y pasa FakeMarsApiService como parámetro marsApiService.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )
}

Si pasas el servicio falso de la API, cualquier llamada a la propiedad marsApiService en el repositorio generará una llamada a FakeMarsApiService. Si pasas clases falsas para dependencias, puedes controlar exactamente lo que muestra la dependencia. Este enfoque garantiza que el código que estás probando no dependa de código no probado ni de APIs que podrían cambiar o tener problemas imprevistos. Estas situaciones pueden hacer que la prueba falle, incluso cuando no haya nada malo con el código que escribiste. Las simulaciones ayudan a crear un entorno de prueba más coherente, reducen su fragilidad y facilitan las evaluaciones concisas de una sola funcionalidad.

  1. Confirma que los datos que muestra el método getMarsPhotos() sean iguales a FakeDataSource.photosList.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}

Ten en cuenta que, en tu IDE, la llamada al método getMarsPhotos() aparece subrayada en rojo.

2bd5f8999e0f3ec2.png

Si colocas el cursor del mouse sobre el método, podrás ver un cuadro de información que indicará: "Suspend function 'getMarsPhotos' should be called only from a coroutine or another suspend function" (Se debe llamar a la función de suspensión 'getMarsPhotos' solo desde una corrutina o desde otra función de suspensión):

d2d3b6d770677ef6.png

En data/MarsPhotosRepository.kt, si observas la implementación de getMarsPhotos() en el NetworkMarsPhotosRepository, verás que la función getMarsPhotos() es de suspensión.

class NetworkMarsPhotosRepository(
   private val marsApiService: MarsApiService
) : MarsPhotosRepository {
   /** Fetches list of MarsPhoto from marsApi*/
   override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}

Recuerda que, cuando llamaste a esta función desde MarsViewModel, llamaste a este método desde una corrutina llamando a una lambda pasada a viewModelScope.launch(). También debes llamar a las funciones de suspensión, como getMarsPhotos(), desde una corrutina en una prueba. Sin embargo, el enfoque es diferente. En la siguiente sección, se explica cómo solucionar este problema.

Cómo probar corrutinas

En esta sección, modificarás la prueba networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() para que el cuerpo del método de prueba se ejecute desde una corrutina.

  1. En NetworkMarsRepositoryTest.kt, modifica la función networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() para que sea una expresión.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
  1. Configura la expresión igual a la función runTest(). Este método espera una lambda.
...
import kotlinx.coroutines.test.runTest
...

@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
    runTest {}

La biblioteca de pruebas de corrutinas proporciona la función runTest(). La función toma el método que pasaste en la lambda y lo ejecuta desde TestScope, que se hereda de CoroutineScope.

  1. Mueve el contenido de la función de prueba a la función lambda.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
   runTest {
       val repository = NetworkMarsPhotosRepository(
           marsApiService = FakeMarsApiService()
       )
       assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
   }

Observa que la línea roja debajo de getMarsPhotos() ya no está. Si ejecutas esta prueba, todo sale bien.

10. Escribe una prueba de ViewModel

En esta sección, escribirás una prueba para la función getMarsPhotos() del MarsViewModel. MarsViewModel depende de MarsPhotosRepository. Por lo tanto, para escribir esta prueba, debes crear un MarsPhotosRepository falso. Además, hay algunos pasos adicionales que se deben tener en cuenta para las corrutinas más allá de usar el método runTest().

Cómo crear el repositorio falso

El objetivo de este paso es crear una clase falsa que herede de la interfaz MarsPhotosRepository y anule la función getMarsPhotos() para mostrar datos falsos. Este enfoque es similar al que tomaste con el servicio de API falso. La diferencia es que esta clase extiende la interfaz MarsPhotosRepository en lugar del MarsApiService.

  1. Crea una clase nueva en el directorio de fake llamada FakeNetworkMarsPhotosRepository.
  2. Extiende esta clase con la interfaz MarsPhotosRepository.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
  1. Anula la función getMarsPhotos().
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
   }
}
  1. Muestra FakeDataSource.photosList de la función getMarsPhotos().
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return FakeDataSource.photosList
   }
}

Cómo escribir la prueba de ViewModel

  1. Crea una clase nueva llamada MarsViewModelTest.
  2. Crea una función llamada marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() y anótala con @Test.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
  1. Haz que esta función sea una expresión establecida en el resultado del método runTest() para garantizar que la prueba se ejecute desde una corrutina, al igual que la prueba de repositorio de la sección anterior.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
    }
  1. En el cuerpo de lambda de runTest(), crea una instancia de MarsViewModel y pásale una instancia del repositorio falso que creaste.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
        val marsViewModel = MarsViewModel(
            marsPhotosRepository = FakeNetworkMarsPhotosRepository()
         )
    }
  1. Confirma que el marsUiState de tu instancia de ViewModel coincida con el resultado de una llamada exitosa a MarsPhotosRepository.getMarsPhotos().
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
   runTest {
       val marsViewModel = MarsViewModel(
           marsPhotosRepository = FakeNetworkMarsPhotosRepository()
       )
       assertEquals(
           MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
                   "photos retrieved"),
           marsViewModel.marsUiState
       )
   }

Si intentas ejecutar esta prueba tal como está, fallará. El error se parecerá al siguiente ejemplo:

Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

Recuerda que MarsViewModel llama al repositorio con viewModelScope.launch(). Esta instrucción inicia una corrutina nueva en el despachador de corrutinas predeterminado, que se denomina despachador Main. El despachador Main une el subproceso de IU de Android. El motivo del error anterior es que el subproceso de la IU de Android no está disponible en una prueba de unidades. Las pruebas de unidades se ejecutan en la estación de trabajo, no en un dispositivo Android ni en un emulador. Si el código de una prueba de unidades local hace referencia al despachador Main, se genera una excepción (como la que se muestra más arriba) cuando se ejecutan las pruebas de unidades. Para solucionar este problema, debes definir explícitamente el despachador predeterminado cuando ejecutes pruebas de unidades. Ve a la siguiente sección para aprender cómo hacerlo.

Cómo crear un despachador de prueba

Dado que el despachador Main solo está disponible en un contexto de IU, debes reemplazarlo por un despachador apto para pruebas de unidades. La biblioteca de corrutinas de Kotlin proporciona un despachador de corrutinas llamado TestDispatcher. Debes usar TestDispatcher, en lugar del despachador Main, para cualquier prueba de unidades en la que se cree una corrutina nueva, como es el caso de la función getMarsPhotos() del modelo de vista.

Para reemplazar el despachador Main por un TestDispatcher en todos los casos, usa la función Dispatchers.setMain(). Puedes usar la función Dispatchers.resetMain() para restablecer el despachador de subprocesos a Main. Para evitar duplicar el código que reemplaza al despachador Main en cada prueba, puedes extraerlo en una regla de prueba JUnit. Una TestRule proporciona una manera de controlar el entorno en el cual se ejecuta una prueba. Una TestRule puede agregar verificaciones adicionales, realizar la configuración o la limpieza necesarias para las pruebas, o bien observar la ejecución de la prueba para enviar el informe a otra parte. Se pueden compartir fácilmente entre clases de prueba.

Crea una clase dedicada para escribir la TestRule que reemplazará al despachador Main. Para implementar una TestRule personalizada, completa los siguientes pasos:

  1. Crea un paquete nuevo en el directorio de prueba llamado rules.
  2. En el directorio de reglas, crea una clase nueva llamada TestDispatcherRule.
  3. Extiende el TestDispatcherRule con TestWatcher. La clase TestWatcher te permite realizar acciones en diferentes fases de ejecución de una prueba.
class TestDispatcherRule(): TestWatcher(){

}
  1. Crea un parámetro de constructor TestDispatcher para TestDispatcherRule.

Este parámetro habilita el uso de diferentes despachadores, como StandardTestDispatcher. Este parámetro de constructor debe tener un valor predeterminado establecido en una instancia del objeto UnconfinedTestDispatcher. La clase UnconfinedTestDispatcher hereda de la clase TestDispatcher y especifica que las tareas no se deben ejecutar en ningún orden en particular. Este patrón de ejecución es adecuado para pruebas simples, ya que las corrutinas se manejan automáticamente. A diferencia de UnconfinedTestDispatcher, la clase StandardTestDispatcher habilita el control total de la ejecución de corrutinas. De esta manera, se prefiere para pruebas complicadas que requieren un enfoque manual, pero no es necesario para las pruebas de este codelab.

class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

}
  1. El objetivo principal de esta regla de prueba es reemplazar el despachador Main por uno de prueba antes de que comience a ejecutarse. La función starting() de la clase TestWatcher se ejecuta antes de que se ejecute una prueba determinada. Anula la función starting().
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {

    }
}
  1. Agrega una llamada a Dispatchers.setMain() y pasa testDispatcher como argumento.
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
}
  1. Una vez finalizada la ejecución de prueba, restablece el despachador Main anulando el método finished(). Llama a la función Dispatchers.resetMain().
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

La regla TestDispatcherRule estará lista para volver a usarse.

  1. Abre el archivo MarsViewModelTest.kt.
  2. En la clase MarsViewModelTest, crea una instancia de la clase TestDispatcherRule y asígnala a una propiedad de solo lectura testDispatcher.
class MarsViewModelTest {

    val testDispatcher = TestDispatcherRule()
    ...
}
  1. Para aplicar esta regla a tus pruebas, agrega la anotación @get:Rule a la propiedad testDispatcher.
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. Vuelve a ejecutar la prueba. Confirma que esta vez se apruebe.

11. Obtén el código de solución

Para descargar el código del codelab terminado, puedes usar estos comandos:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout coil-starter

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

Descargar ZIP

Si deseas ver el código de la solución para este codelab, míralo en GitHub.

12. Conclusión

Felicitaciones por completar este codelab y refactorizar la app de Mars Photos para implementar el patrón del repositorio y la inserción de dependencias.

El código de la app ahora sigue las prácticas recomendadas de Android para la capa de datos, lo que significa que es más flexible, robusta y fácil de escalar.

Estos cambios también ayudaron a facilitar las pruebas en la app. Este beneficio es muy importante, ya que el código puede seguir evolucionando y funcionando como se espera.

No olvides compartir tu trabajo en redes sociales con el hashtag #AndroidBasics.

13. Más información

Documentación para desarrolladores de Android:

Otra opción: