Come scrivere test automatici per iOS

Pubblicato: 2022-03-11

In qualità di bravo sviluppatore, fai del tuo meglio per testare tutte le funzionalità e ogni possibile percorso e risultato del codice nel software che scrivi. Ma è estremamente raro e insolito poter testare manualmente ogni possibile risultato e ogni possibile percorso che un utente potrebbe intraprendere.

Man mano che l'applicazione diventa più grande e più complessa, la probabilità che ti perdi qualcosa attraverso il test manuale aumenta in modo significativo.

I test automatici, sia dell'interfaccia utente che delle API del servizio back-end, ti renderanno più sicuro che tutto funzioni come previsto e ridurranno lo stress durante lo sviluppo, il refactoring, l'aggiunta di nuove funzionalità o la modifica di quelle esistenti.

Con i test automatizzati puoi:

  • Riduci i bug: non esiste un metodo in grado di rimuovere completamente qualsiasi possibilità di bug nel codice, ma i test automatizzati possono ridurre notevolmente il numero di bug.
  • Apporta modifiche con sicurezza: evita i bug quando aggiungi nuove funzionalità, il che significa che puoi apportare modifiche in modo rapido e indolore.
  • Documenta il nostro codice: quando esaminiamo i test, possiamo vedere chiaramente cosa ci si aspetta da determinate funzioni, quali sono le condizioni e quali sono i casi d'angolo.
  • Refactoring indolore: come sviluppatore, a volte potresti avere paura del refactoring, specialmente se devi rifattorizzare una grossa porzione di codice. Gli unit test servono a garantire che il codice rifattorizzato funzioni ancora come previsto.

Questo articolo spiega come strutturare ed eseguire test automatici sulla piattaforma iOS.

Test unitari e test dell'interfaccia utente

È importante distinguere tra test unitari e test dell'interfaccia utente.

Uno unit test verifica una funzione specifica in un contesto specifico . Gli unit test verificano che la parte testata del codice (di solito una singola funzione) faccia ciò che dovrebbe fare. Ci sono molti libri e articoli sui test unitari, quindi non ne parleremo in questo post.

I test dell'interfaccia utente servono a testare l'interfaccia utente. Ad esempio, consente di verificare se una vista viene aggiornata come previsto o se viene attivata un'azione specifica come dovrebbe essere quando l'utente interagisce con un determinato elemento dell'interfaccia utente.

Ogni test dell'interfaccia utente verifica l'interazione di un utente specifico con l'interfaccia utente dell'applicazione. I test automatici possono e devono essere eseguiti sia a livello di unit test che di test dell'interfaccia utente.

Impostazione di test automatizzati

Poiché XCode supporta immediatamente il test dell'unità e dell'interfaccia utente, è facile e diretto aggiungerli al tuo progetto. Quando crei un nuovo progetto, seleziona semplicemente "Includi test unitari" e "Includi test dell'interfaccia utente".

Quando il progetto viene creato, due nuovi target verranno aggiunti al tuo progetto quando queste due opzioni sono state selezionate. I nuovi nomi di destinazione hanno "Test" o "UITests" aggiunti alla fine del nome.

Questo è tutto. Sei pronto per scrivere test automatici per il tuo progetto.

Immagine: impostazione di test automatizzati in XCode.

Se hai già un progetto esistente e desideri aggiungere l'interfaccia utente e il supporto per i test unitari, dovrai fare un po' più di lavoro, ma è anche molto semplice e diretto.

Vai su File → Nuovo → Destinazione e seleziona iOS Unit Testing Bundle per Unit test o iOS UI Testing Bundle per i test UI.

Immagine: selezione del bundle di test unitari iOS.

Premi Avanti .

Nella schermata delle opzioni del target, puoi lasciare tutto così com'è (se hai più target e vuoi testare solo target specifici, seleziona il target nel menu a tendina Target da testare).

Premi Fine . Ripeti questo passaggio per i test dell'interfaccia utente e avrai tutto pronto per iniziare a scrivere test automatici nel tuo progetto esistente.

Test unitari di scrittura

Prima di poter iniziare a scrivere unit test, dobbiamo capire la loro anatomia. Quando includi gli unit test nel tuo progetto, verrà creata una classe di test di esempio. Nel nostro caso, sarà simile a questo:

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

I metodi più importanti da comprendere sono setUp e tearDown . Il metodo setUp viene chiamato prima di ogni metodo di test, mentre il metodo tearDown viene chiamato dopo ogni metodo di test. Se eseguiamo i test definiti in questa classe di test di esempio, i metodi verranno eseguiti in questo modo:

setUp → testExample → tearDown setUp → testPerformanceExample → tearDown

Suggerimento: i test vengono eseguiti premendo cmd + U, selezionando Prodotto → Test o facendo clic e tenendo premuto il pulsante Esegui finché non viene visualizzato il menu delle opzioni, quindi selezionare Test dal menu.

Se si desidera eseguire un solo metodo di test specifico, premere il pulsante a sinistra del nome del metodo (mostrato nell'immagine sottostante).

Immagine: selezione di un metodo di prova specifico.

Ora, quando hai tutto pronto per scrivere i test, puoi aggiungere una classe di esempio e alcuni metodi per testare.

Aggiungi una classe che sarà responsabile della registrazione degli utenti. Un utente inserisce un indirizzo e-mail, una password e una conferma della password. La nostra classe di esempio convaliderà l'input, verificherà la disponibilità dell'indirizzo e-mail e tenterà la registrazione dell'utente.

Nota: questo esempio utilizza il modello architettonico MVVM (o Model-View-ViewModel).

MVVM viene utilizzato perché rende l'architettura di un'applicazione più pulita e più facile da testare.

Con MVVM, è più facile separare la logica aziendale dalla logica di presentazione, evitando così enormi problemi di controller di visualizzazione.

I dettagli sull'architettura MVVM non rientrano nell'ambito di questo articolo, ma puoi leggere di più in questo articolo.

Creiamo una classe view-model responsabile della registrazione degli utenti. .

 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 } }

Innanzitutto, abbiamo aggiunto alcune proprietà, proprietà dinamiche e un metodo init.

Non preoccuparti del tipo Dynamic . Fa parte dell'architettura MVVM.

Quando un valore Dynamic<Bool> è impostato su true, un controller di visualizzazione associato (connesso) a RegistrationViewModel abiliterà il pulsante di registrazione. Quando loginSuccessful è impostato su true, la vista connessa si aggiornerà da sola.

Aggiungiamo ora alcuni metodi per verificare la validità della password e del formato dell'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 }

Ogni volta che l'utente digita qualcosa nel campo dell'e-mail o della password, il metodo enableRegistrationAttempt verificherà se un'e-mail e una password sono nel formato corretto e abilita o disabilita il pulsante di registrazione tramite la proprietà dinamica registrationEnabled .

Per mantenere l'esempio semplice, aggiungi due semplici metodi: uno per verificare la disponibilità di un'e-mail e uno per tentare la registrazione con nome utente e password forniti.

 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 } } }

Questi due metodi utilizzano il NetworkService per verificare se un'e-mail è disponibile e per tentare la registrazione.

Per semplificare questo esempio, l'implementazione NetworkService non utilizza alcuna API back-end, ma è solo uno stub che falsifica i risultati. NetworkService è implementato come protocollo e la sua classe di implementazione.

 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 è un protocollo molto semplice che contiene solo due metodi: tentativi di registrazione e metodi di verifica della disponibilità dell'e-mail. L'implementazione del protocollo è 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) }) } }

Entrambi i metodi aspettano semplicemente un po' di tempo (fingendo il ritardo di una richiesta di rete) e quindi chiamano i metodi di callback appropriati.

Suggerimento: è buona norma utilizzare i protocolli (noti anche come interfacce in altri linguaggi di programmazione). Puoi leggere di più a riguardo se cerchi "principio di programmazione per interfacce". Vedrai anche come funziona bene con i test unitari.

Ora, quando viene impostato un esempio, possiamo scrivere unit test per coprire i metodi di questa classe.

  1. Crea una nuova classe di test per il nostro modello di visualizzazione. Fare clic con il pulsante destro del mouse sulla cartella TestingIOSTests nel riquadro Project Navigator, selezionare Nuovo file → Unit Test Case Class e denominarlo RegistrationViewModelTests .

  2. Elimina i metodi testExample e testPerformanceExample , poiché vogliamo creare i nostri metodi di test.

  3. Poiché Swift utilizza moduli e i nostri test si trovano in un modulo diverso rispetto al codice della nostra applicazione, dobbiamo importare il modulo della nostra applicazione come @testable . Sotto l'istruzione import e la definizione della classe, aggiungi @testable import TestingIOS (o il nome del modulo della tua applicazione). Senza questo, non saremmo in grado di fare riferimento a nessuna delle classi o dei metodi della nostra applicazione.

  4. Aggiungi la variabile registrationViewModel .

Ecco come appare ora la nostra classe di test vuota:

 import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }

Proviamo a scrivere un test per il metodo emailValid . Creeremo un nuovo metodo di test chiamato testEmailValid . È importante aggiungere la parola chiave test all'inizio del nome. In caso contrario, il metodo non verrà riconosciuto come metodo di prova.

Il nostro metodo di prova si presenta così:

 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") }

Il nostro metodo di test utilizza un metodo di asserzione, XCTAssert , che nel nostro caso verifica se una condizione è vera o falsa.

Se la condizione è falsa, assert fallirà (insieme al test) e il nostro messaggio verrà scritto.

Ci sono molti metodi di asserzione che puoi usare nei tuoi test. Descrivere e mostrare ogni metodo di asserzione può facilmente creare il proprio articolo, quindi non entrerò nei dettagli qui.

Alcuni esempi di metodi di asserzione disponibili sono: XCTAssertEqualObjects , XCTAssertGreaterThan , XCTAssertNil , XCTAssertTrue o XCTAssertThrows .

Puoi leggere di più sui metodi di asserzione disponibili qui.

Se esegui il test ora, il metodo di test passerà. Hai creato con successo il tuo primo metodo di prova, ma non è ancora pronto per la prima serata. Questo metodo di prova presenta ancora tre problemi (uno grande e due più piccoli), come descritto di seguito.

Problema 1: stai utilizzando l'implementazione reale del protocollo NetworkService

Uno dei principi fondamentali dello unit test è che ogni test dovrebbe essere indipendente da qualsiasi fattore esterno o dipendenza. I test unitari dovrebbero essere atomici.

Se stai testando un metodo, che a un certo punto chiama un metodo API dal server, il tuo test dipende dal tuo codice di rete e dalla disponibilità del server. Se il server non funziona al momento del test, il test fallirà, accusando erroneamente il metodo testato di non funzionare.

In questo caso, stai testando un metodo di RegistrationViewModel .

RegistrationViewModel dipende dalla classe NetworkServiceImpl , anche se sai che il metodo testato, emailValid , non dipende direttamente da NetworkServiceImpl .

Quando si scrivono unit test, tutte le dipendenze esterne devono essere rimosse. Ma come rimuovere la dipendenza NetworkService senza modificare l'implementazione della classe RegistrationViewModel ?

Esiste una soluzione semplice a questo problema e si chiama Object Mocking . Se guardi da vicino RegistrationViewModel , vedrai che in realtà dipende dal protocollo NetworkService .

 class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...

Durante l'inizializzazione di RegistrationViewModel RegistrationViewModel fornita (o iniettata) un'implementazione del protocollo NetworkService .

Questo principio è chiamato iniezione di dipendenza tramite costruttore ( ci sono più tipi di iniezioni di dipendenza ).

Ci sono molti articoli interessanti sull'iniezione di dipendenza online, come questo articolo su objc.io.

C'è anche un articolo breve ma interessante che spiega l'iniezione di dipendenza in un modo semplice e diretto qui.

Inoltre, sul blog di Toptal è disponibile un ottimo articolo sul principio di responsabilità unica e DI.

Quando viene istanziata RegistrationViewModel , sta iniettando un'implementazione del protocollo NetworkService nel suo costruttore (da cui il nome del principio di iniezione delle dipendenze):

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

Poiché la nostra classe del modello di visualizzazione dipende solo dal protocollo, non c'è nulla che ci impedisca di creare la nostra classe di implementazione NetworkService personalizzata (o derisa) e di iniettare la classe derisa nel nostro oggetto del modello di visualizzazione.

Creiamo la nostra derisa implementazione del protocollo NetworkService .

Aggiungi un nuovo file Swift al nostro target di test facendo clic con il pulsante destro del mouse sulla cartella TestingIOSTests nel Project Navigator, scegli "Nuovo file", seleziona "File Swift" e NetworkServiceMock .

Ecco come dovrebbe apparire la nostra classe derisa:

 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) }) } }

A questo punto, non è molto diverso dalla nostra implementazione effettiva ( NetworkServiceImpl ), ma in una situazione reale, l'attuale NetworkServiceImpl avrebbe un codice di rete, una gestione della risposta e funzionalità simili.

La nostra classe derisa non fa nulla, che è il punto di una classe derisa. Se non fa nulla, non interferirà con i nostri test.

Per risolvere il primo problema del nostro test, aggiorniamo il nostro metodo di test sostituendo:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

insieme a:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

Problema 2: stai creando un'istanza della registrationVM nel corpo del metodo di test

C'è un motivo per cui ci sono i metodi setUp e tearDown .

Questi metodi vengono utilizzati per avviare o configurare tutti gli oggetti richiesti richiesti in un test. Dovresti usare questi metodi per evitare la duplicazione del codice scrivendo gli stessi metodi di inizializzazione o di configurazione in ogni metodo di test. Non utilizzare i metodi di installazione e tearDown non è sempre un grosso problema, soprattutto se si dispone di una configurazione davvero specifica per un metodo di test specifico.

Poiché l'inizializzazione della classe RegistrationViewModel è piuttosto semplice, esegui il refactoring della classe di test per utilizzare i metodi setup e tearDown.

RegistrationViewModelTests dovrebbe assomigliare a questo:

 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") ... } }

Problema 3: hai più asserzioni in un metodo di test

Anche se questo non è un grosso problema, ci sono alcuni sostenitori di avere un'asserzione per metodo.

Il motivo principale di questo principio è il rilevamento degli errori.

Se un metodo di test ha più asserzioni e il primo ha esito negativo, l'intero metodo di test verrà contrassegnato come non riuscito. Altre affermazioni non saranno nemmeno testate.

In questo modo scopriresti un solo errore alla volta. Non sapresti se altre asserzioni fallirebbero o riuscirebbero.

Non è sempre una brutta cosa avere più asserzioni in un metodo perché puoi correggere solo un errore alla volta, quindi rilevare un errore alla volta potrebbe non essere un grosso problema.

Nel nostro caso, viene verificata la validità di un formato di posta elettronica. Poiché questa è solo una funzione, potrebbe essere più logico raggruppare tutte le asserzioni insieme in un metodo per rendere il test più facile da leggere e comprendere.

Poiché questo problema non è in realtà un grosso problema e alcuni potrebbero persino obiettare che non è affatto un problema, manterrai il tuo metodo di test così com'è.

Quando scrivi i tuoi unit test, sta a te decidere quale percorso vuoi intraprendere per ciascun metodo di test. Molto probabilmente, scoprirai che ci sono luoghi in cui l'affermazione per filosofia del test ha senso e altri in cui non lo è.

Metodi di test con chiamate asincrone

Non importa quanto sia semplice l'applicazione, c'è un'alta probabilità che ci sia un metodo che deve essere eseguito su un altro thread in modo asincrono, soprattutto perché in genere ti piace che l'interfaccia utente venga eseguita nel proprio thread..

Il problema principale con lo unit test e le chiamate asincrone è che una chiamata asincrona richiede tempo per essere completata, ma lo unit test non attende fino al termine. Poiché lo unit test è terminato prima dell'esecuzione di qualsiasi codice all'interno di un blocco asincrono, il nostro test terminerà sempre con lo stesso risultato (indipendentemente da ciò che scrivi nel blocco asincrono).

Per dimostrarlo, creiamo un test per il metodo 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") } }

Qui vuoi verificare se una variabile registrationEnabled sarà impostata su false dopo che il nostro metodo ti dice che l'email non è disponibile (già presa da un altro utente).

Se esegui questo test, passerà. Ma prova solo un'altra cosa. Modifica la tua asserzione in:

 XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

Se esegui di nuovo il test, passa di nuovo.

Questo perché la nostra affermazione non è stata nemmeno affermata. Lo unit test è terminato prima dell'esecuzione del blocco di callback (ricorda, nella nostra implementazione del servizio di rete simulato, è impostato per attendere un secondo prima di tornare).

Fortunatamente, con Xcode 6, Apple ha aggiunto le aspettative di test al framework XCTest come classe XCTestExpectation . La classe XCTestExpectation funziona in questo modo:

  1. All'inizio del test imposti le aspettative del test, con un semplice testo che descrive ciò che ti aspettavi dal test.
  2. In un blocco asincrono dopo l'esecuzione del codice di test, soddisfi le aspettative.
  3. Al termine del test è necessario impostare il blocco waitForExpectationWithTimer . Verrà eseguito quando l'aspettativa è soddisfatta o se il timer scade, a seconda di quale evento si verifica per primo.
  4. Ora, lo unit test non terminerà fino a quando l'aspettativa non sarà soddisfatta o fino allo scadere del timer dell'aspettativa.

Riscriviamo il nostro test per usare 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") } } }

Se esegui il test ora, fallirà, come dovrebbe. Risolviamo il test per farlo passare. Modifica l'asserzione in:

 XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")

Esegui di nuovo il test per vederlo passare. Puoi provare a modificare il tempo di ritardo nell'implementazione simulata del servizio di rete per vedere cosa succede se il timer delle aspettative si esaurisce.

Metodi di test con chiamate asincrone senza callback

Il nostro metodo di progetto di esempio attemptUserRegistration usa il metodo NetworkService.attemptRegistration che include codice eseguito in modo asincrono. Il metodo tenta di registrare un utente con il servizio di back-end.

Nella nostra applicazione demo, il metodo attenderà solo un secondo per simulare una chiamata di rete e simulare una registrazione riuscita. Se la registrazione è andata a buon fine, il valore loginSuccessful sarà impostato su true. Facciamo uno unit test per verificare questo comportamento.

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }

Se eseguito, questo test avrà esito negativo perché il valore loginSuccessful non verrà impostato su true fino al termine del metodo asincrono networkService.attemptRegistration .

Poiché hai creato un NetworkServiceImpl deriso in cui il metodo attemptRegistration attenderà un secondo prima di restituire una registrazione riuscita, puoi semplicemente utilizzare Grand Central Dispatch (GCD) e utilizzare il metodo asyncAfter per verificare la tua asserzione dopo un secondo. Dopo aver aggiunto asyncAfter del GCD, il nostro codice di test sarà simile a questo:

 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") } }

Se hai prestato attenzione, saprai che questo non funzionerà ancora perché il metodo di test verrà eseguito prima dell'esecuzione del blocco asyncAfter e di conseguenza il metodo passerà sempre correttamente. Fortunatamente, esiste la classe XCTestException .

Riscriviamo il nostro metodo per utilizzare 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") } } }

Con i test unitari che coprono il nostro RegistrationViewModel , ora puoi essere più sicuro che l'aggiunta di nuove funzionalità o l'aggiornamento di quelle esistenti non interrompono nulla.

Nota importante: gli unit test perderanno il loro valore se non vengono aggiornati quando la funzionalità dei metodi che coprono viene modificata. La scrittura di unit test è un processo che deve stare al passo con il resto dell'applicazione.

Suggerimento: non rimandare i test di scrittura alla fine. Scrivi test durante lo sviluppo. In questo modo avrai una migliore comprensione di cosa deve essere testato e quali sono i casi di confine.

Scrivere test dell'interfaccia utente

Dopo che tutti i test di unità sono stati completamente sviluppati ed eseguiti con successo, puoi essere molto sicuro che ogni unità di codice funzioni correttamente, ma significa che la tua applicazione nel suo insieme funziona come previsto?

È qui che entrano in gioco i test di integrazione, di cui i test dell'interfaccia utente sono una componente essenziale.

Prima di iniziare con il test dell'interfaccia utente, è necessario che siano presenti alcuni elementi dell'interfaccia utente e interazioni (o storie utente) da testare. Creiamo una vista semplice e il relativo controller di visualizzazione.

  1. Apri Main.storyboard e crea un semplice controller di visualizzazione che assomiglierà a quello nell'immagine qui sotto.

Immagine: creazione di una vista semplice e del relativo controller di visualizzazione.

Impostare il tag del campo di testo dell'e-mail su 100, il tag del campo di testo della password su 101 e il tag di conferma della password su 102.

  1. Aggiungi un nuovo file del controller di visualizzazione RegistrationViewController.swift e collega tutti i punti vendita con lo 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() } }

Qui stai aggiungendo IBOutlets e una struttura TextFieldTags alla classe.

Ciò ti consentirà di identificare quale campo di testo viene modificato. Per utilizzare le proprietà dinamiche nel modello di visualizzazione, è necessario "associare" le proprietà dinamiche nel controller di visualizzazione. Puoi farlo nel metodo bindViewModel :

 fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }

Aggiungiamo ora un metodo delegato del campo di testo per tenere traccia di quando uno qualsiasi dei campi di testo viene aggiornato:

 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 }
  1. Aggiorna AppDelegate per associare il controller di visualizzazione al modello di visualizzazione appropriato (tieni presente che questo passaggio è un requisito dell'architettura MVVM). Il codice AppDelegate aggiornato dovrebbe quindi assomigliare a questo:
 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 } }

Il file dello storyboard e il RegistrationViewController sono davvero semplici, ma sono adeguati per dimostrare come funzionano i test automatizzati dell'interfaccia utente.

Se tutto è impostato correttamente, il pulsante di registrazione dovrebbe essere disabilitato all'avvio dell'app. Quando, e solo quando, tutti i campi sono compilati e validi, il pulsante di registrazione deve essere abilitato.

Una volta impostato, puoi creare il tuo primo test dell'interfaccia utente.

Il nostro test dell'interfaccia utente dovrebbe verificare se il pulsante Registra verrà abilitato se e solo se sono stati inseriti un indirizzo e-mail valido, una password valida e una conferma di password valida. Ecco come impostarlo:

  1. Aprire il file TestingIOSUITests.swift .
  2. Elimina il metodo testExample() e aggiungi un metodo testRegistrationButtonEnabled() .
  3. Posiziona il cursore nel metodo testRegistrationButtonEnabled come se dovessi scrivere qualcosa lì.
  4. Premi il pulsante Registra test interfaccia utente (cerchio rosso nella parte inferiore dello schermo).

Immagine: screenshot che mostra il pulsante di test dell'interfaccia utente di registrazione.

  1. Quando viene premuto il pulsante Registra, l'applicazione verrà avviata
  2. Dopo aver avviato l'applicazione, tocca il campo di testo dell'e-mail e scrivi "[email protected]". Noterai che il codice appare automaticamente all'interno del corpo del metodo di test.

Puoi registrare tutte le istruzioni dell'interfaccia utente utilizzando questa funzione, ma potresti scoprire che scrivere semplici istruzioni manualmente sarà molto più veloce.

Questo è un esempio di istruzione del registratore per toccare un campo di testo della password e inserire un indirizzo e-mail '[email protected]'

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. Dopo che le interazioni dell'interfaccia utente che desideri testare sono state registrate, premi nuovamente il pulsante di arresto (l'etichetta del pulsante di registrazione è cambiata in Stop quando hai iniziato la registrazione) per interrompere la registrazione.
  2. Dopo aver registrato le interazioni dell'interfaccia utente, ora puoi aggiungere vari XCTAsserts per testare vari stati dell'applicazione o elementi dell'interfaccia utente.

Immagine: Animazione che mostra un'istruzione del registratore per toccare un campo password.

Le istruzioni registrate non sono sempre autoesplicative e potrebbero persino rendere l'intero metodo di prova un po' difficile da leggere e capire. Fortunatamente, puoi inserire manualmente le istruzioni dell'interfaccia utente.

Let's create the following UI instructions manually:

  1. User taps on the password text field.
  2. 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.