Comment écrire des tests automatisés pour iOS
Publié: 2022-03-11En tant que bon développeur, vous faites de votre mieux pour tester toutes les fonctionnalités et tous les chemins de code et résultats possibles dans le logiciel que vous écrivez. Mais il est extrêmement rare et inhabituel de pouvoir tester manuellement tous les résultats possibles et tous les chemins possibles qu'un utilisateur pourrait emprunter.
Au fur et à mesure que l'application devient plus grande et plus complexe, la probabilité que vous manquiez quelque chose lors des tests manuels augmente considérablement.
Les tests automatisés, à la fois de l'interface utilisateur et des API de service back-end, vous rendront plus confiant que tout fonctionne comme prévu et réduiront le stress lors du développement, de la refactorisation, de l'ajout de nouvelles fonctionnalités ou de la modification de celles existantes.
Avec les tests automatisés, vous pouvez :
- Réduire les bogues : aucune méthode ne supprimera complètement toute possibilité de bogues dans votre code, mais les tests automatisés peuvent réduire considérablement le nombre de bogues.
- Effectuez des modifications en toute confiance : évitez les bogues lors de l'ajout de nouvelles fonctionnalités, ce qui signifie que vous pouvez apporter des modifications rapidement et sans douleur.
- Documenter notre code : lorsque nous parcourons les tests, nous pouvons clairement voir ce qui est attendu de certaines fonctions, quelles sont les conditions et quels sont les cas extrêmes.
- Refactoriser sans douleur : en tant que développeur, vous pouvez parfois avoir peur de refactoriser, surtout si vous avez besoin de refactoriser une grande partie de code. Les tests unitaires sont là pour s'assurer que le code refactorisé fonctionne toujours comme prévu.
Cet article vous apprend à structurer et à exécuter des tests automatisés sur la plate-forme iOS.
Tests unitaires vs tests d'interface utilisateur
Il est important de différencier les tests unitaires et UI.
Un test unitaire teste une fonction spécifique dans un contexte spécifique . Les tests unitaires vérifient que la partie testée du code (généralement une seule fonction) fait ce qu'elle est censée faire. Il existe de nombreux livres et articles sur les tests unitaires, nous ne les aborderons donc pas dans cet article.
Les tests d'interface utilisateur servent à tester l'interface utilisateur. Par exemple, il vous permet de tester si une vue est mise à jour comme prévu ou si une action spécifique est déclenchée comme il se doit lorsque l'utilisateur interagit avec un certain élément de l'interface utilisateur.
Chaque test d'interface utilisateur teste une interaction utilisateur spécifique avec l'interface utilisateur de l'application. Les tests automatisés peuvent et doivent être effectués à la fois au niveau des tests unitaires et des tests d'interface utilisateur.
Configuration des tests automatisés
Étant donné que XCode prend en charge les tests unitaires et d'interface utilisateur prêts à l'emploi, il est facile et direct de les ajouter à votre projet. Lors de la création d'un nouveau projet, cochez simplement "Inclure les tests unitaires" et "Inclure les tests de l'interface utilisateur".
Lorsque le projet est créé, deux nouvelles cibles seront ajoutées à votre projet lorsque ces deux options auront été cochées. Les nouveaux noms de cible ont "Tests" ou "UITests" ajouté à la fin du nom.
C'est ça. Vous êtes prêt à écrire des tests automatisés pour votre projet.
Si vous avez déjà un projet existant et que vous souhaitez ajouter la prise en charge de l'interface utilisateur et des tests unitaires, vous devrez faire un peu plus de travail, mais c'est aussi très simple et direct.
Accédez à Fichier → Nouveau → Cible et sélectionnez Ensemble de tests unitaires iOS pour les tests unitaires ou Ensemble de tests d'interface utilisateur iOS pour les tests d'interface utilisateur.
Appuyez sur Suivant .
Dans l'écran des options de cible, vous pouvez tout laisser tel quel (si vous avez plusieurs cibles et que vous souhaitez tester uniquement des cibles spécifiques, sélectionnez la cible dans la liste déroulante Cible à tester).
Appuyez sur Terminer . Répétez cette étape pour les tests d'interface utilisateur et vous aurez tout prêt pour commencer à écrire des tests automatisés dans votre projet existant.
Rédaction de tests unitaires
Avant de pouvoir commencer à écrire des tests unitaires, nous devons comprendre leur anatomie. Lorsque vous incluez des tests unitaires dans votre projet, un exemple de classe de test sera créé. Dans notre cas, cela ressemblera à ceci :
import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }
Les méthodes les plus importantes à comprendre sont setUp
et tearDown
. La méthode setUp
est appelée avant chaque méthode de test, tandis que la méthode tearDown
est appelée après chaque méthode de test. Si nous exécutons des tests définis dans cet exemple de classe de test, les méthodes s'exécutent comme ceci :
setUp → testExample → tearDown setUp → testPerformanceExample → tearDown
Astuce : Les tests sont exécutés en appuyant sur cmd + U, en sélectionnant Produit → Test, ou en cliquant et en maintenant enfoncé le bouton Exécuter jusqu'à ce que le menu des options s'affiche, puis sélectionnez Test dans le menu.
Si vous souhaitez exécuter une seule méthode de test spécifique, appuyez sur le bouton à gauche du nom de la méthode (indiqué sur l'image ci-dessous).
Maintenant, lorsque tout est prêt pour l'écriture de tests, vous pouvez ajouter une classe d'exemple et quelques méthodes à tester.
Ajoutez une classe qui sera responsable de l'enregistrement des utilisateurs. Un utilisateur saisit une adresse e-mail, un mot de passe et une confirmation de mot de passe. Notre classe d'exemple validera l'entrée, vérifiera la disponibilité de l'adresse e-mail et tentera d'enregistrer l'utilisateur.
Remarque : cet exemple utilise le modèle architectural MVVM (ou Model-View-ViewModel).
MVVM est utilisé car il rend l'architecture d'une application plus propre et plus facile à tester.
Avec MVVM, il est plus facile de séparer la logique métier de la logique de présentation, évitant ainsi un problème massif de contrôleur de vue.
Les détails sur l'architecture MVVM sortent du cadre de cet article, mais vous pouvez en savoir plus à ce sujet dans cet article.
Créons une classe de modèle de vue responsable de l'enregistrement des utilisateurs. .
class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }
Tout d'abord, nous avons ajouté quelques propriétés, des propriétés dynamiques et une méthode init.
Ne vous inquiétez pas du type Dynamic
. Cela fait partie de l'architecture MVVM.
Lorsqu'une valeur Dynamic<Bool>
est définie sur true, un contrôleur de vue lié (connecté) au RegistrationViewModel
activera le bouton d'enregistrement. Lorsque loginSuccessful
est défini sur true, la vue connectée se met à jour.
Ajoutons maintenant quelques méthodes pour vérifier la validité du mot de passe et du format d'e-mail.
func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }
Chaque fois que l'utilisateur saisit quelque chose dans le champ e-mail ou mot de passe, la méthode enableRegistrationAttempt
vérifie si un e-mail et un mot de passe sont au format correct et active ou désactive le bouton d'enregistrement via la propriété dynamique registrationEnabled
.
Pour garder l'exemple simple, ajoutez deux méthodes simples - une pour vérifier la disponibilité d'un e-mail et une pour tenter l'enregistrement avec un nom d'utilisateur et un mot de passe donnés.
func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }
Ces deux méthodes utilisent le NetworkService pour vérifier si un e-mail est disponible et pour tenter l'enregistrement.
Pour que cet exemple reste simple, l'implémentation de NetworkService n'utilise aucune API back-end, mais n'est qu'un stub qui falsifie les résultats. NetworkService est implémenté en tant que protocole et sa classe d'implémentation.
typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }
NetworkService est un protocole très simple contenant seulement deux méthodes : les méthodes de tentative d'enregistrement et de vérification de la disponibilité des e-mails. L'implémentation du protocole est la classe NetworkServiceImpl.
class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }
Les deux méthodes attendent simplement un certain temps (simulant le délai d'une requête réseau), puis appellent les méthodes de rappel appropriées.
Conseil : Il est recommandé d'utiliser des protocoles (également appelés interfaces dans d'autres langages de programmation). Vous pouvez en savoir plus à ce sujet si vous recherchez "principe de programmation aux interfaces". Vous verrez également comment cela fonctionne bien avec les tests unitaires.
Maintenant, lorsqu'un exemple est défini, nous pouvons écrire des tests unitaires pour couvrir les méthodes de cette classe.
Créez une nouvelle classe de test pour notre modèle de vue. Cliquez avec le bouton droit sur le dossier
TestingIOSTests
dans le volet Navigateur du projet, sélectionnez Nouveau fichier → Classe de cas de test unitaire et nommez-leRegistrationViewModelTests
.Supprimez les méthodes
testExample
ettestPerformanceExample
, car nous voulons créer nos propres méthodes de test.Étant donné que Swift utilise des modules et que nos tests sont dans un module différent du code de notre application, nous devons importer le module de notre application en tant que
@testable
. Sous l'instruction d'importation et la définition de classe, ajoutez@testable import TestingIOS
(ou le nom du module de votre application). Sans cela, nous ne pourrions référencer aucune des classes ou méthodes de notre application.Ajoutez la variable
registrationViewModel
.
Voici à quoi ressemble notre classe de test vide :
import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }
Essayons d'écrire un test pour la méthode emailValid
. Nous allons créer une nouvelle méthode de test appelée testEmailValid
. Il est important d'ajouter le mot-clé test
au début du nom. Sinon, la méthode ne sera pas reconnue comme méthode de test.
Notre méthode de test ressemble à ceci :
func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }
Notre méthode de test utilise une méthode d'assertion, XCTAssert
, qui dans notre cas vérifie si une condition est vraie ou fausse.
Si la condition est fausse, assert échouera (avec le test) et notre message sera écrit.
Il existe de nombreuses méthodes d'assertion que vous pouvez utiliser dans vos tests. Décrire et montrer chaque méthode d'assertion peut facilement créer son propre article, donc je n'entrerai pas dans les détails ici.
Voici quelques exemples de méthodes d'assertion disponibles : XCTAssertEqualObjects
, XCTAssertGreaterThan
, XCTAssertNil
, XCTAssertTrue
ou XCTAssertThrows
.
Vous pouvez en savoir plus sur les méthodes d'assertion disponibles ici.
Si vous exécutez le test maintenant, la méthode de test réussira. Vous avez créé avec succès votre première méthode de test, mais elle n'est pas encore tout à fait prête pour le prime time. Cette méthode de test a encore trois problèmes (un gros et deux plus petits), comme détaillé ci-dessous.
Problème 1 : Vous utilisez l'implémentation réelle du protocole NetworkService
L'un des principes fondamentaux des tests unitaires est que chaque test doit être indépendant de tout facteur extérieur ou dépendance. Les tests unitaires doivent être atomiques.
Si vous testez une méthode qui, à un moment donné, appelle une méthode API à partir du serveur, votre test dépend de votre code réseau et de la disponibilité du serveur. Si le serveur ne fonctionne pas au moment du test, votre test échouera, accusant ainsi à tort votre méthode testée de ne pas fonctionner.
Dans ce cas, vous testez une méthode de RegistrationViewModel
.
RegistrationViewModel
dépend de la classe NetworkServiceImpl
, même si vous savez que votre méthode testée, emailValid
, ne dépend pas directement de NetworkServiceImpl
.
Lors de l'écriture de tests unitaires, toutes les dépendances extérieures doivent être supprimées. Mais comment supprimer la dépendance NetworkService sans modifier l'implémentation de la classe RegistrationViewModel
?
Il existe une solution simple à ce problème, appelée Object Mocking . Si vous regardez attentivement le RegistrationViewModel
, vous verrez qu'il dépend en fait du protocole NetworkService
.
class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...
Lors de l'initialisation de RegistrationViewModel
, une implémentation du protocole NetworkService
est donnée (ou injectée) à l'objet RegistrationViewModel
.
Ce principe s'appelle l'injection de dépendances via le constructeur ( il existe plusieurs types d'injections de dépendances ).
Il existe de nombreux articles intéressants sur l'injection de dépendances en ligne, comme cet article sur objc.io.
Il y a aussi un article court mais intéressant expliquant l'injection de dépendances de manière simple et directe ici.
De plus, un excellent article sur le principe de responsabilité unique et DI est disponible sur le blog Toptal.
Lorsque le RegistrationViewModel
est instancié, il injecte une implémentation du protocole NetworkService dans son constructeur (d'où le nom du principe d'injection de dépendance) :
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
Étant donné que notre classe de modèle de vue ne dépend que du protocole, rien ne nous empêche de créer notre classe d'implémentation NetworkService
personnalisée (ou simulée) et d'injecter la classe simulée dans notre objet de modèle de vue.
Créons notre implémentation fictive du protocole NetworkService
.
Ajoutez un nouveau fichier Swift à notre cible de test en cliquant avec le bouton droit sur le dossier TestingIOSTests
dans le navigateur de projet, choisissez "Nouveau fichier", sélectionnez "Fichier Swift" et nommez-le NetworkServiceMock
.
Voici à quoi devrait ressembler notre classe simulée :
import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }
À ce stade, ce n'est pas très différent de notre implémentation réelle ( NetworkServiceImpl
), mais dans une situation réelle, le NetworkServiceImpl
réel aurait un code réseau, une gestion des réponses et des fonctionnalités similaires.
Notre classe simulée ne fait rien, ce qui est le but d'une classe simulée. S'il ne fait rien, il n'interférera pas avec nos tests.
Pour résoudre le premier problème de notre test, mettons à jour notre méthode de test en remplaçant :
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
avec:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())
Problème 2 : vous instanciez registrationVM dans le corps de la méthode de test
Il y a les méthodes setUp
et tearDown
pour une raison.
Ces méthodes sont utilisées pour initialiser ou configurer tous les objets requis requis dans un test. Vous devez utiliser ces méthodes pour éviter la duplication de code en écrivant les mêmes méthodes init ou setup dans chaque méthode de test. Ne pas utiliser les méthodes de configuration et de démontage n'est pas toujours un gros problème, surtout si vous avez une configuration vraiment spécifique pour une méthode de test spécifique.
Étant donné que notre initialisation de la classe RegistrationViewModel
est assez simple, vous allez refactoriser votre classe de test pour utiliser les méthodes setup et tearDown.
RegistrationViewModelTests
devrait ressembler à ceci :
class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }
Problème 3 : Vous avez plusieurs assertions dans une méthode de test
Même si ce n'est pas un gros problème, certains préconisent d'avoir une assertion par méthode.
La raison principale de ce principe est la détection d'erreurs.

Si une méthode de test a plusieurs assertions et que la première échoue, la méthode de test entière sera marquée comme ayant échoué. D'autres assertions ne seront même pas testées.
De cette façon, vous ne découvrirez qu'une seule erreur à la fois. Vous ne sauriez pas si d'autres assertions échoueraient ou réussiraient.
Ce n'est pas toujours une mauvaise chose d'avoir plusieurs assertions dans une méthode car vous ne pouvez corriger qu'une seule erreur à la fois, donc détecter une erreur à la fois n'est peut-être pas un si gros problème.
Dans notre cas, la validité d'un format d'email est testée. Puisqu'il ne s'agit que d'une seule fonction, il pourrait être plus logique de regrouper toutes les assertions dans une seule méthode pour rendre le test plus facile à lire et à comprendre.
Comme ce problème n'est pas vraiment un gros problème et que certains pourraient même dire que ce n'est pas du tout un problème, vous garderez votre méthode de test telle quelle.
Lorsque vous écrivez vos propres tests unitaires, c'est à vous de décider quel chemin vous souhaitez emprunter pour chaque méthode de test. Très probablement, vous constaterez qu'il y a des endroits où la philosophie d'affirmation par test a du sens, et d'autres où ce n'est pas le cas.
Méthodes de test avec des appels asynchrones
Quelle que soit la simplicité de l'application, il y a de fortes chances qu'une méthode doive être exécutée sur un autre thread de manière asynchrone, d'autant plus que vous aimez généralement que l'interface utilisateur s'exécute dans son propre thread.
Le principal problème avec les tests unitaires et les appels asynchrones est qu'un appel asynchrone prend du temps pour se terminer, mais le test unitaire n'attendra pas qu'il se termine. Étant donné que le test unitaire est terminé avant que le code à l'intérieur d'un bloc asynchrone ne soit exécuté, notre test se terminera toujours par le même résultat (peu importe ce que vous écrivez dans votre bloc asynchrone).
Pour le démontrer, créons un test pour la méthode checkEmailAvailability
.
func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }
Ici, vous voulez tester si une variable registrationEnabled sera définie sur false après que notre méthode vous ait indiqué que l'e-mail n'est pas disponible (déjà pris par un autre utilisateur).
Si vous exécutez ce test, il réussira. Mais essayez encore une chose. Changez votre assertion en :
XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")
Si vous exécutez à nouveau le test, il réussit à nouveau.
C'est parce que notre assertion n'a même pas été affirmée. Le test unitaire s'est terminé avant l'exécution du bloc de rappel (rappelez-vous, dans notre implémentation de service réseau simulé, il est configuré pour attendre une seconde avant de revenir).
Heureusement, avec Xcode 6, Apple a ajouté des attentes de test au framework XCTest en tant que classe XCTestExpectation
. La classe XCTestExpectation
fonctionne comme ceci :
- Au début du test, vous définissez vos attentes de test - avec un texte simple décrivant ce que vous attendiez du test.
- Dans un bloc asynchrone après l'exécution de votre code de test, vous répondez alors à l'attente.
- À la fin du test, vous devez définir le bloc
waitForExpectationWithTimer
. Il sera exécuté lorsque l'attente est remplie ou si le temps imparti est écoulé - selon la première éventualité. - Désormais, le test unitaire ne se terminera pas tant que l'attente ne sera pas satisfaite ou tant que le minuteur d'attente ne sera pas écoulé.
Réécrivons notre test pour utiliser la classe XCTestExpectation
.
func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }
Si vous exécutez le test maintenant, il échouera - comme il se doit. Fixons le test pour le faire passer. Remplacez l'assertion par :
XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")
Exécutez à nouveau le test pour le voir passer. Vous pouvez essayer de modifier le délai dans l'implémentation simulée du service réseau pour voir ce qui se passe si le minuteur d'attente s'épuise.
Méthodes de test avec des appels asynchrones sans rappel
Notre exemple de méthode de projet attemptUserRegistration
utilise la méthode NetworkService.attemptRegistration
qui inclut du code exécuté de manière asynchrone. La méthode tente d'enregistrer un utilisateur auprès du service backend.
Dans notre application de démonstration, la méthode n'attendra qu'une seconde pour simuler un appel réseau et simulera un enregistrement réussi. Si l'enregistrement a réussi, la valeur loginSuccessful
sera définie sur true. Faisons un test unitaire pour vérifier ce comportement.
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }
S'il est exécuté, ce test échouera car la valeur loginSuccessful
ne sera pas définie sur true tant que la méthode asynchrone networkService.attemptRegistration
ne sera pas terminée.
Puisque vous avez créé un NetworkServiceImpl
simulé où la méthode attemptRegistration
attendra une seconde avant de renvoyer un enregistrement réussi, vous pouvez simplement utiliser Grand Central Dispatch (GCD) et utiliser la méthode asyncAfter
pour vérifier votre assertion après une seconde. Après avoir ajouté l' asyncAfter
du GCD, notre code de test ressemblera à ceci :
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }
Si vous avez fait attention, vous saurez que cela ne fonctionnera toujours pas car la méthode de test s'exécutera avant l'exécution du bloc asyncAfter
et la méthode réussira toujours en conséquence. Heureusement, il existe la classe XCTestException
.
Réécrivons notre méthode pour utiliser la classe XCTestException
:
func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }
Avec les tests unitaires couvrant notre RegistrationViewModel
, vous pouvez désormais être plus sûr que l'ajout de nouvelles fonctionnalités ou la mise à jour de fonctionnalités existantes ne cassera rien.
Remarque importante : Les tests unitaires perdront leur valeur s'ils ne sont pas mis à jour lorsque la fonctionnalité des méthodes qu'ils couvrent change. L'écriture de tests unitaires est un processus qui doit suivre le reste de l'application.
Conseil : ne remettez pas les tests d'écriture à la fin. Écrire des tests pendant le développement. De cette façon, vous aurez une meilleure compréhension de ce qui doit être testé et quels sont les cas frontaliers.
Rédaction de tests d'interface utilisateur
Une fois que tous les tests unitaires sont entièrement développés et exécutés avec succès, vous pouvez être sûr que chaque unité de code fonctionne correctement, mais cela signifie-t-il que votre application dans son ensemble fonctionne comme prévu ?
C'est là qu'interviennent les tests d'intégration, dont les tests d'interface utilisateur sont une composante essentielle.
Avant de commencer les tests d'interface utilisateur, il doit y avoir des éléments d'interface utilisateur et des interactions (ou user stories) à tester. Créons une vue simple et son contrôleur de vue.
- Ouvrez le
Main.storyboard
et créez un contrôleur de vue simple qui ressemblera à celui de l'image ci-dessous.
Définissez la balise du champ de texte de l'e-mail sur 100, la balise du champ de texte du mot de passe sur 101 et la balise de confirmation du mot de passe sur 102.
- Ajoutez un nouveau fichier de contrôleur de vue
RegistrationViewController.swift
et connectez toutes les prises avec le storyboard.
import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }
Ici, vous ajoutez IBOutlets
et une structure TextFieldTags
à la classe.
Cela vous permettra d'identifier quel champ de texte est en cours d'édition. Pour utiliser les propriétés dynamiques dans le modèle de vue, vous devez "lier" les propriétés dynamiques dans le contrôleur de vue. Vous pouvez le faire dans la méthode bindViewModel
:
fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }
Ajoutons maintenant une méthode déléguée de champ de texte pour savoir quand l'un des champs de texte est mis à jour :
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
- Mettez à jour
AppDelegate
pour lier le contrôleur de vue au modèle de vue approprié (notez que cette étape est une exigence de l'architecture MVVM). Le codeAppDelegate
mis à jour devrait alors ressembler à ceci :
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }
Le fichier storyboard et le RegistrationViewController
sont vraiment simples, mais ils sont suffisants pour démontrer le fonctionnement des tests automatisés de l'interface utilisateur.
Si tout est correctement configuré, le bouton d'enregistrement doit être désactivé au démarrage de l'application. Lorsque, et seulement lorsque, tous les champs sont remplis et valides, le bouton d'inscription doit être activé.
Une fois que cela est configuré, vous pouvez créer votre premier test d'interface utilisateur.
Notre test d'interface utilisateur devrait vérifier si le bouton S'inscrire sera activé si et seulement si une adresse e-mail valide, un mot de passe valide et une confirmation de mot de passe valide ont tous été saisis. Voici comment configurer ceci :
- Ouvrez le fichier
TestingIOSUITests.swift
. - Supprimez la méthode
testExample()
et ajoutez une méthodetestRegistrationButtonEnabled()
. - Placez le curseur dans la méthode
testRegistrationButtonEnabled
comme si vous alliez y écrire quelque chose. - Appuyez sur le bouton Record UI test (cercle rouge en bas de l'écran).
- Lorsque le bouton Enregistrer est enfoncé, l'application sera lancée
- Une fois l'application lancée, appuyez sur le champ de texte de l'e-mail et écrivez "[email protected]". Vous remarquerez que le code apparaît automatiquement dans le corps de la méthode de test.
Vous pouvez enregistrer toutes les instructions de l'interface utilisateur à l'aide de cette fonctionnalité, mais vous constaterez peut-être que l'écriture manuelle d'instructions simples sera beaucoup plus rapide.
Ceci est un exemple d'instruction d'enregistreur pour taper sur un champ de texte de mot de passe et entrer une adresse e-mail '[email protected]'
let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
- Une fois que les interactions de l'interface utilisateur que vous souhaitez tester ont été enregistrées, appuyez à nouveau sur le bouton d'arrêt (l'étiquette du bouton d'enregistrement a changé pour arrêter lorsque vous avez commencé l'enregistrement) pour arrêter l'enregistrement.
- Une fois que vous avez votre enregistreur d'interactions d'interface utilisateur, vous pouvez maintenant ajouter divers
XCTAsserts
pour tester différents états de l'application ou des éléments de l'interface utilisateur.
Les instructions enregistrées ne sont pas toujours explicites et peuvent même rendre l'ensemble de la méthode de test un peu difficile à lire et à comprendre. Heureusement, vous pouvez saisir manuellement les instructions de l'interface utilisateur.
Let's create the following UI instructions manually:
- User taps on the password text field.
- User enters a 'password'.
To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.
The password UI interaction can now be written as:
let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")
There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:
let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")
Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert
(the same as you did in unit testing) to verify if the Register button's isEnabled
state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled
property looks like this:
let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")
The whole UI test should now look like this:
func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }
If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.
To improve the test, let's also test that the isEnabled
property of the register button is false whenever any of the required fields have not been not entered correctly.
The complete test method should now look like this:
func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }
Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.
The example UI test is very simple, but it demonstrates the power of automated UI testing.
The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.
Become a Better Developer by Writing Good Tests
From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.
To write good tests, you will have to learn how to better organize your code.
Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.
In some cases, it is even impossible to write tests when code is not organized well.
When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).
When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.
Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.
As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).
Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.