1. Avant de commencer
Introduction
Dans l'atelier de programmation précédent, vous avez appris à extraire des données d'un service Web en faisant en sorte que ViewModel
récupère les URL des photos de Mars à partir du réseau à l'aide d'un service d'API. Même si cette approche fonctionne et qu'elle est simple à appliquer, elle ne s'adapte pas bien à mesure que votre application se développe et doit fonctionner avec plusieurs sources de données. Pour cela, il est recommandé de séparer la couche d'interface utilisateur de la couche de données, comme le suggèrent les bonnes pratiques liées à l'architecture Android.
Dans cet atelier de programmation, vous allez refactoriser l'application Mars Photos de façon à séparer ces deux couches. Vous allez apprendre à implémenter le schéma de dépôt et à injecter des dépendances. Cette injection crée une structure de codage plus flexible qui facilite le développement et les tests.
Conditions préalables
- Savoir récupérer des fichiers JSON à partir d'un service Web REST et analyser ces données dans des objets Kotlin à l'aide des bibliothèques Retrofit et Serialization (kotlinx.Serialization).
- Savoir utiliser un service Web REST
- Savoir implémenter des coroutines dans votre application
Points abordés
- Schéma de dépôt
- Injection de dépendances
Objectifs de l'atelier
- Modifier l'application Mars Photos pour la diviser en une couche d'interface utilisateur et une couche de données
- Implémenter le schéma de dépôt, tout en séparant la couche de données
- Injecter des dépendances pour créer un codebase faiblement couplé
Ce dont vous avez besoin
- Un ordinateur doté d'un navigateur Web récent (par exemple, la dernière version de Chrome)
Télécharger le code de démarrage
Pour commencer, téléchargez le code de démarrage :
Vous pouvez également cloner le dépôt GitHub du code :
$ 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
Vous pouvez parcourir le code dans le dépôt GitHub Mars Photos
.
2. Séparer la couche d'interface utilisateur de la couche de données
Pourquoi différentes couches ?
En séparant le code en différentes couches, vous rendez votre application plus adaptable, plus robuste et plus facile à tester. Le fait d'avoir des couches distinctes avec des limites clairement définies permet également à plusieurs développeurs de travailler sur la même application sans que cela nuise aux uns ni aux autres.
Selon l'architecture d'application recommandée d'Android, une application doit avoir au moins une couche d'UI et une couche de données.
Dans cet atelier de programmation, vous allez vous concentrer sur la couche de données et apporter des modifications de sorte que votre application respecte les bonnes pratiques recommandées.
Qu'est-ce qu'une couche de données ?
Une couche de données est responsable de la logique métier de votre application, ainsi que de la collecte et de l'enregistrement des données pour cette application. Elle présente les données à la couche d'UI à l'aide du modèle Flux de données unidirectionnel. Les données peuvent provenir de plusieurs sources, comme une requête réseau, une base de données locale ou un fichier sur l'appareil.
Une application peut même avoir plusieurs sources de données. Lorsqu'elle s'ouvre, elle récupère les données d'une base de données locale sur l'appareil, laquelle constitue la première source. Quand elle est ensuite en cours d'exécution, elle envoie une requête réseau à la deuxième source pour récupérer les données les plus récentes.
En plaçant les données dans une couche distincte du code de l'interface utilisateur, vous pouvez apporter des modifications dans une partie du code sans affecter les autres. Cette approche s'inscrit dans un principe de conception appelé séparation des tâches. Une section de code se concentre sur sa propre tâche et encapsule son fonctionnement interne à partir d'un autre code. L'encapsulation consiste à masquer le fonctionnement interne du code à partir d'autres sections de code. Lorsqu'une section de code doit interagir avec une autre, elle le fait via une interface.
La tâche de la couche d'UI est d'afficher les données fournies. L'UI ne récupère plus les données, car c'est la tâche de la couche de données.
La couche de données est composée d'un ou de plusieurs dépôts. Les dépôts eux-mêmes contiennent plusieurs sources de données, voire aucune.
Conformément aux bonnes pratiques, l'application doit avoir un dépôt pour chaque type de source de données qu'elle utilise.
Dans cet atelier de programmation, l'application possède une source de données et a donc un seul dépôt une fois que vous avez refactorisé le code. Pour cette application, le dépôt qui récupère les données sur Internet remplit les responsabilités de la source de données. Pour cela, il envoie une requête réseau à une API. Si le codage de la source de données est plus complexe ou que d'autres sources de données sont ajoutées, les responsabilités de ces sources sont encapsulées dans des classes dédiées distinctes, et le dépôt est responsable de la gestion de toutes les sources de données.
Qu'est-ce qu'un dépôt ?
En général, une classe Repository (Dépôt) a les caractéristiques suivantes :
- Présenter les données au reste de l'application
- Centraliser les modifications apportées aux données
- Résoudre les conflits entre plusieurs sources de données
- Extraire les sources de données du reste de l'application
- Contenir une logique métier
L'application Mars Photos a une seule source de données : l'appel d'API réseau. Elle n'a aucune logique métier, car elle ne fait que récupérer des données. Les données sont présentées à l'application via la classe Repository, ignorant ainsi la source des données.
3. Créer une couche de données
Pour commencer, vous devez créer la classe Repository. Selon le guide du développeur Android, les classes Repository sont nommées d'après les données dont elles sont responsables. La convention de dénomination des dépôts prévoit de les nommer sur le modèle suivant : Type de données+Repository. Dans votre application, le nom est MarsPhotosRepository
.
Créer un dépôt
- Effectuez un clic droit sur com.example.marsphotos, puis sélectionnez New > Package (Nouveau > Package).
- Dans la boîte de dialogue, saisissez
data
. - Effectuez un clic droit sur le package
data
, puis sélectionnez New > Kotlin Class/File (Nouveau > Classe/Fichier Kotlin). - Dans la boîte de dialogue, sélectionnez Interface et saisissez
MarsPhotosRepository
en nom d'interface. - Dans l'interface
MarsPhotosRepository
, ajoutez une fonction abstraite intituléegetMarsPhotos()
, qui renvoie une liste d'objetsMarsPhoto
. Comme elle est appelée à partir d'une coroutine, déclarez-la avecsuspend
.
import com.example.marsphotos.model.MarsPhoto
interface MarsPhotosRepository {
suspend fun getMarsPhotos(): List<MarsPhoto>
}
- Sous la déclaration de l'interface, créez une classe intitulée
NetworkMarsPhotosRepository
pour implémenter l'interfaceMarsPhotosRepository
. - Ajoutez l'interface
MarsPhotosRepository
à la déclaration de classe.
Comme vous n'avez pas ignoré la méthode abstraite de l'interface, un message d'erreur s'affiche (l'erreur en question est corrigée à la prochaine étape).
- Dans la classe
NetworkMarsPhotosRepository
, ignorez la fonction abstraitegetMarsPhotos()
. Cette fonction renvoie les données de l'appel àMarsApi.retrofitService.getPhotos()
.
import com.example.marsphotos.network.MarsApi
class NetworkMarsPhotosRepository() : MarsPhotosRepository {
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return MarsApi.retrofitService.getPhotos()
}
}
Vous devez ensuite modifier le code ViewModel
pour utiliser le dépôt afin de récupérer les données, comme le suggèrent les bonnes pratiques Android.
- Ouvrez le fichier
ui/screens/MarsViewModel.kt
. - Faites défiler la page vers le bas jusqu'à la méthode
getMarsPhotos()
. - Remplacez la ligne "
val listResult = MarsApi.retrofitService.getPhotos()
" par le code suivant :
import com.example.marsphotos.data.NetworkMarsPhotosRepository
val marsPhotosRepository = NetworkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()
- Exécutez l'application. Notez que les résultats affichés sont identiques aux résultats précédents.
Au lieu que le ViewModel
envoie directement la requête réseau pour récupérer les données, celles-ci sont fournies par le dépôt. Le ViewModel
ne mentionne plus directement le code MarsApi
.
Avec cette approche, le code peut récupérer les données faiblement couplées à partir de ViewModel
. Le couplage faible permet d'apporter des modifications au ViewModel
ou au dépôt sans nuire à l'autre, tant que le dépôt comporte une fonction intitulée getMarsPhotos()
.
Nous pouvons désormais modifier l'implémentation dans le dépôt sans affecter l'appelant. Pour les applications plus volumineuses, ce changement peut concerner plusieurs appelants.
4. Injection de dépendances
Souvent, les classes ont besoin d'objets d'autres classes pour fonctionner. Lorsqu'une classe nécessite une autre classe, cette dernière est appelée dépendance.
Dans les exemples ci-dessous, l'objet Car
dépend d'un objet Engine
.
Une classe peut obtenir ces objets de deux façons. La première consiste pour la classe à instancier elle-même l'objet requis.
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 seconde consiste pour la classe à transmettre l'objet requis en tant qu'argument.
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()
}
Même si une classe peut instancier facilement les objets requis, cette approche rend le code inflexible et plus difficile à tester, car la classe et les objets requis sont fortement couplés.
La classe appelante doit appeler le constructeur de l'objet (ce qui est un détail de l'implémentation). Si le constructeur change, le code d'appel doit également changer.
Pour rendre le code plus flexible et adaptable, une classe ne doit pas instancier les objets dont elle dépend, lesquels doivent être instanciés en dehors de la classe, puis transmis. Cette approche permet d'avoir un code plus flexible, car la classe n'est plus codée en dur dans un objet particulier. L'implémentation de l'objet requis peut changer sans qu'il soit nécessaire de modifier le code d'appel.
Reprenons l'exemple précédent. Si vous avez besoin d'un ElectricEngine
, vous pouvez le créer et le transmettre à la classe Car
. La classe Car
n'a pas besoin d'être modifiée de quelque manière que ce soit.
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()
}
La transmission des objets requis est appelée injection de dépendances (on parle également d'inversion du contrôle).
Cette injection intervient quand une dépendance est fournie au moment de l'exécution au lieu d'être codée en dur dans la classe appelante.
L'implémentation de l'injection de dépendances présente plusieurs avantages :
- Aide à la réutilisation du code : le code ne dépend pas d'un objet spécifique, ce qui offre une plus grande flexibilité.
- Facilite la refactorisation : le code étant faiblement couplé, la refactorisation d'une section de code n'a pas d'incidence sur une autre section.
- Aide à la réalisation de tests : les objets de test peuvent être transmis lors des tests.
Pour illustrer ce troisième point, prenons l'exemple d'un test du code d'appel réseau. Pour ce test, vous essayez de vérifier que l'appel réseau est vraiment effectué et que les données sont renvoyées. Si vous devez payer chaque fois que vous envoyez une requête réseau lors d'un test, vous pouvez choisir de ne pas tester ce code, car cela peut coûter cher. Imaginez maintenant que nous pouvons simuler la requête réseau pour le test. Dans quelle mesure cela vous rend-il plus heureux (et plus riche) ? À des fins de test, vous pouvez transmettre au dépôt un objet de test qui affiche des données fictives quand il est appelé sans réaliser de véritable appel réseau.
Nous souhaitons que le ViewModel
puisse être testé, mais il dépend actuellement d'un dépôt qui effectue des appels réseau réels. Lors des tests avec le dépôt de production réel, il effectue de nombreux appels réseau. Pour résoudre ce problème, au lieu que le ViewModel
crée le dépôt, nous devons trouver un moyen de choisir et transmettre une instance de dépôt à utiliser pour la production et le test de manière dynamique.
Ce processus est effectué en implémentant un conteneur d'application qui fournit le dépôt à MarsViewModel
.
Un conteneur est un objet qui contient les dépendances requises par l'application. Ces dépendances étant utilisées dans l'ensemble de l'application, elles doivent figurer à un emplacement commun que toutes les activités peuvent utiliser. Vous pouvez créer une sous-classe de la classe Application et stocker une référence au conteneur.
Créer un conteneur d'application
- Effectuez un clic droit sur le package
data
, puis sélectionnez New > Kotlin Class/File (Nouveau > Classe/Fichier Kotlin). - Dans la boîte de dialogue, sélectionnez Interface, puis saisissez
AppContainer
comme nom d'interface. - Dans l'interface
AppContainer
, ajoutez une propriété abstraite intituléemarsPhotosRepository
de typeMarsPhotosRepository
. - Sous la définition de l'interface, créez une classe intitulée
DefaultAppContainer
qui implémente l'interfaceAppContainer
. - À partir de
network/MarsApiService.kt
, déplacez le code des variablesBASE_URL
,retrofit
etretrofitService
dans la classeDefaultAppContainer
afin qu'elles figurent toutes dans le conteneur qui gère les dépendances.
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)
}
}
- Pour la variable
BASE_URL
, supprimez le mot cléconst
. La suppression deconst
est nécessaire, carBASE_URL
est désormais une propriété de la classeDefaultAppContainer
et non une variable de niveau supérieur comme elle l'était auparavant. Refactorisez-la en camelcasebaseUrl
. - Pour la variable
retrofitService
, ajoutez un modificateur de visibilitéprivate
. Le modificateurprivate
est ajouté, car la variableretrofitService
n'est utilisée que dans la classe par la propriétémarsPhotosRepository
et n'a donc pas besoin d'être accessible en dehors de la classe. - La classe
DefaultAppContainer
implémente l'interfaceAppContainer
. Nous devons donc ignorer la propriétémarsPhotosRepository
. Après la variableretrofitService
, ajoutez le code suivant :
override val marsPhotosRepository: MarsPhotosRepository by lazy {
NetworkMarsPhotosRepository(retrofitService)
}
Une fois terminée, la classe DefaultAppContainer
doit se présenter comme suit :
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)
}
}
- Ouvrez le fichier
data/MarsPhotosRepository.kt
. Nous transmettons maintenantretrofitService
àNetworkMarsPhotosRepository
, et vous devez modifier la classeNetworkMarsPhotosRepository
. - Dans la déclaration de la classe
NetworkMarsPhotosRepository
, ajoutez le paramètre de constructeurmarsApiService
, comme indiqué dans le code ci-dessous.
import com.example.marsphotos.network.MarsApiService
class NetworkMarsPhotosRepository(
private val marsApiService: MarsApiService
) : MarsPhotosRepository {
- Dans la classe
NetworkMarsPhotosRepository
, dans la fonctiongetMarsPhotos()
, modifiez l'instruction return pour récupérer les données demarsApiService
.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
- Supprimez l'importation suivante du fichier
MarsPhotosRepository.kt
.
// Remove
import com.example.marsphotos.network.MarsApi
À partir du fichier network/MarsApiService.kt
, nous avons déplacé tout le code hors de l'objet. Nous pouvons désormais supprimer la déclaration d'objet restante, car elle n'est plus nécessaire.
- Supprimez le code suivant :
object MarsApi {
}
5. Associer un conteneur d'application à l'application
La procédure décrite dans cette section permet d'associer l'objet d'application au conteneur d'application, comme illustré ci-dessous.
- Effectuez un clic droit sur
com.example.marsphotos
, puis sélectionnez New > Kotlin Class/File (Nouveau > Classe/Fichier Kotlin). - Dans la boîte de dialogue, saisissez
MarsPhotosApplication
. Cette classe hérite de l'objet d'application. Vous devez donc l'ajouter à la déclaration de classe.
import android.app.Application
class MarsPhotosApplication : Application() {
}
- Dans la classe
MarsPhotosApplication
, déclarez une variable intituléecontainer
de typeAppContainer
pour stocker l'objetDefaultAppContainer
. La variable étant initialisée lors de l'appel àonCreate()
, vous devez la marquer avec le modificateurlateinit
.
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
- Le fichier
MarsPhotosApplication.kt
complet devrait se présenter comme suit :
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()
}
}
- Vous devez modifier le fichier manifeste Android afin que l'application utilise la classe que vous venez de définir. Ouvrez le fichier
manifests/AndroidManifest.xml
.
- Dans la section
application
, ajoutez l'attributandroid:name
avec la valeur".MarsPhotosApplication"
en nom de la classe Application.
<application
android:name=".MarsPhotosApplication"
android:allowBackup="true"
...
</application>
6. Ajouter un dépôt au ViewModel
Une fois ces étapes effectuées, le ViewModel
peut appeler l'objet de dépôt pour récupérer les données de Mars.
- Ouvrez le fichier
ui/screens/MarsViewModel.kt
. - Dans la déclaration de classe pour
MarsViewModel
, ajoutez un paramètre de constructeur privémarsPhotosRepository
de typeMarsPhotosRepository
. La valeur du paramètre de constructeur provient du conteneur d'application, car l'application utilise désormais l'injection de dépendances.
import com.example.marsphotos.data.MarsPhotosRepository
class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
- Dans la fonction
getMarsPhotos()
, supprimez la ligne de code ci-dessous, carmarsPhotosRepository
est désormais renseigné dans l'appel du constructeur.
val marsPhotosRepository = NetworkMarsPhotosRepository()
- Comme le framework Android ne permet pas de transmettre des valeurs à un
ViewModel
dans le constructeur lors de sa création, nous implémentons un objetViewModelProvider.Factory
qui nous donne la possibilité de contourner cette limitation.
Le modèle Factory est utilisé pour créer des objets. L'objet MarsViewModel.Factory
utilise le conteneur d'application pour récupérer marsPhotosRepository
, puis transmet ce dépôt au ViewModel
lorsque l'objet ViewModel
est créé.
- Sous la fonction
getMarsPhotos()
, saisissez le code de l'objet associé.
Un objet associé nous aide en n'ayant qu'une seule instance d'objet utilisée par tous, sans qu'il soit nécessaire d'en créer une autre pour un objet coûteux. Ceci est un détail de l'implémentation. En le séparant, nous pouvons ainsi apporter des modifications sans impacter les autres parties du code de l'application.
APPLICATION_KEY
fait partie de l'objet ViewModelProvider.AndroidViewModelFactory.Companion
et permet de rechercher l'objet MarsPhotosApplication
de l'application, dont la propriété container
permet de récupérer le dépôt utilisé pour l'injection de dépendances.
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)
}
}
}
- Ouvrez le fichier
theme/MarsPhotosApp.kt
, dans la fonctionMarsPhotosApp()
, puis mettez à jourviewModel()
pour utiliser la fabrique.
Surface(
// ...
) {
val marsViewModel: MarsViewModel =
viewModel(factory = MarsViewModel.Factory)
// ...
}
Cette variable marsViewModel
est renseignée par l'appel à la fonction viewModel()
qui reçoit MarsViewModel.Factory
de l'objet associé en tant qu'argument pour créer le ViewModel
.
- Exécutez l'application pour vérifier qu'elle fonctionne toujours comme avant.
Bravo ! Vous avez refactorisé l'application Mars Photos pour utiliser un dépôt et l'injection de dépendances. L'implémentation d'une couche de données avec un dépôt a permis de séparer le code de l'UI du code de la source de données, conformément aux bonnes pratiques Android.
Avec l'injection de dépendances, le ViewModel
peut être testé plus facilement. Votre application est désormais plus flexible, robuste et prête à évoluer.
Après avoir apporté ces améliorations, découvrez maintenant comment les tester. Grâce aux tests, votre code se comporte comme prévu et, à mesure que vous travaillez dessus, vous réduisez les risques d'introduire des bugs.
7. Configurer des tests en local
Dans les sections précédentes, vous avez implémenté un dépôt pour éliminer du ViewModel
toute interaction directe avec le service d'API REST. De cette façon, vous pouvez tester de petites parties du code ayant un objectif limité. Il est plus facile de créer, d'implémenter et de comprendre des tests concernant de petites parties de code avec des fonctionnalités limitées que des tests écrits pour des parties de code volumineuses incluant plusieurs fonctionnalités.
Vous avez également implémenté le dépôt en exploitant les interfaces, l'héritage et l'injection de dépendances. Dans les sections suivantes, vous allez découvrir en quoi ces bonnes pratiques architecturales facilitent les tests. Vous avez également utilisé des coroutines Kotlin pour envoyer la requête réseau. Pour tester le code qui utilise des coroutines, des étapes supplémentaires sont nécessaires pour tenir compte de l'exécution asynchrone du code. Ces étapes seront abordées plus tard dans cet atelier de programmation.
Ajouter les dépendances pour les tests en local
Ajoutez les dépendances suivantes à app/build.gradle.kts
.
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
Créer le répertoire de tests en local
- Créez un répertoire de tests en local en effectuant un clic droit sur le répertoire src dans la vue de projet, puis en sélectionnant Nouveau > Répertoire > test/java.
- Créez un package dans le répertoire de test intitulé
com.example.marsphotos
.
8. Créer des dépendances et des données fictives pour les tests
Dans cette section, vous allez découvrir comment l'injection de dépendances peut vous aider à écrire des tests en local. Plus tôt dans cet atelier de programmation, vous avez créé un dépôt qui dépend d'un service d'API. Vous avez ensuite modifié le ViewModel
pour qu'il dépende du dépôt.
Chaque test en local ne doit porter que sur une seule chose. Par exemple, lorsque vous testez la fonctionnalité du modèle de vue, vous ne voulez pas tester celle du dépôt ni le service d'API. De même, lorsque vous testez le dépôt, vous ne voulez pas tester le service d'API.
En utilisant des interfaces, puis en injectant des dépendances pour inclure les classes qui héritent de ces interfaces, vous pouvez simuler le fonctionnement de ces dépendances avec les classes fictives créées uniquement à des fins de test. En injectant des sources de données et classes fictives à des fins de test, vous pouvez tester le code de façon isolée, reproductible et cohérente.
La première chose dont vous avez besoin, ce sont des données fictives à utiliser dans des classes fictives que vous allez créer plus tard.
- Dans le répertoire de test, créez un package intitulé
fake
souscom.example.marsphotos
. - Créez un objet Kotlin intitulé
FakeDataSource
dans le répertoirefake
. - Dans cet objet, créez une propriété définie sur une liste d'objets
MarsPhoto
. La liste ne doit pas nécessairement être longue, mais elle doit contenir au moins deux objets.
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
)
)
}
Comme mentionné précédemment dans cet atelier, le dépôt dépend du service d'API. Pour créer un test de dépôt, vous devez disposer d'un service d'API fictif qui renvoie les données fictives que vous venez de créer. Lorsque ce service est transmis dans le dépôt, celui-ci reçoit les données fictives lorsque les méthodes du service sont appelées.
- Dans le package
fake
, créez une classe intituléeFakeMarsApiService
. - Configurez la classe
FakeMarsApiService
pour qu'elle hérite de l'interfaceMarsApiService
.
class FakeMarsApiService : MarsApiService {
}
- Ignorez la fonction
getPhotos()
.
override suspend fun getPhotos(): List<MarsPhoto> {
}
- Renvoyez la liste des fausses photos à partir de la méthode
getPhotos()
.
override suspend fun getPhotos(): List<MarsPhoto> {
return FakeDataSource.photosList
}
N'oubliez pas que ce n'est pas grave si l'objectif de cette classe ne vous paraît pas encore clair. Les utilisations de cette classe fictive sont expliquées plus en détail dans la prochaine section.
9. Écrire un test de dépôt
Dans cette section, vous allez tester la méthode getMarsPhotos()
de la classe NetworkMarsPhotosRepository
. Cette section clarifie l'utilisation des classes fictives et montre comment tester les coroutines.
- Dans le répertoire fictif, créez une classe intitulée
NetworkMarsRepositoryTest
. - Dans la classe que vous venez de créer, créez une méthode intitulée
networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
et annotez-la avec@Test
.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}
Pour tester le dépôt, vous aurez besoin d'une instance de NetworkMarsPhotosRepository
. Rappelez-vous que cette classe dépend de l'interface MarsApiService
. C'est ici que vous allez exploiter le service d'API fictif de la section précédente.
- Créez une instance de
NetworkMarsPhotosRepository
et transmettezFakeMarsApiService
en tant que paramètremarsApiService
.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
val repository = NetworkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)
}
En transmettant le service d'API fictif, tous les appels à la propriété marsApiService
dans le dépôt entraînent un appel à FakeMarsApiService
. En transmettant les classes fictives pour les dépendances, vous pouvez contrôler exactement ce que renvoient les dépendances. Cette approche garantit que le code que vous êtes en train de tester ne dépend pas d'un code non testé ni d'API qui pourraient changer ou présenter des problèmes imprévus. De telles situations peuvent faire échouer votre test, même si le code que vous avez écrit est correct. Les données fictives aident à créer un environnement de test plus cohérent et fiable, et à effectuer des tests concis sur une seule fonctionnalité.
- Déclarez que les données renvoyées par la méthode
getMarsPhotos()
sont égales àFakeDataSource.photosList
.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
val repository = NetworkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}
Notez que dans votre IDE, l'appel de méthode getMarsPhotos()
est souligné en rouge.
Si vous pointez sur la méthode, une info-bulle indique que la fonction de suspension "getMarsPhotos" doit être appelée uniquement à partir d'une coroutine ou d'une autre fonction de suspension.
Dans data/MarsPhotosRepository.kt
, en examinant l'implémentation de getMarsPhotos()
dans NetworkMarsPhotosRepository
, vous constaterez que la fonction getMarsPhotos()
est une fonction de suspension.
class NetworkMarsPhotosRepository(
private val marsApiService: MarsApiService
) : MarsPhotosRepository {
/** Fetches list of MarsPhoto from marsApi*/
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
Rappelez-vous que lorsque vous avez appelé cette fonction à partir de MarsViewModel
, vous avez appelé cette méthode à partir d'une coroutine en l'appelant depuis un lambda transmis à viewModelScope.launch()
. Vous devez également appeler des fonctions de suspension, comme getMarsPhotos()
, à partir d'une coroutine dans un test. Toutefois, l'approche est différente. La section suivante explique comment résoudre ce problème.
Tester les coroutines
Dans cette section, vous allez modifier le test networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
afin que le corps de la méthode de test soit exécuté à partir d'une coroutine.
- Modifiez la fonction
networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList()
dansNetworkMarsRepositoryTest.kt
pour en faire une expression.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
- Définissez l'expression égale à la fonction
runTest()
. Cette méthode nécessite un lambda.
...
import kotlinx.coroutines.test.runTest
...
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
runTest {}
La fonction runTest()
est fournie par la bibliothèque de tests de coroutine. Elle prend la méthode que vous avez transmise dans le lambda et l'exécute à partir de TestScope
, qui hérite de CoroutineScope
.
- Déplacez le contenu de la fonction de test dans la fonction lambda.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
runTest {
val repository = NetworkMarsPhotosRepository(
marsApiService = FakeMarsApiService()
)
assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}
Notez que la ligne rouge sous getMarsPhotos()
a disparu. Si vous exécutez ce test, il est conclu avec succès.
10. Écrire un test ViewModel
Dans cette section, vous allez écrire un test pour la fonction getMarsPhotos()
à partir de MarsViewModel
. MarsViewModel
dépend de MarsPhotosRepository
. Par conséquent, pour écrire ce test, vous devez créer un MarsPhotosRepository
fictif. En plus de la méthode runTest()
, vous devez prendre en compte quelques étapes supplémentaires concernant les coroutines.
Créer un dépôt fictif
Cette étape vise à créer une classe fictive qui hérite de l'interface MarsPhotosRepository
et ignore la fonction getMarsPhotos()
pour renvoyer des données fictives. Cette approche est semblable à celle que vous avez adoptée avec le service d'API fictif, à la différence que cette classe étend l'interface MarsPhotosRepository
au lieu de MarsApiService
.
- Créez une classe intitulée
FakeNetworkMarsPhotosRepository
dans le répertoirefake
. - Étendez cette classe avec l'interface
MarsPhotosRepository
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
- Ignorez la fonction
getMarsPhotos()
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
}
}
- Renvoyez
FakeDataSource.photosList
à partir de la fonctiongetMarsPhotos()
.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
override suspend fun getMarsPhotos(): List<MarsPhoto> {
return FakeDataSource.photosList
}
}
Écrire le test ViewModel
- Créez une classe intitulée
MarsViewModelTest
. - Créez une fonction intitulée
marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
et annotez-la avec@Test
.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
- Faites de cette fonction une expression définie sur le résultat de la méthode
runTest()
pour vous assurer que le test est exécuté à partir d'une coroutine, comme pour le test de dépôt dans la section précédente.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest{
}
- Dans le corps lambda de
runTest()
, créez une instance deMarsViewModel
et transmettez-lui une instance du dépôt fictif que vous avez créé.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest{
val marsViewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
}
- Déclarez que le
marsUiState
de votre instanceViewModel
correspond au résultat d'un appel àMarsPhotosRepository.getMarsPhotos()
réussi.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest {
val marsViewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
assertEquals(
MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
"photos retrieved"),
marsViewModel.marsUiState
)
}
Si vous essayez d'exécuter ce test tel quel, il échouera. L'erreur se présente comme suit :
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
Rappelez-vous que le MarsViewModel
appelle le dépôt à l'aide de viewModelScope.launch()
. Cette instruction lance une nouvelle coroutine sous le coordinateur de coroutine par défaut (appelé coordinateur Main
). Le coordinateur Main
encapsule le thread UI Android. L'erreur précédente s'explique par le fait que le thread UI Android n'est pas disponible dans un test unitaire. Les tests unitaires sont exécutés sur votre station de travail, et non sur un appareil Android ou un émulateur. Si le code d'un test unitaire en local mentionne le coordinateur Main
, une exception (comme celle ci-dessus) est générée lorsque ce test est exécuté. Pour résoudre ce problème, vous devez définir clairement le coordinateur par défaut lorsque vous exécutez des tests unitaires. Pour savoir comment procéder, consultez la section suivante.
Créer un coordinateur de test
Le coordinateur Main
n'étant disponible que dans un contexte d'interface utilisateur, vous devez le remplacer par un coordinateur compatible avec les tests unitaires. La bibliothèque Kotlin Coroutines fournit à cet effet un coordinateur de coroutines appelé TestDispatcher
. TestDispatcher
doit être utilisé à la place du coordinateur Main
pour tout test unitaire dans lequel une nouvelle coroutine est effectuée, comme c'est le cas avec la fonction getMarsPhotos()
du modèle de vue.
Pour remplacer le coordinateur Main
par un TestDispatcher
dans tous les cas, utilisez la fonction Dispatchers.setMain()
. Vous pouvez utiliser la fonction Dispatchers.resetMain()
pour rétablir le coordinateur Main
. Pour éviter de dupliquer le code qui remplace le coordinateur Main
dans chaque test, vous pouvez l'extraire dans une règle de test JUnit. Une règle de test permet de contrôler l'environnement dans lequel un test est exécuté. Elle peut ajouter des vérifications supplémentaires, effectuer la configuration ou le nettoyage nécessaire lié au test, ou observer l'exécution de test pour établir ailleurs le rapport correspondant. Elle peut être facilement partagée entre les classes de test.
Créez une classe dédiée pour écrire la règle de règle de test afin de remplacer le coordinateur Main
. Pour implémenter une règle de test personnalisée, procédez comme suit :
- Créez un package dans le répertoire de test intitulé
rules
. - Dans le répertoire des règles, créez une classe intitulée
TestDispatcherRule
. - Étendez
TestDispatcherRule
avecTestWatcher
. La classeTestWatcher
vous permet d'effectuer des actions sur différentes phases d'exécution d'un test.
class TestDispatcherRule(): TestWatcher(){
}
- Créez un paramètre de constructeur
TestDispatcher
pourTestDispatcherRule
.
Ce paramètre permet d'utiliser différents coordinateurs, comme StandardTestDispatcher
. Vous devez lui attribuer une valeur par défaut définie sur une instance de l'objet UnconfinedTestDispatcher
. La classe UnconfinedTestDispatcher
hérite de la classe TestDispatcher
. Elle spécifie que les tâches ne doivent pas être exécutées dans un ordre particulier. Ce modèle d'exécution est idéal pour les tests simples, car les coroutines sont gérées automatiquement. Contrairement à UnconfinedTestDispatcher
, la classe StandardTestDispatcher
offre un contrôle total sur l'exécution des coroutines. Cette méthode est préférable pour les tests complexes nécessitant une approche manuelle, mais elle n'est pas nécessaire pour les tests dans cet atelier de programmation.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
}
- Cette règle de test vise principalement à remplacer le coordinateur
Main
par un coordinateur de test avant l'exécution d'un test. La fonctionstarting()
de la classeTestWatcher
est exécutée avant l'exécution d'un test donné. Ignorez la fonctionstarting()
.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
}
}
- Ajoutez un appel à
Dispatchers.setMain()
, en transmettanttestDispatcher
en tant qu'argument.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
}
- Une fois l'exécution du test terminée, réinitialisez le coordinateur
Main
en ignorant la méthodefinished()
. Appelez la fonctionDispatchers.resetMain()
.
class TestDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
La règle TestDispatcherRule
est prête à être réutilisée.
- Ouvrez le fichier
MarsViewModelTest.kt
. - Dans la classe
MarsViewModelTest
, instanciez la classeTestDispatcherRule
et affectez-la à une propriététestDispatcher
en lecture seule.
class MarsViewModelTest {
val testDispatcher = TestDispatcherRule()
...
}
- Pour appliquer cette règle à vos tests, ajoutez l'annotation
@get:Rule
à la propriététestDispatcher
.
class MarsViewModelTest {
@get:Rule
val testDispatcher = TestDispatcherRule()
...
}
- Relancez le test. Vérifiez que le test est conclu avec succès cette fois-ci.
11. Télécharger le code de solution
Pour télécharger le code de cet atelier de programmation, utilisez les commandes suivantes :
$ 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
Vous pouvez également télécharger le dépôt sous forme de fichier ZIP, le décompresser et l'ouvrir dans Android Studio.
Si vous le souhaitez, vous pouvez consulter le code de solution de cet atelier de programmation sur GitHub.
12. Conclusion
Bravo ! Vous avez terminé cet atelier de programmation et refactorisé l'application Mars Photos pour implémenter le schéma de dépôt et l'injection de dépendances.
Le code de l'application respecte désormais les bonnes pratiques Android concernant la couche de données. Il est ainsi plus flexible, robuste et facilement adaptable.
Ces modifications ont également permis de tester plus facilement l'application. Un atout considérable, puisque le code peut continuer à évoluer, ce qui vous permet de veiller à ce qu'il fonctionne toujours comme prévu.
N'oubliez pas de partager le fruit de vos efforts sur les réseaux sociaux avec le hashtag #AndroidBasics.
13. En savoir plus
Documentation pour les développeurs Android :
Autre :