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.
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.
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
- Clique com o botão direito do mouse em com.example.marsphotos e selecione New > Package.
- Na caixa de diálogo, insira
data
. - Clique com o botão direito do mouse no pacote
data
e selecione New > Kotlin Class/File. - Na caixa de diálogo, selecione Interface e insira
MarsPhotosRepository
como o nome da interface. - Na interface
MarsPhotosRepository
, adicione uma função abstrata com o nomegetMarsPhotos()
, que retorna uma lista de objetosMarsPhoto
. Ela é conhecida como uma corrotina. Portanto, declare-a comsuspend
.
import com.example.marsphotos.model.MarsPhoto
interface MarsPhotosRepository {
suspend fun getMarsPhotos(): List<MarsPhoto>
}
- Abaixo da declaração da interface, crie uma classe com o nome
NetworkMarsPhotosRepository
para implementar a interfaceMarsPhotosRepository
. - 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.
- Na classe
NetworkMarsPhotosRepository
, substitua a função abstratagetMarsPhotos()
. Essa função retorna os dados da chamadaMarsApi.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.
- Abra o arquivo
ui/screens/MarsViewModel.kt
. - Role para baixo até encontrar o método
getMarsPhotos()
. - 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()
- 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
.
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.
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
- Clique com o botão direito do mouse no pacote
data
e selecione New > Kotlin Class/File. - Na caixa de diálogo, selecione Interface e insira
AppContainer
como o nome da interface. - Na interface
AppContainer
, adicione uma propriedade abstrata com o nomemarsPhotosRepository
do tipoMarsPhotosRepository
. - Abaixo da definição da interface, crie uma classe com o nome
DefaultAppContainer
que implemente a interfaceAppContainer
. - No
network/MarsApiService.kt
, mova o código das variáveisBASE_URL
,retrofit
eretrofitService
para a classeDefaultAppContainer
, 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)
}
}
- Para a variável
BASE_URL
, remova a palavra-chaveconst
. A remoção deconst
é necessária porqueBASE_URL
não é mais uma variável de nível superior e agora é uma propriedade da classeDefaultAppContainer
. Refatore-a para a concatenaçãobaseUrl
. - Para a variável
retrofitService
, adicione um modificador de visibilidadeprivate
. O modificadorprivate
foi adicionado porque a variávelretrofitService
é usada apenas dentro da classe pela propriedademarsPhotosRepository
, de modo que não precisa ser acessível fora da classe. - A classe
DefaultAppContainer
implementa a interfaceAppContainer
, então precisamos substituir a propriedademarsPhotosRepository
. Depois da variávelretrofitService
, 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)
}
}
- Abra o arquivo
data/MarsPhotosRepository.kt
. Agora, estamos transmitindoretrofitService
paraNetworkMarsPhotosRepository
, e você precisa modificar a classeNetworkMarsPhotosRepository
. - Na declaração de classe
NetworkMarsPhotosRepository
, adicione o parâmetro construtormarsApiService
, conforme mostrado no código abaixo.
import com.example.marsphotos.network.MarsApiService
class NetworkMarsPhotosRepository(
private val marsApiService: MarsApiService
) : MarsPhotosRepository {
- Na classe
NetworkMarsPhotosRepository
, na funçãogetMarsPhotos()
, mude a instrução de retorno para extrair dados demarsApiService
.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
- 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.
- 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.
- Clique com o botão direito do mouse em
com.example.marsphotos
e selecione New > Kotlin Class/File. - 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() {
}
- Na classe
MarsPhotosApplication
, declare uma variável com o nomecontainer
do tipoAppContainer
para armazenar o objetoDefaultAppContainer
. A variável é inicializada durante a chamada paraonCreate()
, de modo que precise ser marcada com o modificadorlateinit
.
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
- 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()
}
}
- É 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
.
- Na seção
application
, adicione o atributoandroid: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.
- Abra o arquivo
ui/screens/MarsViewModel.kt
. - Na declaração de classe de
MarsViewModel
, adicione um parâmetro de construtor privadomarsPhotosRepository
do tipoMarsPhotosRepository
. 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(){
- Na função
getMarsPhotos()
, remova a linha de código abaixo porquemarsPhotosRepository
agora está sendo preenchido na chamada do construtor.
val marsPhotosRepository = NetworkMarsPhotosRepository()
- Como o framework do Android não permite que um
ViewModel
receba valores no construtor quando criado, implementamos um objetoViewModelProvider.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.
- 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)
}
}
}
- Abra o arquivo
theme/MarsPhotosApp.kt
da funçãoMarsPhotosApp()
e atualize oviewModel()
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
.
- 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
- 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.
- 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.
- No diretório de teste, crie um pacote em
com.example.marsphotos
com o nomefake
. - Crie um novo objeto Kotlin com o nome
FakeDataSource
no diretóriofake
. - 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.
- No pacote
fake
, crie uma nova classe com o nomeFakeMarsApiService
. - Configure a classe
FakeMarsApiService
para herdar da interfaceMarsApiService
.
class FakeMarsApiService : MarsApiService {
}
- Substitua a função
getPhotos()
.
override suspend fun getPhotos(): List<MarsPhoto> {
}
- 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.
- No diretório simulado, crie uma nova classe com o nome
NetworkMarsRepositoryTest
. - 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.
- Crie uma instância do
NetworkMarsPhotosRepository
e transmita oFakeMarsApiService
como o parâmetromarsApiService
.
@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.
- Declare que os dados retornados pelo método
getMarsPhotos()
são iguais aFakeDataSource.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.
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):
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.
- No
NetworkMarsRepositoryTest.kt
, modifique a funçãonetworkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
para que seja uma expressão.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
- 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
.
- 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
.
- Crie uma nova classe com o nome
FakeNetworkMarsPhotosRepository
no diretóriofake
. - Estenda essa classe com a interface
MarsPhotosRepository
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
- Substitua a função
getMarsPhotos()
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
}
}
- Retorne
FakeDataSource.photosList
da funçãogetMarsPhotos()
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return FakeDataSource.photosList
}
}
Criar o teste do ViewModel
- Crie uma nova classe com o nome
MarsViewModelTest
. - Crie uma função com o nome
marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
e use a anotação@Test
.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
- 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{
}
- No corpo da função lambda de
runTest()
, crie uma instância doMarsViewModel
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()
)
}
- Declare que o
marsUiState
da instânciaViewModel
corresponde ao resultado de uma chamada bem-sucedida paraMarsPhotosRepository.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:
- Crie um novo pacote com o nome
rules
no diretório de teste. - No diretório de regras, crie uma nova classe com o nome
TestDispatcherRule
. - Estenda o
TestDispatcherRule
comTestWatcher
. A classeTestWatcher
permite realizar ações em diferentes fases de execução de um teste.
class TestDispatcherRule(): TestWatcher(){
}
- Crie um parâmetro construtor
TestDispatcher
para aTestDispatcherRule
.
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() {
}
- 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çãostarting()
da classeTestWatcher
é executada antes da execução de um determinado teste. Substitua a funçãostarting()
.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
}
}
- Adicione uma chamada para
Dispatchers.setMain()
, transmitindotestDispatcher
como um argumento.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
}
- Após a conclusão do teste, redefina o agente
Main
substituindo o métodofinished()
. Chame a funçãoDispatchers.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.
- Abra o arquivo
MarsViewModelTest.kt
. - Na classe
MarsViewModelTest
, instancie a classeTestDispatcherRule
e a atribua a uma propriedade somente leituratestDispatcher
.
class MarsViewModelTest {
val testDispatcher = TestDispatcherRule()
...
}
- Para aplicar essa regra aos testes, adicione a anotação
@get:Rule
à propriedadetestDispatcher
.
class MarsViewModelTest {
@get:Rule
val testDispatcher = TestDispatcherRule()
...
}
- 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:
- Acoplamento (link em inglês)