Adicionar repositório e DI manual

1. Antes de começar

Introdução

No codelab anterior, você aprendeu a acessar dados de um serviço da Web usando o ViewModel para extrair os URLs de fotos de Marte da rede usando um serviço de API. Embora essa abordagem funcione e seja simples de implementar, ela não escalona bem à medida que seu app cresce e precisa funcionar com mais de uma fonte de dados. Para resolver esse problema, as práticas recomendadas de arquitetura do Android recomendam separar as camadas da interface e de dados.

Neste codelab, você vai refatorar o app Mars Photos em camadas separadas de interface e de dados. Você vai aprender a implementar o padrão do repositório e usar a injeção de dependência. Ela cria uma estrutura de programação mais flexível para o desenvolvimento e testes.

Pré-requisitos

  • Saber extrair o JSON de um serviço REST da Web e analisar esses dados em objetos Kotlin usando as bibliotecas Retrofit e Serialization (kotlinx.serialization) (links em inglês).
  • Saber como usar um serviço da Web REST (link em inglês).
  • Sabe implementar corrotinas no seu app.

O que você vai aprender

  • Padrão do repositório.
  • Injeção de dependência.

O que você vai criar

  • Modificar o Mars Photos para separar o app em uma camada da interface e uma de dados.
  • Implementar o padrão do repositório ao separar a camada de dados.
  • Usar a injeção de dependência para criar uma base de código acoplada com flexibilidade.

O que você precisa

  • Um computador com um navegador da Web moderno, como a versão mais recente do Chrome.

Acessar o código inicial

Para começar, faça o download do código inicial:

Outra opção é clonar o repositório do GitHub:

$ 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

Procure o código no repositório do GitHub do Mars Photos (link em inglês).

2. Separar a camada da interface e a camada de dados

Por que usar camadas diferentes?

Separar o código em camadas diferentes deixa seu app mais escalonável, robusto e fácil de testar. Com várias camadas com limites claramente definidos, fica mais fácil para vários desenvolvedores trabalhar no mesmo app sem afetar negativamente uns aos outros.

A arquitetura de apps recomendada do Android declara que um app precisa ter pelo menos uma camada da interface e uma de dados.

Neste codelab, você se concentra na camada de dados e faz mudanças para que seu app siga as práticas recomendadas.

O que é uma camada de dados?

Uma camada de dados é responsável pela lógica de negócios do app e por gerar e salvar dados para ele. Ela expõe dados à camada da interface usando o padrão de fluxo de dados unidirecional. Os dados podem vir de várias fontes, como uma solicitação de rede, um banco de dados local ou um arquivo no dispositivo.

Um app pode até ter mais de uma fonte de dados. Quando o app é aberto, ele extrai dados de um banco de dados local no dispositivo, que é a primeira origem. Enquanto o app está em execução, ele faz uma solicitação de rede à segunda origem para extrair os dados mais recentes.

Com os dados em uma camada separada do código da interface, você pode fazer mudanças em uma parte do código sem afetar outra. Essa abordagem faz parte de um princípio de design chamado de separação de conceitos (link em inglês). Uma seção do código se concentra no próprio conceito e encapsula o funcionamento interno de outro código. O encapsulamento é uma forma de ocultar como o código funciona internamente em outras seções. Quando uma seção do código precisa interagir com outra, isso é feito em uma interface.

A preocupação da camada de interface é mostrar os dados fornecidos. A interface não extrai mais os dados porque esse é o papel da camada de dados.

A camada de dados é composta por um ou mais repositórios. Os repositórios contêm zero ou mais fontes de dados.

dbf927072d3070f0.png

As práticas recomendadas exigem que o app tenha um repositório para cada tipo de fonte de dados que ele usa.

Neste codelab, o app tem uma fonte de dados e, portanto, um repositório após a refatoração do código. Para este app, o repositório que extrai dados da Internet conclui as responsabilidades da fonte de dados. Ele faz uma solicitação de rede a uma API. Se a codificação da fonte de dados for mais complexa ou se outras fontes forem adicionadas, as responsabilidades da fonte serão encapsuladas em classes separadas e o repositório será responsável pelo gerenciamento de todas as fontes.

O que é um repositório?

Em geral, uma classe de repositório:

  • Expõe dados ao restante do app.
  • Centraliza as mudanças nos dados.
  • Resolve conflitos entre várias fontes de dados.
  • Abstrai fontes de dados do restante do app.
  • Contém lógica de negócios.

O app Mars Photos tem uma única fonte de dados, que é a chamada de API de rede. Ele não tem nenhuma lógica de negócios, porque está apenas extraindo dados. Os dados são expostos ao app pela classe de repositório, que abstrai a origem dos dados.

ff7a7cd039402747.png

3. Criar a camada de dados

Primeiro, você precisa criar a classe de repositório. O guia para Desenvolvedores Android afirma que as classes de repositório são nomeadas com base nos dados pelos quais são responsáveis. A convenção de nomenclatura do repositório é tipo de dados + repositório. No app, é MarsPhotosRepository.

Criar repositório

  1. Clique com o botão direito do mouse em com.example.marsphotos e selecione New > Package.
  2. Na caixa de diálogo, insira data.
  3. Clique com o botão direito do mouse no pacote data e selecione New > Kotlin Class/File.
  4. Na caixa de diálogo, selecione Interface e insira MarsPhotosRepository como o nome da interface.
  5. Na interface MarsPhotosRepository, adicione uma função abstrata com o nome getMarsPhotos(), que retorna uma lista de objetos MarsPhoto. Ela é conhecida como uma corrotina. Portanto, declare-a com suspend.
import com.example.marsphotos.model.MarsPhoto

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}
  1. Abaixo da declaração da interface, crie uma classe com o nome NetworkMarsPhotosRepository para implementar a interface MarsPhotosRepository.
  2. Adicione a interface MarsPhotosRepository à declaração de classe.

Como você não substituiu o método abstrato da interface, uma mensagem de erro vai ser mostrada. A próxima etapa corrige esse erro.

Captura de tela do Android Studio que mostra a interface MarsPhotosRepository e a classe NetworkMarsPhotosRepository

  1. Na classe NetworkMarsPhotosRepository, substitua a função abstrata getMarsPhotos(). Essa função retorna os dados da chamada MarsApi.retrofitService.getPhotos().
import com.example.marsphotos.network.MarsApi

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

Em seguida, você precisa atualizar o código ViewModel para usar o repositório a fim de acessar os dados, como as práticas recomendadas do Android sugerem.

  1. Abra o arquivo ui/screens/MarsViewModel.kt.
  2. Role para baixo até encontrar o método getMarsPhotos().
  3. Substitua a linha "val listResult = MarsApi.retrofitService.getPhotos()" por este código:
import com.example.marsphotos.data.NetworkMarsPhotosRepository

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

5313985852c151aa.png

  1. Execute o app. Observe que os resultados mostrados são iguais aos anteriores.

Em vez de ViewModel solicitar os dados diretamente da rede, o repositório fornece os dados. O ViewModel não faz mais referência direta ao código MarsApi. Diagrama de fluxo que mostra como a camada de dados é acessada diretamente do Viewmodel. Agora temos o repositório de fotos de Marte

Essa abordagem faz com que o código extraia os dados acoplados com flexibilidade de ViewModel. Isso permite que mudanças sejam feitas no ViewModel ou no repositório sem afetar negativamente o outro, desde que o repositório tenha uma função com o nome getMarsPhotos().

Agora, podemos fazer mudanças na implementação dentro do repositório sem afetar o autor da chamada. Em apps maiores, essa mudança pode oferecer suporte a vários autores de chamada.

4. Injeção de dependência

Muitas vezes, as classes exigem objetos de outras classes para funcionar. Quando uma classe exige outra, a classe obrigatória é chamada de dependência.

Nos exemplos abaixo, o objeto Car depende de um objeto Engine.

Uma classe pode receber esses objetos obrigatórios de duas maneiras. Uma delas é a classe instanciar o próprio objeto obrigatório.

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

Também é possível transmitir o objeto obrigatório como um 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()
}

Ter uma classe para instanciar os objetos necessários é fácil, mas essa abordagem torna o código inflexível e mais difícil de testar porque a classe e o objeto obrigatório estão rigidamente acoplados.

A classe de chamada precisa chamar o construtor do objeto, que é um detalhe de implementação. Se o construtor mudar, o código de chamada também vai precisar ser modificado.

Para tornar o código mais flexível e adaptável, a classe não pode instanciar os objetos de que depende. Os objetos de que ela depende precisam ser instanciados fora da classe e transmitidos a ela. Essa abordagem cria um código mais flexível, porque a classe não está mais fixada no código de um objeto específico. A implementação do objeto obrigatório pode mudar sem precisar modificar o código de chamada.

Continuando com o exemplo anterior, se um ElectricEngine for necessário, ele poderá ser criado e transmitido para a classe Car. A classe Car não precisa ser modificada de nenhuma forma.

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

A transmissão dos objetos necessários é chamada de injeção de dependência (DI, na sigla em inglês). Ela também é conhecida como inversão de controle (link em inglês).

Uma DI ocorre quando uma dependência é fornecida durante a execução em vez de ser fixada na classe de chamada.

Como implementar a injeção de dependência:

  • Ajuda a reutilizar o código. O código não depende de um objeto específico, o que permite maior flexibilidade.
  • Facilita a refatoração. O código é acoplado com flexibilidade, então a refatoração de uma seção do código não afeta outra.
  • Ajuda em testes. Objetos de teste podem ser transmitidos durante o teste.

Um exemplo de como a DI pode ajudar nos testes é ao testar o código de chamada da rede. Nesse caso, você está tentando testar se a chamada de rede é feita e se os dados são retornados. Se você tivesse que pagar toda vez que fizesse uma solicitação de rede durante um teste, poderia decidir pular o teste desse código, já que isso pode custar caro. Agora, imagine se pudéssemos simular a solicitação de rede para testes. Isso deixaria você mais feliz, além de economizar bastante. Para o teste, você pode transmitir um objeto de teste para o repositório que retorna dados simulados quando chamado sem realmente executar uma chamada de rede real. 1ea410d6670b7670.png

Queremos que o ViewModel seja testável, mas, atualmente, ele depende de um repositório que faz chamadas de rede reais. Ao testar com o repositório de produção real, ele faz muitas chamadas de rede. Para corrigir esse problema, em vez de ViewModel criar o repositório, precisamos de uma maneira de decidir e transmitir uma instância de repositório a ser usada dinamicamente para produção e teste.

Esse processo é feito pela implementação de um contêiner de aplicativos que fornece o repositório para MarsViewModel.

Um contêiner é um objeto que contém as dependências necessárias para o app. Essas dependências são usadas em todo o app. Portanto, elas precisam estar em um lugar comum que todas as atividades possam usar. É possível criar uma subclasse de Application e armazenar uma referência ao contêiner.

Criar um contêiner de aplicativos

  1. Clique com o botão direito do mouse no pacote data e selecione New > Kotlin Class/File.
  2. Na caixa de diálogo, selecione Interface e insira AppContainer como o nome da interface.
  3. Na interface AppContainer, adicione uma propriedade abstrata com o nome marsPhotosRepository do tipo MarsPhotosRepository. 7ed26c6dcf607a55.png
  4. Abaixo da definição da interface, crie uma classe com o nome DefaultAppContainer que implemente a interface AppContainer.
  5. No network/MarsApiService.kt, mova o código das variáveis BASE_URL, retrofit e retrofitService para a classe DefaultAppContainer, de modo que todos estejam localizados no contêiner que mantém as dependências.
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 a variável BASE_URL, remova a palavra-chave const. A remoção de const é necessária porque BASE_URL não é mais uma variável de nível superior e agora é uma propriedade da classe DefaultAppContainer. Refatore-a para a concatenação baseUrl.
  2. Para a variável retrofitService, adicione um modificador de visibilidade private. O modificador private foi adicionado porque a variável retrofitService é usada apenas dentro da classe pela propriedade marsPhotosRepository, de modo que não precisa ser acessível fora da classe.
  3. A classe DefaultAppContainer implementa a interface AppContainer, então precisamos substituir a propriedade marsPhotosRepository. Depois da variável retrofitService, adicione o código abaixo:
override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetworkMarsPhotosRepository(retrofitService)
}

A classe DefaultAppContainer concluída vai ficar assim:

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. Abra o arquivo data/MarsPhotosRepository.kt. Agora, estamos transmitindo retrofitService para NetworkMarsPhotosRepository, e você precisa modificar a classe NetworkMarsPhotosRepository.
  2. Na declaração de classe NetworkMarsPhotosRepository, adicione o parâmetro construtor marsApiService, conforme mostrado no código abaixo.
import com.example.marsphotos.network.MarsApiService

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
  1. Na classe NetworkMarsPhotosRepository, na função getMarsPhotos(), mude a instrução de retorno para extrair dados de marsApiService.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
  1. Remova a importação a seguir do arquivo MarsPhotosRepository.kt.
// Remove
import com.example.marsphotos.network.MarsApi

No arquivo network/MarsApiService.kt, removemos todo o código do objeto. Agora podemos excluir a declaração de objeto restante, porque ela não é mais necessária.

  1. Exclua o seguinte código:
object MarsApi {

}

5. Anexar um contêiner de aplicativos ao app

As etapas nesta seção conectam o objeto do aplicativo ao contêiner do aplicativo, conforme mostrado na figura abaixo.

92e7d7b79c4134f0.png

  1. Clique com o botão direito do mouse em com.example.marsphotos e selecione New > Kotlin Class/File.
  2. Na caixa de diálogo, insira MarsPhotosApplication. Essa classe é herdada do objeto do aplicativo. Portanto, você precisa adicioná-la à declaração da classe.
import android.app.Application

class MarsPhotosApplication : Application() {
}
  1. Na classe MarsPhotosApplication, declare uma variável com o nome container do tipo AppContainer para armazenar o objeto DefaultAppContainer. A variável é inicializada durante a chamada para onCreate(), de modo que precise ser marcada com o 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. O arquivo MarsPhotosApplication.kt completo vai ser semelhante a este 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. É necessário atualizar o manifesto do Android para que o app use a classe de aplicativo que você acabou de definir. Abra o arquivo manifests/AndroidManifest.xml.

759144e4e0634ed8.png

  1. Na seção application, adicione o atributo android:name com um valor do nome da classe do aplicativo ".MarsPhotosApplication".
<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

6. Adicionar um repositório ao ViewModel

Depois de concluir essas etapas, o ViewModel vai poder chamar o objeto do repositório para extrair dados de Marte.

7425864315cb5e6f.png

  1. Abra o arquivo ui/screens/MarsViewModel.kt.
  2. Na declaração de classe de MarsViewModel, adicione um parâmetro de construtor privado marsPhotosRepository do tipo MarsPhotosRepository. O valor do parâmetro do construtor vem do contêiner do aplicativo porque o app agora está usando a injeção de dependência.
import com.example.marsphotos.data.MarsPhotosRepository

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
  1. Na função getMarsPhotos(), remova a linha de código abaixo porque marsPhotosRepository agora está sendo preenchido na chamada do construtor.
val marsPhotosRepository = NetworkMarsPhotosRepository()
  1. Como o framework do Android não permite que um ViewModel receba valores no construtor quando criado, implementamos um objeto ViewModelProvider.Factory, o que nos permite contornar essa limitação.

O padrão de fábrica (link em inglês) é um padrão de criação usado para criar objetos. O objeto MarsViewModel.Factory usa o contêiner do aplicativo para recuperar o marsPhotosRepository e, em seguida, transmite esse repositório para o ViewModel quando o objeto ViewModel é criado.

  1. Abaixo da função getMarsPhotos(), digite o código do objeto complementar.

Um objeto complementar nos ajuda com a presença de uma única instância de um objeto usado por todos sem precisar criar uma nova instância de um objeto caro. Esse é um detalhe de implementação. Separá-lo permite fazer mudanças sem afetar outras partes do código do app.

O APPLICATION_KEY faz parte do objeto ViewModelProvider.AndroidViewModelFactory.Companion e é usado para encontrar o objeto MarsPhotosApplication do app, que tem a propriedade container de extrair o repositório para injeção de dependência.

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. Abra o arquivo theme/MarsPhotosApp.kt da função MarsPhotosApp() e atualize o viewModel() para usar a fábrica.
Surface(
            // ...
        ) {
            val marsViewModel: MarsViewModel =
   viewModel(factory = MarsViewModel.Factory)
            // ...
        }

Essa variável marsViewModel é preenchida pela chamada para a função viewModel() que recebe o MarsViewModel.Factory do objeto complementar como um argumento para criar o ViewModel.

  1. Execute o app para confirmar que ele ainda está funcionando como antes.

Parabéns por refatorar o app Mars Photos para usar um repositório e a injeção de dependência. Ao implementar uma camada de dados com um repositório, a interface e o código da fonte de dados foram separados para seguir as práticas recomendadas do Android.

Ao usar a injeção de dependência, é mais fácil testar o ViewModel. Seu app agora é mais flexível, robusto e está pronto para ser escalonado.

Depois de fazer essas melhorias, é hora de aprender a testá-las. Os testes mantêm seu código com o comportamento esperado e reduzem a possibilidade de introdução de bugs à medida que você continua trabalhando no código.

7. Configurar testes locais

Nas seções anteriores, você implementou um repositório para abstrair a interação direta com o serviço da API REST de forma independente do ViewModel. Essa prática permite testar pequenos trechos de código com uma finalidade limitada. Testes para pequenos pedaços de código com funcionalidade limitada são mais fáceis de criar, implementar e entender do que testes para grandes pedaços de código com várias funcionalidades.

Você também implementou o repositório usando interfaces, herança e injeção de dependência. Nas próximas seções, você vai aprender por que essas práticas recomendadas de arquitetura facilitam os testes. Além disso, você usou corrotinas do Kotlin para fazer a solicitação de rede. Testar códigos que usam corrotinas requer etapas adicionais para considerar a execução assíncrona do código. Vamos falar sobre essas etapas mais adiante neste codelab.

Adicionar as dependências dos testes locais

Adicione as dependências abaixo ao app/build.gradle.kts.

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

Criar o diretório de teste local

  1. Crie um diretório de teste local clicando com o botão direito do mouse no diretório src na visualização do projeto e selecionando New > Directory > test/java.
  2. Crie um novo pacote no diretório de teste com o nome com.example.marsphotos.

8. Criar dependências e dados simulados para testes

Nesta seção, você vai aprender como a injeção de dependência pode ajudar a criar testes locais. No início do codelab, você criou um repositório que depende de um serviço de API. Você modificou ViewModel para depender do repositório.

Cada teste local precisa avaliar apenas um recurso. Por exemplo, ao testar a funcionalidade do modelo de visualização, você não quer testar a funcionalidade do repositório ou do serviço da API. Da mesma forma, quando você testa o repositório, não quer testar o serviço da API.

Ao usar interfaces e, em seguida, usar a injeção de dependência para incluir classes herdadas dessas interfaces, é possível simular a funcionalidade dessas dependências usando classes simuladas criadas exclusivamente para fins de teste. A injeção de classes e fontes de dados simuladas para testes permite que o código seja testado de forma isolada, com capacidade de repetição e consistência.

A primeira coisa que você precisa é de dados falsos para usar nas classes simuladas criadas mais tarde.

  1. No diretório de teste, crie um pacote em com.example.marsphotos com o nome fake.
  2. Crie um novo objeto Kotlin com o nome FakeDataSource no diretório fake.
  3. Nesse objeto, crie uma propriedade definida como uma lista de objetos MarsPhoto. A lista não precisa ser longa, mas precisa conter pelo menos dois 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
       )
   )
}

Mencionamos no início deste codelab que o repositório depende do serviço de API. Para criar um teste de repositório, é preciso que haja um serviço de API simulado que retorne os dados falsos que você acabou de criar. Quando esse serviço de API simulado é transmitido ao repositório, ele recebe os dados falsos quando os métodos nesse serviço são chamados.

  1. No pacote fake, crie uma nova classe com o nome FakeMarsApiService.
  2. Configure a classe FakeMarsApiService para herdar da interface MarsApiService.
class FakeMarsApiService : MarsApiService {
}
  1. Substitua a função getPhotos().
override suspend fun getPhotos(): List<MarsPhoto> {
}
  1. Retorne a lista de fotos simuladas do método getPhotos().
override suspend fun getPhotos(): List<MarsPhoto> {
   return FakeDataSource.photosList
}

Se você ainda não estiver entendendo o propósito desta aula, tudo bem. Os usos dessas classes simuladas vão ser explicados em mais detalhes na próxima seção.

9. Criar um teste de repositório

Nesta seção, você vai testar o método getMarsPhotos() da classe NetworkMarsPhotosRepository. Esta seção esclarece o uso de classes simuladas e demonstra como testar corrotinas.

  1. No diretório simulado, crie uma nova classe com o nome NetworkMarsRepositoryTest.
  2. Crie um novo método com o nome networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() na classe recém-criada e use a anotação @Test.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}

Para testar o repositório, você vai precisar de uma instância do NetworkMarsPhotosRepository. Essa classe depende da interface MarsApiService. Aqui, você aproveita o serviço de API simulado da seção anterior.

  1. Crie uma instância do NetworkMarsPhotosRepository e transmita o FakeMarsApiService como o parâmetro marsApiService.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )
}

Ao transmitir o serviço de API simulado, todas as chamadas para a propriedade marsApiService no repositório resultam em uma chamada para o FakeMarsApiService. Ao transmitir classes simuladas para dependências, você pode controlar exatamente o que a dependência retorna. Essa abordagem garante que o código que você está testando não dependa de códigos não testados ou de APIs que podem sofrer mudanças ou ter problemas imprevisíveis. Essas situações podem fazer seu teste falhar, mesmo quando não há nada de errado com o código que você criou. As simulações ajudam a criar um ambiente de teste mais consistente, reduzir a inconsistência e facilitar testes concisos de uma única funcionalidade.

  1. Declare que os dados retornados pelo método getMarsPhotos() são iguais a FakeDataSource.photosList.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}

No ambiente de desenvolvimento integrado, a chamada do método getMarsPhotos() é sublinhada em vermelho.

2bd5f8999e0f3ec2.png

Se você passar o cursor sobre o método, vai encontrar uma dica indicando "Suspend function 'getMarsPhotos' should be called only from a coroutine or another suspend function" (a função de suspensão "getMarsPhotos" só pode ser chamada em uma corrotina ou em outra função de suspensão):

d2d3b6d770677ef6.png

No data/MarsPhotosRepository.kt, ao implementar getMarsPhotos() no NetworkMarsPhotosRepository, confirmamos que a função getMarsPhotos() é de suspensão.

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

Não esqueça que, ao chamar essa função do MarsViewModel, você chamou esse método de uma corrotina e a chamada foi feita de uma lambda transmitida para viewModelScope.launch(). Também é necessário chamar funções de suspensão, como getMarsPhotos(), de uma corrotina em um teste. No entanto, a abordagem é diferente. Confira na próxima seção como resolver esse problema.

Corrotinas de teste

Nesta seção, você vai modificar o teste networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() para que o corpo do método de teste seja executado em uma corrotina.

  1. No NetworkMarsRepositoryTest.kt, modifique a função networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() para que seja uma expressão.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
  1. Defina a expressão igual à função runTest(). Esse método espera uma função lambda.
...
import kotlinx.coroutines.test.runTest
...

@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
    runTest {}

A biblioteca de testes de corrotinas fornece a função runTest(). A função usa o método que você transmitiu na lambda e o executa de TestScope, que herda de CoroutineScope.

  1. Mova o conteúdo da função de teste para a função lambda.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
   runTest {
       val repository = NetworkMarsPhotosRepository(
           marsApiService = FakeMarsApiService()
       )
       assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
   }

A linha vermelha em getMarsPhotos() agora desaparece. Se você executar esse teste, ele vai ser aprovado.

10. Criar um teste do ViewModel

Nesta seção, você vai criar um teste para a função getMarsPhotos() do MarsViewModel. O MarsViewModel depende do MarsPhotosRepository. Para criar esse teste, você precisa criar um MarsPhotosRepository simulado. Além disso, há algumas outras etapas a serem consideradas para corrotinas além de usar o método runTest().

Criar o repositório simulado

O objetivo desta etapa é criar uma classe simulada herdada da interface MarsPhotosRepository e que substitui a função getMarsPhotos() para retornar dados simulados. Essa abordagem é semelhante àquela que você usou com o serviço de API simulado. A diferença é que essa classe estende a interface MarsPhotosRepository em vez da MarsApiService.

  1. Crie uma nova classe com o nome FakeNetworkMarsPhotosRepository no diretório fake.
  2. Estenda essa classe com a interface MarsPhotosRepository.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
  1. Substitua a função getMarsPhotos().
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
   }
}
  1. Retorne FakeDataSource.photosList da função getMarsPhotos().
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return FakeDataSource.photosList
   }
}

Criar o teste do ViewModel

  1. Crie uma nova classe com o nome MarsViewModelTest.
  2. Crie uma função com o nome marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() e use a anotação @Test.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
  1. Transforme essa função em uma expressão definida como o resultado do método runTest() para garantir que o teste seja executado por uma corrotina, assim como o teste de repositório na seção anterior.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
    }
  1. No corpo da função lambda de runTest(), crie uma instância do MarsViewModel e transmita a ele uma instância do repositório simulado que você criou.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
        val marsViewModel = MarsViewModel(
            marsPhotosRepository = FakeNetworkMarsPhotosRepository()
         )
    }
  1. Declare que o marsUiState da instância ViewModel corresponde ao resultado de uma chamada bem-sucedida para 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
       )
   }

Se você tentar executar esse teste como está, vai ocorrer uma falha. O erro será semelhante ao exemplo abaixo:

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

Não esqueça que o MarsViewModel chama o repositório usando viewModelScope.launch(). Esta instrução inicia uma nova corrotina no agente padrão de corrotinas, conhecido como Main. O agente Main encapsula a linha de execução de interface do Android. O motivo do erro anterior é que a linha de execução de interface do Android não está disponível em um teste de unidade. Os testes de unidade são executados na estação de trabalho, e não em dispositivos Android ou no emulador. Se o código em um teste de unidade local referenciar o agente Main, uma exceção, como a mostrada acima, vai ser gerada quando os testes de unidade forem executados. Para resolver esse problema, é preciso definir explicitamente o agente padrão ao executar testes de unidade. Consulte a próxima seção para saber como fazer isso.

Criar um agente de teste

Como o agente Main está disponível apenas em um contexto de interface, é necessário substituí-lo por um agente compatível com teste de unidade. A biblioteca de corrotinas do Kotlin oferece um agente com essa finalidade, conhecido como TestDispatcher. O TestDispatcher precisa ser usado em vez do agente Main para qualquer teste de unidade em que uma nova corrotina é feita, como acontece com a função getMarsPhotos() do modelo de visualização.

Para substituir o agente Main por um TestDispatcher em todos os casos, use a função Dispatchers.setMain(). Você pode usar a função Dispatchers.resetMain() para redefinir o agente de linhas de execução de volta para o agente Main. Para evitar a duplicação do código que substitui o agente Main em cada teste, é possível extraí-lo para uma regra de teste do JUnit. Uma TestRule fornece uma maneira de controlar o ambiente em que um teste é executado. Uma TestRule permite adicionar outras verificações, realizar a configuração ou a limpeza necessárias para testes, ou observar a execução do teste para informar o resultado em outro lugar. Elas podem ser facilmente compartilhadas entre classes de teste.

Crie uma classe dedicada para criar a TestRule e substituir o agente Main. Para implementar uma TestRule personalizada, siga as etapas abaixo:

  1. Crie um novo pacote com o nome rules no diretório de teste.
  2. No diretório de regras, crie uma nova classe com o nome TestDispatcherRule.
  3. Estenda o TestDispatcherRule com TestWatcher. A classe TestWatcher permite realizar ações em diferentes fases de execução de um teste.
class TestDispatcherRule(): TestWatcher(){

}
  1. Crie um parâmetro construtor TestDispatcher para a TestDispatcherRule.

Esse parâmetro permite o uso de diferentes agentes, como StandardTestDispatcher. Esse parâmetro do construtor precisa ter um valor padrão definido como uma instância do objeto UnconfinedTestDispatcher. A classe UnconfinedTestDispatcher (link em inglês) herda da classe TestDispatcher e especifica que as tarefas não podem ser executadas em uma ordem específica. Esse padrão de execução é bom para testes simples, já que as corrotinas são processadas automaticamente. Ao contrário de UnconfinedTestDispatcher, a classe StandardTestDispatcher permite controle total da execução das corrotinas. Esse método é indicado para testes complicados que exigem uma abordagem manual, mas não é necessário para os testes deste codelab.

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

}
  1. O objetivo principal desta regra de teste é substituir o agente Main por um agente de teste antes que um teste comece a ser executado. A função starting() da classe TestWatcher é executada antes da execução de um determinado teste. Substitua a função starting().
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        
    }
}
  1. Adicione uma chamada para Dispatchers.setMain(), transmitindo testDispatcher como um argumento.
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
}
  1. Após a conclusão do teste, redefina o agente Main substituindo o método finished(). Chame a função Dispatchers.resetMain().
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

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

A regra TestDispatcherRule está pronta para ser reutilizada.

  1. Abra o arquivo MarsViewModelTest.kt.
  2. Na classe MarsViewModelTest, instancie a classe TestDispatcherRule e a atribua a uma propriedade somente leitura testDispatcher.
class MarsViewModelTest {
    
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. Para aplicar essa regra aos testes, adicione a anotação @get:Rule à propriedade testDispatcher.
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. Execute o teste novamente. Confirme se ele foi aprovado dessa vez.

11. Acessar o código da solução

Para fazer o download do código do codelab concluído, use estes 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

Se preferir, você pode fazer o download do repositório como um arquivo ZIP, descompactar e abrir no Android Studio.

Você pode conferir o código da solução deste codelab no GitHub (link em inglês).

12. Conclusão

Parabéns por concluir este codelab e refatorar o app Mars Photos para implementar o padrão do repositório e a injeção de dependência.

O código do app agora está seguindo as práticas recomendadas do Android para a camada de dados, o que significa que é mais flexível, robusto e fácil de escalonar.

Essas mudanças também facilitam os testes do app. Esse benefício é muito importante, porque o código pode continuar a evoluir e garantir que ele ainda se comporte da maneira esperada.

Não se esqueça de compartilhar seu trabalho nas redes sociais com a hashtag #AndroidBasics.

13. Saiba mais

Documentação do desenvolvedor Android:

Outro: