Ajouter un dépôt et injecter manuellement des dépendances

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.

dbf927072d3070f0.png

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.

ff7a7cd039402747.png

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

  1. Effectuez un clic droit sur com.example.marsphotos, puis sélectionnez New > Package (Nouveau > Package).
  2. Dans la boîte de dialogue, saisissez data.
  3. Effectuez un clic droit sur le package data, puis sélectionnez New > Kotlin Class/File (Nouveau > Classe/Fichier Kotlin).
  4. Dans la boîte de dialogue, sélectionnez Interface et saisissez MarsPhotosRepository en nom d'interface.
  5. Dans l'interface MarsPhotosRepository, ajoutez une fonction abstraite intitulée getMarsPhotos(), qui renvoie une liste d'objets MarsPhoto. Comme elle est appelée à partir d'une coroutine, déclarez-la avec suspend.
import com.example.marsphotos.model.MarsPhoto

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}
  1. Sous la déclaration de l'interface, créez une classe intitulée NetworkMarsPhotosRepository pour implémenter l'interface MarsPhotosRepository.
  2. 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).

Capture d'écran d'Android Studio montrant l'interface MarsPhotosRepository et la classe NetworkMarsPhotosRepository

  1. Dans la classe NetworkMarsPhotosRepository, ignorez la fonction abstraite getMarsPhotos(). 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.

  1. Ouvrez le fichier ui/screens/MarsViewModel.kt.
  2. Faites défiler la page vers le bas jusqu'à la méthode getMarsPhotos().
  3. 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()

5313985852c151aa.png

  1. 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. Schéma de flux montrant comment la couche de données était accessible directement à partir de ViewModel auparavant. Nous avons maintenant un dépôt de photos de Mars.

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. 1ea410d6670b7670.png

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

  1. Effectuez un clic droit sur le package data, puis sélectionnez New > Kotlin Class/File (Nouveau > Classe/Fichier Kotlin).
  2. Dans la boîte de dialogue, sélectionnez Interface, puis saisissez AppContainer comme nom d'interface.
  3. Dans l'interface AppContainer, ajoutez une propriété abstraite intitulée marsPhotosRepository de type MarsPhotosRepository. 7ed26c6dcf607a55.png
  4. Sous la définition de l'interface, créez une classe intitulée DefaultAppContainer qui implémente l'interface AppContainer.
  5. À partir de network/MarsApiService.kt, déplacez le code des variables BASE_URL, retrofit et retrofitService dans la classe DefaultAppContainer 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)
    }

}
  1. Pour la variable BASE_URL, supprimez le mot clé const. La suppression de const est nécessaire, car BASE_URL est désormais une propriété de la classe DefaultAppContainer et non une variable de niveau supérieur comme elle l'était auparavant. Refactorisez-la en camelcase baseUrl.
  2. Pour la variable retrofitService, ajoutez un modificateur de visibilité private. Le modificateur private est ajouté, car la variable retrofitService 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.
  3. La classe DefaultAppContainer implémente l'interface AppContainer. Nous devons donc ignorer la propriété marsPhotosRepository. Après la variable retrofitService, 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)
    }
}
  1. Ouvrez le fichier data/MarsPhotosRepository.kt. Nous transmettons maintenant retrofitService à NetworkMarsPhotosRepository, et vous devez modifier la classe NetworkMarsPhotosRepository.
  2. Dans la déclaration de la classe NetworkMarsPhotosRepository, ajoutez le paramètre de constructeur marsApiService, comme indiqué dans le code ci-dessous.
import com.example.marsphotos.network.MarsApiService

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
  1. Dans la classe NetworkMarsPhotosRepository, dans la fonction getMarsPhotos(), modifiez l'instruction return pour récupérer les données de marsApiService.
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
  1. 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.

  1. 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.

92e7d7b79c4134f0.png

  1. Effectuez un clic droit sur com.example.marsphotos, puis sélectionnez New > Kotlin Class/File (Nouveau > Classe/Fichier Kotlin).
  2. 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() {
}
  1. Dans la classe MarsPhotosApplication, déclarez une variable intitulée container de type AppContainer pour stocker l'objet DefaultAppContainer. La variable étant initialisée lors de l'appel à onCreate(), vous devez la marquer avec le modificateur 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. 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()
    }
}
  1. 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.

759144e4e0634ed8.png

  1. Dans la section application, ajoutez l'attribut android: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.

7425864315cb5e6f.png

  1. Ouvrez le fichier ui/screens/MarsViewModel.kt.
  2. Dans la déclaration de classe pour MarsViewModel, ajoutez un paramètre de constructeur privé marsPhotosRepository de type MarsPhotosRepository. 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(){
  1. Dans la fonction getMarsPhotos(), supprimez la ligne de code ci-dessous, car marsPhotosRepository est désormais renseigné dans l'appel du constructeur.
val marsPhotosRepository = NetworkMarsPhotosRepository()
  1. 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 objet ViewModelProvider.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éé.

  1. 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)
       }
   }
}
  1. Ouvrez le fichier theme/MarsPhotosApp.kt, dans la fonction MarsPhotosApp(), puis mettez à jour viewModel() 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.

  1. 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

  1. 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.
  2. 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.

  1. Dans le répertoire de test, créez un package intitulé fake sous com.example.marsphotos.
  2. Créez un objet Kotlin intitulé FakeDataSource dans le répertoire fake.
  3. 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.

  1. Dans le package fake, créez une classe intitulée FakeMarsApiService.
  2. Configurez la classe FakeMarsApiService pour qu'elle hérite de l'interface MarsApiService.
class FakeMarsApiService : MarsApiService {
}
  1. Ignorez la fonction getPhotos().
override suspend fun getPhotos(): List<MarsPhoto> {
}
  1. 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.

  1. Dans le répertoire fictif, créez une classe intitulée NetworkMarsRepositoryTest.
  2. 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.

  1. Créez une instance de NetworkMarsPhotosRepository et transmettez FakeMarsApiService en tant que paramètre marsApiService.
@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é.

  1. 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.

2bd5f8999e0f3ec2.png

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.

d2d3b6d770677ef6.png

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.

  1. Modifiez la fonction networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() dans NetworkMarsRepositoryTest.kt pour en faire une expression.
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
  1. 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.

  1. 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.

  1. Créez une classe intitulée FakeNetworkMarsPhotosRepository dans le répertoire fake.
  2. Étendez cette classe avec l'interface MarsPhotosRepository.
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
  1. Ignorez la fonction getMarsPhotos().
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
   }
}
  1. Renvoyez FakeDataSource.photosList à partir de la fonction getMarsPhotos().
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return FakeDataSource.photosList
   }
}

Écrire le test ViewModel

  1. Créez une classe intitulée MarsViewModelTest.
  2. Créez une fonction intitulée marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() et annotez-la avec @Test.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
  1. 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{
    }
  1. Dans le corps lambda de runTest(), créez une instance de MarsViewModel 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()
         )
    }
  1. Déclarez que le marsUiState de votre instance ViewModel 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 :

  1. Créez un package dans le répertoire de test intitulé rules.
  2. Dans le répertoire des règles, créez une classe intitulée TestDispatcherRule.
  3. Étendez TestDispatcherRule avec TestWatcher. La classe TestWatcher vous permet d'effectuer des actions sur différentes phases d'exécution d'un test.
class TestDispatcherRule(): TestWatcher(){

}
  1. Créez un paramètre de constructeur TestDispatcher pour TestDispatcherRule.

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

}
  1. Cette règle de test vise principalement à remplacer le coordinateur Main par un coordinateur de test avant l'exécution d'un test. La fonction starting() de la classe TestWatcher est exécutée avant l'exécution d'un test donné. Ignorez la fonction starting().
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {

    }
}
  1. Ajoutez un appel à Dispatchers.setMain(), en transmettant testDispatcher en tant qu'argument.
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
}
  1. Une fois l'exécution du test terminée, réinitialisez le coordinateur Main en ignorant la méthode finished(). Appelez la fonction Dispatchers.resetMain().
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

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

La règle TestDispatcherRule est prête à être réutilisée.

  1. Ouvrez le fichier MarsViewModelTest.kt.
  2. Dans la classe MarsViewModelTest, instanciez la classe TestDispatcherRule et affectez-la à une propriété testDispatcher en lecture seule.
class MarsViewModelTest {

    val testDispatcher = TestDispatcherRule()
    ...
}
  1. Pour appliquer cette règle à vos tests, ajoutez l'annotation @get:Rule à la propriété testDispatcher.
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 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 :