J’ai un vif intérêt à trouver différentes solutions à un problème. Exploration actuelle de différentes architectures pour les applications iOS.
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 :
- L’application commencera à charger les jeux lors du chargement de la vue.
- Pendant le chargement, l’application affichera un spinner sur l’écran.
- Si les données sont chargées avec succès, nous afficherons les éléments dans la vue Tableau.
- Si le chargement se termine par une erreur, l’application affichera « Impossible de charger les jeux. Veuillez réessayer ! » message.
- 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.
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 :
- Attentes définies qui doivent être remplies avant la sortie du test.
- Configuration des dépendances pour envoyer une réponse valide sans liste de jeux vide.
- Configuration du moniteur de réponse qui écoute les événements et affirme l’état attendu de ViewModel.
- Simulation de l’action « loadGames » sur ViewModel.
- Attendre que les attentes soient satisfaites dans une séquence particulière.
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.
Vous pouvez télécharger le projet final ici. Aussi, si vous avez des questions ou des suggestions, laissez un commentaire ci-dessous.