Tel Mobiles

Tests unitaires avec MVVM dans iOS

J’ai un vif intérêt à trouver différentes solutions à un problème. Exploration actuelle de différentes architectures pour les applications iOS.

Tests unitaires avec MVVM

Un aperçu rapide de MVVM

Dans mon article précédent « MVVM une amélioration sur MVC », nous avons vu comment MVVM sépare l’état d’affichage du contrôleur dans ViewModel. Nous pouvons piloter les états de vue représentés par ViewModel de manière isolée. Cela nous donne la possibilité d’écrire des cas de test pour vérifier différents comportements de View.

Le sujet de test

Dans ce didacticiel, nous allons tester l’application « Jeux vidéo ». C’est une application simple qui charge et affiche une liste de jeux sur l’écran d’accueil. Notre flux d’application est le suivant :

  1. L’application commencera à charger les jeux lors du chargement de la vue.
  2. Pendant le chargement, l’application affichera un spinner sur l’écran.
  3. Si les données sont chargées avec succès, nous afficherons les éléments dans la vue Tableau.
  4. Si le chargement se termine par une erreur, l’application affichera « Impossible de charger les jeux. Veuillez réessayer ! » message.
  5. Si le nombre d’éléments chargés est de 0, l’application affichera « Aucun jeu disponible pour le moment ». message.

J’ai compilé un projet de démarrage afin que vous puissiez vous entraîner au fur et à mesure. Téléchargez le projet à partir d’ici et ouvrez le projet dans le dossier Starter pour commencer.

Modèle d’affichage de la liste des jeux

Avant de commencer à écrire des cas de test de notre GamesListViewModel, examinons son comportement. Vous pouvez trouver GamesListViewModel à la racine du projet de démarrage.

public protocol GamesListViewModelDelegate {
    
    func errorDidOccur (vm: GamesListViewModel)
    func didStartLoading (vm: GamesListViewModel)
    func itemsLoaded (vm: GamesListViewModel)
}

public class GamesListViewModel {

    /// Notifies of different flow events 
    public var delegate: GamesListViewModelDelegate?

    /// Represetns is there any error to dislay
    /// True if Yes other wise False
    public var showError: Bool { get }

    /// Error message to display on to the screen
    public var errorMessage: String { get }

    /// Number of items in the loaded games list
    public var itemsCount: Int { get }

    /// Represents either to show loading sign or not
    public var showLoading: Bool { get }

    /// This method initiates the load request to DataService
    public func loadGames()

    /// Returns the item view model to display in list
    public func getItem(at index: Int) -> GameListItemViewModel?

    /// Takes data service to perform data operations
    public init (_ dataService: DataService)
}

Dans GameListViewModel, nous exposons toutes les informations d’état, les notifications de changement d’état (GameListViewModelDelegate) et les actions que l’interface utilisateur peut initier. GameListViewModel a une dépendance qui est DataService qui nous permet de charger la liste des jeux.

A lire aussi :  Comment utiliser la protection par mot de passe dans l'application iOS OneNote

Préparer l’environnement de test

Un des principes des tests unitaires est d’isoler le sujet de test et de simuler ses dépendances afin de tester tous les cas possibles.

Faites défiler pour continuer

Notre sujet de test GameListViewModel a une dépendance, c’est-à-dire DataService. Afin d’isoler et de simuler l’environnement de GameListViewModel, nous devons simuler les comportements de DataService. Voici le service fictif que nous utiliserons :

protocol DataService {
    func loadGames(_ completion: @escaping ([Game]?, Error?) -> Void)
}

struct MockDataService : DataService {

    var games: [Game]?
    var error: Error?
    
    func loadGames(_ completion: @escaping ([Game]?, Error?) -> Void) {
        
        DispatchQueue.main.async {
            
            guard let games = self.games else {
                completion(nil, self.error!)
                return
            }
            completion(games, nil)
        }
    }
}

Le GameListViewModel notifie les événements via GameListViewModelDelegate et pour surveiller ces événements, nous utiliserons ce qui suit :

public struct MonitorGamesListViewModelDelegate : GamesListViewModelDelegate {

    public var errorDidOccurCallback: ((_ vm: GamesListViewModel) -> Void)?
    public var didStartLoadingCallback: ((_ vm: GamesListViewModel) -> Void)?
    public var itemsLoadedCallback: ((_ vm: GamesListViewModel) -> Void)?
    
    public func errorDidOccur (vm: GamesListViewModel) {
        errorDidOccurCallback?(vm)
    }
    public func didStartLoading (vm: GamesListViewModel) {
        didStartLoadingCallback?(vm)
    }
    public func itemsLoaded (vm: GamesListViewModel) {
        itemsLoadedCallback?(vm)
    }
}

Début des tests lettons

Dans notre premier cas de test, nous testerons le comportement de GamesListViewModel si les données sont chargées avec succès sans aucune liste vide. Pour ce faire, nous disons à MockDataService d’envoyer une liste de jeux avec un élément et vérifierons la réponse GamesListViewModel à l’aide de MonitorGamesListViewModelDelegate.

/// After successfully loading games list
/// Expected behaviour:
/// - only show loading should be true and started loading should be called
/// - items loaded should be called and items count should be correct
func testDataLoadSuccessfully() {
    
    // setting up expectations
    let startedLoadingExpectation = expectation(description: "got callback start loading")
    let itemsLoadedExpectation = expectation(description: "items loaded expectation")
    
    // simulating the enviornment
    mockDataService.error = nil
    mockDataService.games = [ Game(title: "Happy life") ]
    
    // setting up response monitors
    responseMonitor.didStartLoadingCallback = { vm in
        XCTAssert(vm.showLoading, "Loading flag should be true")
        XCTAssert(!vm.showError, "Error flag should not be true")
        startedLoadingExpectation.fulfill()
    }
    responseMonitor.errorDidOccurCallback = { _ in
        XCTAssert(false, "Invalid error callback")
    }
    responseMonitor.itemsLoadedCallback = { vm in
        XCTAssert(!vm.showLoading, "Loading flag should not be true")
        XCTAssert(!vm.showError, "Error flag should not be true")
        XCTAssert(vm.itemsCount == 1, "Invalid items loaded")
        itemsLoadedExpectation.fulfill()
    }
    
    // performing action
    vm.loadGames()
    
    // check for expectation
    wait(for: [
        startedLoadingExpectation,
        itemsLoadedExpectation
    ], timeout: 1, enforceOrder: true)
}

Dans ce cas de test, nous effectuons cinq étapes :

  1. Attentes définies qui doivent être remplies avant la sortie du test.
  2. Configuration des dépendances pour envoyer une réponse valide sans liste de jeux vide.
  3. Configuration du moniteur de réponse qui écoute les événements et affirme l’état attendu de ViewModel.
  4. Simulation de l’action « loadGames » sur ViewModel.
  5. Attendre que les attentes soient satisfaites dans une séquence particulière.
A lire aussi :  Le Motorola Moto G Pure, un point d'accès mobile économique

Nous allons maintenant tester si le chargement de la demande de données échoue avec une erreur. Selon nos exigences, showError doit être vrai et errorMessage doit indiquer « Impossible de charger les jeux. Veuillez réessayer ! »

/// Loading games list ends with error
func testDataLoadedWithError() {
        
    // setting up expectation
    let startedLoadingExpectation = expectation(description: "got callback start loading.")
    let errorOccuredExpectation = expectation(description: "loading error expectation.")
    
    // simulating the enviornment
    mockDataService.error = AppDataServiceError.invalidResponse
    mockDataService.games = nil
    
    // setting up response monitors
    responseMonitor.didStartLoadingCallback = { vm in
        XCTAssert(vm.showLoading, "Loading flag should be true.")
        XCTAssert(!vm.showError, "Error flag should not be true.")
        startedLoadingExpectation.fulfill()
    }
    responseMonitor.errorDidOccurCallback = { vm in
        
        XCTAssert(!vm.showLoading, "Loading flag should not be true.")
        XCTAssert(vm.showError, "Error flag should be true.")
        XCTAssert(vm.errorMessage == "Unable to load games. Please try again !",
                    "Invalid error message.")
        XCTAssert(vm.itemsCount == 0, "Items count should be 0.")
        errorOccuredExpectation.fulfill()
    }
    responseMonitor.itemsLoadedCallback = { _ in
        
        XCTAssert(false, "Items loaded callback should not be called.")
    }
    
    // performing action
    vm.loadGames()
    
    // check for expectation
    wait(for: [
        startedLoadingExpectation,
        errorOccuredExpectation
    ], timeout: 1, enforceOrder: true)
}

Maintenant, notre dernier cas, les données ont été chargées avec succès mais la liste des jeux est vide. Le résultat souhaité dans ce cas est, showError doit être vrai et errorMessage doit indiquer « Aucun jeu disponible pour le moment ».

/// Loaded games list is empty
func testDataLoadedWithEmptyList() {
        
    // setting up expectation
    let startedLoadingExpectation = expectation(description: "got callback start loading.")
    let errorOccuredExpectation = expectation(description: "loading error expectation.")
    
    // simulating the enviornment
    mockDataService.error = nil
    mockDataService.games = []
    
    // setting up response monitors
    responseMonitor.didStartLoadingCallback = { vm in
        XCTAssert(vm.showLoading, "Loading flag should be true.")
        XCTAssert(!vm.showError, "Error flag should not be true.")
        startedLoadingExpectation.fulfill()
    }
    responseMonitor.errorDidOccurCallback = { vm in
        
        XCTAssert(!vm.showLoading, "Loading flag should not be true.")
        XCTAssert(vm.showError, "Error flag should be true.")
        XCTAssert(vm.errorMessage == "No games available at the moment.",
                    "Invalid error message.")
        XCTAssert(vm.itemsCount == 0, "Items count should be 0.")
        errorOccuredExpectation.fulfill()
    }
    responseMonitor.itemsLoadedCallback = { _ in
        
        XCTAssert(false, "Items loaded callback should not be called.")
    }
    
    // performing action
    vm.loadGames()
    
    // check for expectation
    wait(for: [
        startedLoadingExpectation,
        errorOccuredExpectation
    ], timeout: 1, enforceOrder: true)
}

conclusion

Nous avons vu comment MVVM permet de simuler facilement le comportement de l’utilisateur sans s’occuper de la vue. Nous avons écrit trois scénarios de test pour voir comment tester différents états de la vue.

A lire aussi :  Comment obtenir des notifications répétées pour les appels manqués et les messages texte

Vous pouvez télécharger le projet final ici. Aussi, si vous avez des questions ou des suggestions, laissez un commentaire ci-dessous.

Bouton retour en haut de la page