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:
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.
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.
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
- Haz clic con el botón derecho en com.example.marsphotos y selecciona New > Package.
- En el cuadro de diálogo, ingresa
data
. - Haz clic con el botón derecho en el paquete
data
y selecciona New > Kotlin Class/File. - En el cuadro de diálogo, selecciona Interface y asígnale el nombre
MarsPhotosRepository
a la interfaz. - Dentro de la interfaz
MarsPhotosRepository
, agrega una función abstracta llamadagetMarsPhotos()
, que muestra una lista de objetosMarsPhoto
. Se llama desde una corrutina, por lo que debes declararla consuspend
.
import com.example.marsphotos.model.MarsPhoto
interface MarsPhotosRepository {
suspend fun getMarsPhotos(): List<MarsPhoto>
}
- Debajo de la declaración de la interfaz, crea una clase llamada
NetworkMarsPhotosRepository
para implementar la interfazMarsPhotosRepository
. - 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.
- Dentro de la clase
NetworkMarsPhotosRepository
, anula la función abstractagetMarsPhotos()
. Esta función muestra los datos de la llamada aMarsApi.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.
- Abre el archivo
ui/screens/MarsViewModel.kt
. - Desplázate hacia abajo hasta el método
getMarsPhotos()
. - 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()
- 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
.
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.
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
- Haz clic con el botón derecho en el paquete
data
y selecciona New > Kotlin Class/File. - En el cuadro de diálogo, selecciona Interface y asígnale el nombre
AppContainer
a la interfaz. - Dentro de la interfaz
AppContainer
, agrega una propiedad abstracta llamadamarsPhotosRepository
de tipoMarsPhotosRepository
. - Debajo de la definición de la interfaz, crea una clase llamada
DefaultAppContainer
que implemente la interfazAppContainer
. - Desde
network/MarsApiService.kt
, mueve el código de las variablesBASE_URL
,retrofit
yretrofitService
a la claseDefaultAppContainer
, 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)
}
}
- Para la variable
BASE_URL
, quita la palabra claveconst
. Es necesario quitarconst
, porqueBASE_URL
ya no es una variable de nivel superior y ahora es una propiedad de la claseDefaultAppContainer
. Refactorízalo abaseUrl
en mayúsculas y minúsculas. - Para la variable
retrofitService
, agrega un modificador de visibilidadprivate
. Se agrega el modificadorprivate
porque la variablemarsPhotosRepository
solo se usa dentro de la clase de la propiedadretrofitService
, por lo que no es necesario acceder a ella fuera de la clase. - La clase
DefaultAppContainer
implementa la interfazAppContainer
, por lo que debemos anular la propiedadmarsPhotosRepository
. Después de la variableretrofitService
, 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)
}
}
- Abre el archivo
data/MarsPhotosRepository.kt
. PasaretrofitService
aNetworkMarsPhotosRepository
y modifica la claseNetworkMarsPhotosRepository
. - En la declaración de la clase
NetworkMarsPhotosRepository
, agrega el parámetro constructormarsApiService
como se muestra en el siguiente código.
import com.example.marsphotos.network.MarsApiService
class NetworkMarsPhotosRepository(
private val marsApiService: MarsApiService
) : MarsPhotosRepository {
- En la clase
NetworkMarsPhotosRepository
, en la funcióngetMarsPhotos()
, cambia la sentencia return para recuperar datos demarsApiService
.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
- 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.
- 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.
- Haz clic con el botón derecho en
com.example.marsphotos
y selecciona New > Kotlin Class/File. - 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() {
}
- Dentro de la clase
MarsPhotosApplication
, declara una variable llamadacontainer
del tipoAppContainer
para almacenar el objetoDefaultAppContainer
. La variable se inicializa durante la llamada aonCreate()
, por lo que debe marcarse con el modificadorlateinit
.
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
- 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()
}
}
- 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
.
- En la sección
application
, agrega el atributoandroid: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.
- Abre el archivo
ui/screens/MarsViewModel.kt
. - En la declaración de clase para
MarsViewModel
, agrega un parámetro de constructor privadomarsPhotosRepository
de tipoMarsPhotosRepository
. 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(){
- En la función
getMarsPhotos()
, quita la siguiente línea de código, ya que ahora se propagamarsPhotosRepository
en la llamada del constructor.
val marsPhotosRepository = NetworkMarsPhotosRepository()
- Debido a que el framework de Android no permite que se pasen valores a
ViewModel
en el constructor cuando se crean, implementamos un objetoViewModelProvider.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
.
- 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)
}
}
}
- Abre el archivo
theme/MarsPhotosApp.kt
, dentro de la funciónMarsPhotosApp()
, y actualizaviewModel()
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
.
- 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
- 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.
- 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.
- En el directorio de prueba, crea un paquete en
com.example.marsphotos
llamadofake
. - Crea un nuevo objeto Kotlin en el directorio
fake
llamadoFakeDataSource
. - 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.
- En el paquete
fake
, crea una nueva clase llamadaFakeMarsApiService
. - Configura la clase
FakeMarsApiService
para heredar de la interfazMarsApiService
.
class FakeMarsApiService : MarsApiService {
}
- Anula la función
getPhotos()
.
override suspend fun getPhotos(): List<MarsPhoto> {
}
- 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.
- En el directorio falso, crea una clase nueva llamada
NetworkMarsRepositoryTest
. - 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.
- Crea una instancia de
NetworkMarsPhotosRepository
y pasaFakeMarsApiService
como parámetromarsApiService
.
@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.
- Confirma que los datos que muestra el método
getMarsPhotos()
sean iguales aFakeDataSource.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.
Si colocas el cursor 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".
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.
- En
NetworkMarsRepositoryTest.kt
, modifica la funciónnetworkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
para que sea una expresión.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
- 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
.
- 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
.
- Crea una clase nueva en el directorio de
fake
llamadaFakeNetworkMarsPhotosRepository
. - Extiende esta clase con la interfaz
MarsPhotosRepository
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
- Anula la función
getMarsPhotos()
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
}
}
- Muestra
FakeDataSource.photosList
de la funcióngetMarsPhotos()
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return FakeDataSource.photosList
}
}
Cómo escribir la prueba de ViewModel
- Crea una clase nueva llamada
MarsViewModelTest
. - Crea una función llamada
marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
y anótala con@Test
.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
- 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{
}
- En el cuerpo de lambda de
runTest()
, crea una instancia deMarsViewModel
y pásale una instancia del repositorio falso que creaste.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest{
val marsViewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
}
- Confirma que el
marsUiState
de tu instancia deViewModel
coincida con el resultado de una llamada exitosa aMarsPhotosRepository.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:
- Crea un paquete nuevo en el directorio de prueba llamado
rules
. - En el directorio de reglas, crea una clase nueva llamada
TestDispatcherRule
. - Extiende el
TestDispatcherRule
conTestWatcher
. La claseTestWatcher
te permite realizar acciones en diferentes fases de ejecución de una prueba.
class TestDispatcherRule(): TestWatcher(){
}
- Crea un parámetro de constructor
TestDispatcher
paraTestDispatcherRule
.
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() {
}
- 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ónstarting()
de la claseTestWatcher
se ejecuta antes de que se ejecute una prueba determinada. Anula la funciónstarting()
.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
}
}
- Agrega una llamada a
Dispatchers.setMain()
y pasatestDispatcher
como argumento.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
}
- Una vez finalizada la ejecución de prueba, restablece el despachador
Main
anulando el métodofinished()
. Llama a la funciónDispatchers.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.
- Abre el archivo
MarsViewModelTest.kt
. - En la clase
MarsViewModelTest
, crea una instancia de la claseTestDispatcherRule
y asígnala a una propiedad de solo lecturatestDispatcher
.
class MarsViewModelTest {
val testDispatcher = TestDispatcherRule()
...
}
- Para aplicar esta regla a tus pruebas, agrega la anotación
@get:Rule
a la propiedadtestDispatcher
.
class MarsViewModelTest {
@get:Rule
val testDispatcher = TestDispatcherRule()
...
}
- 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.
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: