Cum să scrieți teste automate pentru iOS
Publicat: 2022-03-11În calitate de dezvoltator bun, faceți tot posibilul pentru a testa toate funcționalitățile și fiecare cale posibilă de cod și rezultat în software-ul pe care îl scrieți. Dar este extrem de rar și neobișnuit să poți testa manual fiecare rezultat posibil și fiecare cale posibilă pe care o poate lua un utilizator.
Pe măsură ce aplicația devine mai mare și mai complexă, probabilitatea ca veți pierde ceva prin testarea manuală crește semnificativ.
Testarea automată, atât a interfeței de utilizare, cât și a API-urilor de serviciu back-end, vă va face mai încrezător că totul funcționează conform intenției și va reduce stresul la dezvoltarea, refactorizarea, adăugarea de noi funcții sau modificarea celor existente.
Cu testele automate, puteți:
- Reduceți erorile: nu există nicio metodă care să elimine complet orice posibilitate de erori în codul dvs., dar testele automate pot reduce foarte mult numărul de erori.
- Faceți modificări cu încredere: evitați erorile când adăugați funcții noi, ceea ce înseamnă că puteți face modificări rapid și fără durere.
- Documentați codul nostru: atunci când ne uităm prin teste, putem vedea clar ce se așteaptă de la anumite funcții, care sunt condițiile și care sunt cazurile de colț.
- Refactorizare fără durere: în calitate de dezvoltator, s-ar putea să vă fie teamă uneori de refactorizare, mai ales dacă trebuie să refactorizați o bucată mare de cod. Testele unitare sunt aici pentru a se asigura că codul refactorizat încă funcționează conform intenției.
Acest articol vă învață cum să structurați și să executați testarea automată pe platforma iOS.
Teste unitare vs. Teste UI
Este important să faceți diferența între testele unitare și cele ale UI.
Un test unitar testează o anumită funcție într-un context specific . Testele unitare verifică dacă partea testată a codului (de obicei o singură funcție) face ceea ce ar trebui să facă. Există o mulțime de cărți și articole despre testele unitare, așa că nu vom acoperi asta în această postare.
Testele UI sunt pentru testarea interfeței cu utilizatorul. De exemplu, vă permite să testați dacă o vizualizare este actualizată conform intenției sau dacă o anumită acțiune este declanșată așa cum ar trebui să fie atunci când utilizatorul interacționează cu un anumit element UI.
Fiecare test de interfață de utilizare testează o anumită interacțiune a utilizatorului cu interfața de utilizare a aplicației. Testarea automată poate și ar trebui să fie efectuată atât la nivelul testului unitar, cât și al testului UI.
Configurarea testelor automate
Întrucât XCode acceptă testarea unitară și a interfeței de utilizator din cutie, este ușor și simplu să le adăugați la proiectul dvs. Când creați un nou proiect, bifați pur și simplu „Include Unit Tests” și „Include UI Tests”.
Când proiectul este creat, două noi ținte vor fi adăugate la proiectul dvs. când aceste două opțiuni au fost bifate. Noile nume țintă au „Tests” sau „UITests” adăugate la sfârșitul numelui.
Asta e. Sunteți gata să scrieți teste automate pentru proiectul dvs.
Dacă aveți deja un proiect existent și doriți să adăugați suportul pentru UI și Unit tests, va trebui să lucrați mai mult, dar este și foarte simplu și simplu.
Accesați Fișier → Nou → Țintă și selectați Pachetul de testare unitară iOS pentru testele unitare sau Pachetul de testare UI iOS pentru testele UI.
Apăsați Următorul .
În ecranul de opțiuni de țintă, puteți lăsa totul așa cum este (dacă aveți mai multe ținte și doriți să testați numai ținte specifice, selectați ținta din meniul drop-down Ținta de testat).
Apăsați Terminare . Repetați acest pas pentru testele UI și veți avea totul pregătit pentru a începe să scrieți teste automate în proiectul dvs. existent.
Scrierea testelor unitare
Înainte de a începe să scriem teste unitare, trebuie să înțelegem anatomia acestora. Când includeți teste unitare în proiectul dvs., va fi creată un exemplu de clasă de testare. În cazul nostru, va arăta astfel:
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. } } }
Cele mai importante metode de înțeles sunt setUp
și tearDown
. Metoda setUp
este apelată înainte de fiecare metodă de testare, în timp ce metoda tearDown
este apelată după fiecare metodă de testare. Dacă rulăm teste definite în acest exemplu de clasă de testare, metodele ar rula astfel:
setUp → testExample → tearDown setUp → testPerformanceExample → tearDown
Sfat: Testele sunt executate apăsând cmd + U, selectând Produs → Test sau făcând clic și menținând apăsat butonul Run până când apare meniul de opțiuni, apoi selectați Test din meniu.
Dacă doriți să rulați o singură metodă de testare specifică, apăsați pe butonul din stânga numelui metodei (prezentat în imaginea de mai jos).
Acum, când aveți totul pregătit pentru scrierea testelor, puteți adăuga o clasă exemplu și câteva metode de testat.
Adăugați o clasă care va fi responsabilă pentru înregistrarea utilizatorilor. Un utilizator introduce o adresă de e-mail, o parolă și o confirmare a parolei. Exemplul nostru de clasă va valida introducerea, va verifica disponibilitatea adresei de e-mail și va încerca înregistrarea utilizatorului.
Notă: acest exemplu utilizează modelul arhitectural MVVM (sau Model-View-ViewModel).
MVVM este folosit deoarece face arhitectura unei aplicații mai curată și mai ușor de testat.
Cu MVVM, este mai ușor să separați logica de afaceri de logica de prezentare, evitând astfel problemele masive ale controlerului de vizualizare.
Detaliile despre arhitectura MVVM nu fac obiectul acestui articol, dar puteți citi mai multe despre aceasta în acest articol.
Să creăm o clasă de model de vizualizare responsabilă pentru înregistrarea utilizatorilor. .
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 } }
În primul rând, am adăugat câteva proprietăți, proprietăți dinamice și o metodă init.
Nu vă faceți griji pentru tipul Dynamic
. Face parte din arhitectura MVVM.
Când o valoare Dynamic<Bool>
este setată la adevărat, un controler de vizualizare care este legat (conectat) la RegistrationViewModel
va activa butonul de înregistrare. Când loginSuccessful
este setată la true, vizualizarea conectată se va actualiza singură.
Să adăugăm acum câteva metode pentru a verifica validitatea parolei și a formatului de 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 }
De fiecare dată când utilizatorul introduce ceva în câmpul de e-mail sau de parolă, metoda enableRegistrationAttempt
va verifica dacă un e-mail și o parolă sunt în formatul corect și va activa sau dezactiva butonul de înregistrare prin proprietatea dinamică registrationEnabled
.
Pentru a păstra exemplul simplu, adăugați două metode simple – una pentru a verifica disponibilitatea unui e-mail și alta pentru a încerca înregistrarea cu numele de utilizator și parola date.
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 } } }
Aceste două metode folosesc NetworkService pentru a verifica dacă un e-mail este disponibil și pentru a încerca înregistrarea.
Pentru a menține acest exemplu simplu, implementarea NetworkService nu folosește niciun API back-end, ci este doar un stub care falsifică rezultatele. NetworkService este implementat ca protocol și clasa sa de implementare.
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 este un protocol foarte simplu care conține doar două metode: încercare de înregistrare și metode de verificare a disponibilității e-mailului. Implementarea protocolului este clasa 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) }) } }
Ambele metode pur și simplu așteaptă ceva timp (făcând întârzierea unei cereri de rețea) și apoi apelează metodele de apel invers corespunzătoare.
Sfat: Este o practică bună să folosiți protocoale (cunoscute și ca interfețe în alte limbaje de programare). Puteți citi mai multe despre asta dacă căutați „principiul programării la interfețe”. Veți vedea, de asemenea, cum funcționează bine cu testarea unitară.
Acum, când este dat un exemplu, putem scrie teste unitare pentru a acoperi metodele acestei clase.
Creați o nouă clasă de testare pentru modelul nostru de vizualizare. Faceți clic dreapta pe folderul
TestingIOSTests
din panoul Project Navigator, selectați New File → Unit Test Case Class și denumiți-lRegistrationViewModelTests
.Ștergeți metodele
testExample
șitestPerformanceExample
, deoarece dorim să ne creăm propriile metode de testare.Deoarece Swift folosește module și testele noastre sunt într-un modul diferit de codul aplicației noastre, trebuie să importam modulul aplicației noastre ca
@testable
. Sub instrucțiunea de import și definiția clasei, adăugați@testable import TestingIOS
(sau numele modulului aplicației dvs.). Fără aceasta, nu am putea face referire la niciuna dintre clasele sau metodele aplicației noastre.Adăugați variabila
registrationViewModel
.
Iată cum arată clasa noastră de testare goală acum:
import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }
Să încercăm să scriem un test pentru metoda emailValid
. Vom crea o nouă metodă de testare numită testEmailValid
. Este important să adăugați cuvântul cheie de test
la începutul numelui. În caz contrar, metoda nu va fi recunoscută ca metodă de testare.
Metoda noastră de testare arată astfel:
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") }
Metoda noastră de testare utilizează o metodă de afirmare, XCTAssert
, care în cazul nostru verifică dacă o condiție este adevărată sau falsă.
Dacă condiția este falsă, afirmația va eșua (împreună cu testul), iar mesajul nostru va fi scris.
Există o mulțime de metode de afirmare pe care le puteți folosi în teste. Descrierea și afișarea fiecărei metode de afirmare poate face cu ușurință propriul articol, așa că nu voi intra în detalii aici.
Câteva exemple de metode de afirmare disponibile sunt: XCTAssertEqualObjects
, XCTAssertGreaterThan
, XCTAssertNil
, XCTAssertTrue
sau XCTAssertThrows
.
Puteți citi mai multe despre metodele de afirmare disponibile aici.
Dacă rulați testul acum, metoda de testare va trece. Ați creat cu succes prima metodă de testare, dar încă nu este pregătită pentru prime time. Această metodă de testare are încă trei probleme (una mare și două mai mici), așa cum este detaliat mai jos.
Problema 1: utilizați implementarea reală a protocolului NetworkService
Unul dintre principiile de bază ale testării unitare este că fiecare test ar trebui să fie independent de orice factori externi sau dependențe. Testele unitare ar trebui să fie atomice.
Dacă testați o metodă, care la un moment dat apelează o metodă API de pe server, testul dvs. depinde de codul dvs. de rețea și de disponibilitatea serverului. Dacă serverul nu funcționează în momentul testării, testul tău va eșua, acuzând astfel în mod greșit metoda ta testată că nu funcționează.
În acest caz, testați o metodă a RegistrationViewModel
.
RegistrationViewModel
depinde de clasa NetworkServiceImpl
, chiar dacă știți că metoda dvs. testată, emailValid
, nu depinde direct de NetworkServiceImpl
.
Când scrieți teste unitare, toate dependențele exterioare ar trebui eliminate. Dar cum ar trebui să eliminați dependența NetworkService fără a modifica implementarea clasei RegistrationViewModel
?
Există o soluție ușoară la această problemă și se numește Object Mocking . Dacă te uiți cu atenție la RegistrationViewModel
, vei vedea că depinde de fapt de protocolul NetworkService
.
class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...
Când RegistrationViewModel
este inițializat, o implementare a protocolului NetworkService
este dată (sau injectată) obiectului RegistrationViewModel
.
Acest principiu se numește injecție de dependență prin constructor ( există mai multe tipuri de injecții de dependență ).
Există o mulțime de articole interesante despre injectarea dependenței online, cum ar fi acest articol pe objc.io.
Există, de asemenea, un articol scurt, dar interesant, care explică injectarea dependenței într-un mod simplu și direct aici.
În plus, un articol grozav despre principiul responsabilității unice și DI este disponibil pe blogul Toptal.
Când RegistrationViewModel
este instanțiat, injectează o implementare a protocolului NetworkService în constructorul său (de unde și numele principiului de injectare a dependenței):
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
Deoarece clasa noastră de model de vizualizare depinde doar de protocol, nimic nu ne împiedică să creăm clasa noastră de implementare NetworkService
personalizată (sau batjocorită) și să injectăm clasa batjocorită în obiectul model de vizualizare.
Să creăm implementarea noastră batjocorită a protocolului NetworkService
.
Adăugați un nou fișier Swift la ținta noastră de testare făcând clic dreapta pe folderul TestingIOSTests
din Navigatorul de proiect, alegeți „Fișier nou”, selectați „Fișier Swift” și numiți-l NetworkServiceMock
.
Iată cum ar trebui să arate clasa noastră batjocorită:
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) }) } }
În acest moment, nu este mult diferit de implementarea noastră actuală ( NetworkServiceImpl
), dar într-o situație reală, NetworkServiceImpl
real ar avea un cod de rețea, gestionarea răspunsurilor și funcționalități similare.
Clasa noastră batjocorită nu face nimic, ceea ce este scopul unei clase batjocoritoare. Dacă nu face nimic, atunci nu va interfera cu testele noastre.
Pentru a remedia prima problemă a testului nostru, să ne actualizăm metoda de testare prin înlocuirea:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
cu:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())
Problema 2: Instanțiați registrationVM în corpul metodei de testare
Există metodele de tearDown
setUp
un motiv.
Aceste metode sunt folosite pentru a iniția sau a configura toate obiectele necesare unui test. Ar trebui să utilizați aceste metode pentru a evita duplicarea codului, scriind aceleași metode de inițializare sau de configurare în fiecare metodă de testare. Nu folosirea metodelor de configurare și tearDown nu este întotdeauna o problemă mare, mai ales dacă aveți o configurație cu adevărat specifică pentru o anumită metodă de testare.
Deoarece inițializarea clasei RegistrationViewModel
este destul de simplă, vă veți refactoriza clasa de testare pentru a utiliza metodele de configurare și tearDown.
RegistrationViewModelTests
ar trebui să arate astfel:
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: aveți mai multe afirmații într-o singură metodă de testare
Chiar dacă aceasta nu este o problemă mare, există unii susținători ai unei afirmații pentru fiecare metodă.
Raționamentul principal pentru acest principiu este detectarea erorilor.
Dacă o metodă de testare are mai multe afirmații și prima eșuează, întreaga metodă de testare va fi marcată ca eșuată. Alte afirmații nici măcar nu vor fi testate.
În acest fel, veți descoperi o singură eroare la un moment dat. Nu ai ști dacă alte afirmații vor eșua sau vor reuși.

Nu este întotdeauna un lucru rău să aveți mai multe afirmații într-o singură metodă, deoarece puteți remedia o singură eroare la un moment dat, așa că detectarea unei erori la un moment dat ar putea să nu fie o problemă atât de mare.
În cazul nostru, este testată validitatea unui format de e-mail. Deoarece aceasta este o singură funcție, ar putea fi mai logic să grupați toate afirmațiile într-o singură metodă pentru a face testul mai ușor de citit și de înțeles.
Deoarece această problemă nu este de fapt o problemă mare și unii ar putea chiar argumenta că nu este deloc o problemă, vă veți păstra metoda de testare așa cum este.
Când vă scrieți propriile teste unitare, rămâne la latitudinea dvs. să decideți ce cale doriți să urmați pentru fiecare metodă de testare. Cel mai probabil, veți descoperi că există locuri în care filozofia afirmată pe test are sens și altele unde nu.
Metode de testare cu apeluri asincrone
Indiferent cât de simplă este aplicația, există șanse mari să existe o metodă care trebuie să fie executată pe un alt fir de execuție asincron, mai ales că de obicei îți place ca interfața de utilizare să se execute în propriul thread.
Principala problemă a testării unitare și a apelurilor asincrone este că un apel asincron durează timp pentru a se finaliza, dar testul unitar nu va aștepta până când se termină. Deoarece testul unitar este terminat înainte de executarea oricărui cod din interiorul unui bloc asincron, testul nostru se va încheia întotdeauna cu același rezultat (indiferent de ceea ce scrieți în blocul asincron).
Pentru a demonstra acest lucru, să creăm un test pentru metoda 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") } }
Aici doriți să testați dacă o variabilă registrationEnabled va fi setată la fals după ce metoda noastră vă spune că e-mailul nu este disponibil (deja preluat de un alt utilizator).
Dacă rulați acest test, va trece. Dar mai încearcă încă un lucru. Schimbați-vă afirmația în:
XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")
Dacă rulați din nou testul, acesta trece din nou.
Asta pentru că afirmația noastră nici măcar nu a fost susținută. Testul unitar s-a încheiat înainte ca blocul de apel invers să fie executat (nu uitați, în implementarea serviciului nostru de rețea batjocorit, este setat să aștepte o secundă înainte de a reveni).
Din fericire, cu Xcode 6, Apple a adăugat așteptări de testare cadrului XCTest ca clasa XCTestExpectation
. Clasa XCTestExpectation
funcționează astfel:
- La începutul testului îți setezi așteptările la test - cu un text simplu care descrie ceea ce te-ai așteptat de la test.
- Într-un bloc asincron după executarea codului de testare, apoi îndepliniți așteptările.
- La sfârșitul testului, trebuie să setați blocul
waitForExpectationWithTimer
. Acesta va fi executat atunci când așteptările sunt îndeplinite sau dacă cronometrul se epuizează - oricare se întâmplă mai întâi. - Acum, testul unitar nu se va termina până când așteptările nu sunt îndeplinite sau până când cronometrul așteptărilor se epuizează.
Să rescriem testul pentru a folosi clasa 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") } } }
Dacă executați testul acum, acesta va eșua - așa cum ar trebui. Să reparăm testul ca să treacă. Schimbați afirmația în:
XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")
Rulați din nou testul pentru a vedea că trece. Puteți încerca să modificați timpul de întârziere în implementarea serviciului de rețea batjocorit pentru a vedea ce se întâmplă dacă cronometrul de așteptare expiră.
Metode de testare cu apeluri asincrone fără apel invers
Exemplul nostru de metodă de proiect attemptUserRegistration
utilizează metoda NetworkService.attemptRegistration
care include cod care este executat asincron. Metoda încearcă să înregistreze un utilizator cu serviciul backend.
În aplicația noastră demonstrativă, metoda va aștepta doar o secundă pentru a simula un apel de rețea și a falsifica înregistrarea cu succes. Dacă înregistrarea a avut succes, valoarea loginSuccessful
va fi setată la true. Să facem un test unitar pentru a verifica acest comportament.
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }
Dacă se rulează, acest test va eșua deoarece valoarea loginSuccessful
nu va fi setată la true până când metoda asincronă networkService.attemptRegistration
este terminată.
Deoarece ați creat un NetworkServiceImpl
batjocorit în care metoda attemptRegistration
de înregistrare va aștepta o secundă înainte de a returna o înregistrare reușită, puteți doar să utilizați Grand Central Dispatch (GCD) și să utilizați metoda asyncAfter
pentru a vă verifica afirmația după o secundă. După adăugarea asyncAfter
GCD-ului, codul nostru de testare va arăta astfel:
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") } }
Dacă ați acordat atenție, veți ști că acest lucru încă nu va funcționa, deoarece metoda de testare se va executa înainte ca blocul asyncAfter
fie executat și metoda va trece întotdeauna cu succes ca rezultat. Din fericire, există clasa XCTestException
.
Să rescriem metoda noastră pentru a folosi clasa 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") } } }
Cu testele unitare care acoperă RegistrationViewModel
, acum puteți fi mai încrezător că adăugarea de funcționalități noi sau actualizarea existente nu va distruge nimic.
Notă importantă: Testele unitare își vor pierde valoarea dacă nu sunt actualizate atunci când funcționalitatea metodelor pe care le acoperă se modifică. Scrierea testelor unitare este un proces care trebuie să țină pasul cu restul aplicației.
Sfat: Nu amânați testele de scriere până la sfârșit. Scrieți teste în timpul dezvoltării. În acest fel, veți avea o mai bună înțelegere a ceea ce trebuie testat și care sunt cazurile de frontieră.
Scrierea testelor UI
După ce toate testele unitare sunt complet dezvoltate și executate cu succes, puteți fi foarte sigur că fiecare unitate de cod funcționează corect, dar înseamnă că aplicația dvs. în ansamblu funcționează conform intenției?
Aici intervin testele de integrare, dintre care testele UI sunt o componentă esențială.
Înainte de a începe cu testarea interfeței de utilizare, trebuie să existe câteva elemente și interacțiuni ale interfeței de utilizator (sau povești de utilizator) de testat. Să creăm o vizualizare simplă și controlerul său de vizualizare.
- Deschideți
Main.storyboard
și creați un controler de vizualizare simplu care va arăta ca cel din imaginea de mai jos.
Setați eticheta câmpului text de e-mail la 100, eticheta câmpului text al parolei la 101 și eticheta de confirmare a parolei la 102.
- Adăugați un nou fișier controler de vizualizare
RegistrationViewController.swift
și conectați toate prizele cu storyboard-ul.
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() } }
Aici adăugați IBOutlets
și o structură TextFieldTags
la clasă.
Acest lucru vă va permite să identificați ce câmp de text este editat. Pentru a utiliza proprietățile dinamice din modelul de vizualizare, trebuie să „legați” proprietățile dinamice în controlerul de vizualizare. Puteți face asta în metoda bindViewModel
:
fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }
Să adăugăm acum o metodă de delegare a câmpurilor de text pentru a ține evidența când oricare dintre câmpurile de text este actualizat:
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 }
- Actualizați
AppDelegate
pentru a lega controlerul de vizualizare la modelul de vizualizare corespunzător (rețineți că acest pas este o cerință a arhitecturii MVVM). CodulAppDelegate
actualizat ar trebui să arate astfel:
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 } }
Fișierul storyboard și RegistrationViewController
sunt cu adevărat simple, dar sunt adecvate pentru a demonstra modul în care funcționează testarea automată a UI.
Dacă totul este configurat corect, butonul de înregistrare ar trebui să fie dezactivat când pornește aplicația. Când și numai când toate câmpurile sunt completate și valide, butonul de înregistrare ar trebui să fie activat.
Odată configurat, puteți crea primul test de interfață.
Testul nostru de interfață ar trebui să verifice dacă butonul Înregistrare va deveni activat dacă și numai dacă au fost introduse o adresă de e-mail validă, o parolă validă și o confirmare validă a parolei. Iată cum să configurați acest lucru:
- Deschideți fișierul
TestingIOSUITests.swift
. - Ștergeți metoda
testExample()
și adăugați o metodătestRegistrationButtonEnabled()
. - Puneți cursorul în metoda
testRegistrationButtonEnabled
ca și cum ați scrie ceva acolo. - Apăsați butonul de testare Record UI (cerc roșu în partea de jos a ecranului).
- Când se apasă butonul Înregistrare, aplicația va fi lansată
- După ce aplicația este lansată, apăsați în câmpul de text al e-mailului și scrieți „[email protected]”. Veți observa că codul apare automat în corpul metodei de testare.
Puteți înregistra toate instrucțiunile UI folosind această funcție, dar este posibil să descoperiți că scrierea manuală a instrucțiunilor simple va fi mult mai rapidă.
Acesta este un exemplu de instrucțiune de înregistrare pentru atingerea unui câmp de text pentru parolă și introducerea unei adrese de e-mail „[email protected]”
let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
- După ce interacțiunile UI pe care doriți să le testați au fost înregistrate, apăsați din nou butonul de oprire (eticheta butonului de înregistrare s-a schimbat pentru a opri când ați început înregistrarea) pentru a opri înregistrarea.
- După ce aveți înregistratorul de interacțiuni cu UI, acum puteți adăuga diverse
XCTAsserts
pentru a testa diferite stări ale aplicației sau ale elementelor UI.
Instrucțiunile înregistrate nu sunt întotdeauna explicative și ar putea chiar să îngreuneze întreaga metodă de testare. Din fericire, puteți introduce manual instrucțiunile UI.
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.