So schreiben Sie automatisierte Tests für iOS
Veröffentlicht: 2022-03-11Als guter Entwickler tun Sie Ihr Bestes, um alle Funktionen und alle möglichen Codepfade und -ergebnisse in der von Ihnen geschriebenen Software zu testen. Aber es ist äußerst selten und ungewöhnlich, jedes mögliche Ergebnis und jeden möglichen Weg, den ein Benutzer einschlagen könnte, manuell testen zu können.
Je größer und komplexer die Anwendung wird, desto größer wird die Wahrscheinlichkeit, dass Sie durch manuelles Testen etwas übersehen.
Automatisierte Tests, sowohl der Benutzeroberfläche als auch der Back-End-Service-APIs, geben Ihnen mehr Sicherheit, dass alles wie beabsichtigt funktioniert, und reduzieren den Stress beim Entwickeln, Refactoring, Hinzufügen neuer Funktionen oder Ändern vorhandener Funktionen.
Mit automatisierten Tests können Sie:
- Fehler reduzieren: Es gibt keine Methode, die alle möglichen Fehler in Ihrem Code vollständig beseitigt, aber automatisierte Tests können die Anzahl der Fehler erheblich reduzieren.
- Souverän Änderungen vornehmen: Vermeiden Sie Fehler beim Hinzufügen neuer Funktionen, was bedeutet, dass Sie Änderungen schnell und problemlos vornehmen können.
- Dokumentieren Sie unseren Code: Wenn wir Tests durchsehen, können wir klar sehen, was von bestimmten Funktionen erwartet wird, was Bedingungen sind und was Eckfälle sind.
- Problemloses Refactoring: Als Entwickler haben Sie vielleicht manchmal Angst vor dem Refactoring, insbesondere wenn Sie einen großen Codeabschnitt umgestalten müssen. Komponententests sollen sicherstellen, dass der umgestaltete Code weiterhin wie beabsichtigt funktioniert.
In diesem Artikel erfahren Sie, wie Sie automatisierte Tests auf der iOS-Plattform strukturieren und ausführen.
Unit-Tests vs. UI-Tests
Es ist wichtig, zwischen Unit- und UI-Tests zu unterscheiden.
Ein Unit-Test testet eine bestimmte Funktion in einem bestimmten Kontext . Komponententests überprüfen, ob der getestete Teil des Codes (normalerweise eine einzelne Funktion) das tut, was er tun soll. Es gibt viele Bücher und Artikel über Komponententests, daher werden wir das in diesem Beitrag nicht behandeln.
UI-Tests dienen zum Testen der Benutzeroberfläche. So können Sie beispielsweise testen, ob eine Ansicht wie beabsichtigt aktualisiert oder eine bestimmte Aktion ausgelöst wird, wie es sein sollte, wenn der Benutzer mit einem bestimmten UI-Element interagiert.
Jeder UI-Test testet eine bestimmte Benutzerinteraktion mit der UI der Anwendung. Automatisierte Tests können und sollten sowohl auf Unit-Test- als auch auf UI-Testebene durchgeführt werden.
Automatisierte Tests einrichten
Da XCode Unit- und UI-Tests standardmäßig unterstützt, ist es einfach und unkompliziert, sie zu Ihrem Projekt hinzuzufügen. Aktivieren Sie beim Erstellen eines neuen Projekts einfach „Unit-Tests einbeziehen“ und „UI-Tests einbeziehen“.
Wenn das Projekt erstellt wird, werden Ihrem Projekt zwei neue Ziele hinzugefügt, wenn diese beiden Optionen aktiviert wurden. Bei neuen Zielnamen wird am Ende des Namens „Tests“ oder „UITests“ angehängt.
Das ist es. Sie sind bereit, automatisierte Tests für Ihr Projekt zu schreiben.
Wenn Sie bereits ein bestehendes Projekt haben und die Unterstützung für UI- und Unit-Tests hinzufügen möchten, müssen Sie etwas mehr Arbeit leisten, aber es ist auch sehr unkompliziert und einfach.
Gehen Sie zu Datei → Neu → Ziel und wählen Sie iOS Unit Testing Bundle für Unit-Tests oder iOS UI Testing Bundle für UI-Tests aus.
Drücken Sie Weiter .
Auf dem Zieloptionsbildschirm können Sie alles so lassen, wie es ist (wenn Sie mehrere Ziele haben und nur bestimmte Ziele testen möchten, wählen Sie das Ziel in der Dropdown-Liste Zu testendes Ziel aus).
Drücken Sie Fertig . Wiederholen Sie diesen Schritt für UI-Tests, und Sie haben alles bereit, um mit dem Schreiben automatisierter Tests in Ihrem bestehenden Projekt zu beginnen.
Unit-Tests schreiben
Bevor wir mit dem Schreiben von Komponententests beginnen können, müssen wir ihre Anatomie verstehen. Wenn Sie Komponententests in Ihr Projekt aufnehmen, wird eine Beispieltestklasse erstellt. In unserem Fall sieht das so aus:
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. } } }
Die wichtigsten zu verstehenden Methoden sind setUp
und tearDown
. Die setUp
Methode wird vor jeder Testmethode aufgerufen, während die tearDown
-Methode nach jeder Testmethode aufgerufen wird. Wenn wir Tests ausführen, die in dieser Beispieltestklasse definiert sind, würden die Methoden wie folgt ausgeführt:
setUp → testExample → tearDown setUp → testPerformanceExample → tearDown
Tipp: Tests werden ausgeführt, indem Sie cmd + U drücken, Produkt → Test auswählen oder auf die Schaltfläche Ausführen klicken und sie gedrückt halten, bis das Optionsmenü angezeigt wird, und dann Test aus dem Menü auswählen.
Wenn Sie nur eine bestimmte Testmethode ausführen möchten, drücken Sie auf die Schaltfläche links neben dem Namen der Methode (siehe Abbildung unten).
Wenn Sie nun alles bereit haben, um Tests zu schreiben, können Sie eine Beispielklasse und einige Methoden zum Testen hinzufügen.
Fügen Sie eine Klasse hinzu, die für die Benutzerregistrierung verantwortlich ist. Ein Benutzer gibt eine E-Mail-Adresse, ein Passwort und eine Passwortbestätigung ein. Unsere Beispielklasse validiert die Eingabe, prüft die Verfügbarkeit der E-Mail-Adresse und versucht die Benutzerregistrierung.
Hinweis: Dieses Beispiel verwendet das Architekturmuster MVVM (oder Model-View-ViewModel).
MVVM wird verwendet, weil es die Architektur einer Anwendung sauberer und einfacher zu testen macht.
Mit MVVM ist es einfacher, die Geschäftslogik von der Präsentationslogik zu trennen und so massive View-Controller-Probleme zu vermeiden.
Details zur MVVM-Architektur gehen über den Rahmen dieses Artikels hinaus, aber Sie können mehr darüber in diesem Artikel lesen.
Lassen Sie uns eine View-Model-Klasse erstellen, die für die Benutzerregistrierung verantwortlich ist. .
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 } }
Zuerst haben wir einige Eigenschaften, dynamische Eigenschaften und eine Init-Methode hinzugefügt.
Machen Sie sich keine Sorgen um den Dynamic
Typ. Es ist Teil der MVVM-Architektur.
Wenn ein Dynamic<Bool>
-Wert auf true festgelegt ist, aktiviert ein Ansichtscontroller, der an das RegistrationViewModel
gebunden (verbunden) ist, die Registrierungsschaltfläche. Wenn loginSuccessful
auf „true“ gesetzt ist, aktualisiert sich die verbundene Ansicht selbst.
Lassen Sie uns nun einige Methoden hinzufügen, um die Gültigkeit des Passworts und des E-Mail-Formats zu überprüfen.
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 }
Jedes Mal, wenn der Benutzer etwas in das E-Mail- oder das Passwortfeld eingibt, prüft die enableRegistrationAttempt
Methode, ob eine E-Mail und ein Passwort das richtige Format haben, und aktiviert oder deaktiviert die Registrierungsschaltfläche über die dynamische Eigenschaft registrationEnabled
.
Um das Beispiel einfach zu halten, fügen Sie zwei einfache Methoden hinzu – eine, um die Verfügbarkeit einer E-Mail zu überprüfen, und eine, um die Registrierung mit dem angegebenen Benutzernamen und Passwort zu versuchen.
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 } } }
Diese beiden Methoden verwenden den NetworkService, um zu prüfen, ob eine E-Mail verfügbar ist, und um die Registrierung zu versuchen.
Um dieses Beispiel einfach zu halten, verwendet die NetworkService-Implementierung keine Back-End-API, sondern ist nur ein Stub, der die Ergebnisse vortäuscht. NetworkService wird als Protokoll und seine Implementierungsklasse implementiert.
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 ist ein sehr einfaches Protokoll, das nur zwei Methoden enthält: Registrierungsversuch und Methoden zur Überprüfung der E-Mail-Verfügbarkeit. Die Protokollimplementierung ist die NetworkServiceImpl-Klasse.
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) }) } }
Beide Methoden warten einfach einige Zeit (vortäuschen der Zeitverzögerung einer Netzwerkanfrage) und rufen dann die entsprechenden Callback-Methoden auf.
Tipp: Es hat sich bewährt, Protokolle (in anderen Programmiersprachen auch als Schnittstellen bezeichnet) zu verwenden. Sie können mehr darüber lesen, wenn Sie nach dem Prinzip „Programmierung nach Schnittstellen“ suchen. Sie werden auch sehen, wie gut es mit Unit-Tests funktioniert.
Wenn nun ein Beispiel festgelegt ist, können wir Komponententests schreiben, um Methoden dieser Klasse abzudecken.
Erstellen Sie eine neue Testklasse für unser Ansichtsmodell. Klicken Sie mit der rechten Maustaste auf den Ordner
TestingIOSTests
im Bereich Project Navigator, wählen Sie New File → Unit Test Case Class aus und nennen Sie esRegistrationViewModelTests
.Löschen Sie die Methoden
testExample
undtestPerformanceExample
, da wir unsere eigenen Testmethoden erstellen möchten.Da Swift Module verwendet und unsere Tests sich in einem anderen Modul befinden als der Code unserer Anwendung, müssen wir das Modul unserer Anwendung als
@testable
. Fügen Sie unter der import-Anweisung und der Klassendefinition@testable import TestingIOS
(oder den Modulnamen Ihrer Anwendung) hinzu. Ohne dies wären wir nicht in der Lage, auf Klassen oder Methoden unserer Anwendung zu verweisen.Fügen Sie die Variable
registrationViewModel
hinzu.
So sieht unsere leere Testklasse jetzt aus:
import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }
Versuchen wir, einen Test für die Methode emailValid
zu schreiben. Wir erstellen eine neue Testmethode namens testEmailValid
. Es ist wichtig, das Schlüsselwort test
am Anfang des Namens hinzuzufügen. Andernfalls wird die Methode nicht als Testmethode anerkannt.
Unsere Testmethode sieht so aus:
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") }
Unsere Testmethode verwendet eine Assertionsmethode, XCTAssert
, die in unserem Fall prüft, ob eine Bedingung wahr oder falsch ist.
Wenn die Bedingung falsch ist, wird assert (zusammen mit dem Test) fehlschlagen und unsere Nachricht wird ausgeschrieben.
Es gibt viele Assert-Methoden, die Sie in Ihren Tests verwenden können. Das Beschreiben und Zeigen jeder Assert-Methode kann leicht einen eigenen Artikel bilden, daher werde ich hier nicht ins Detail gehen.
Einige Beispiele für verfügbare Assert-Methoden sind: XCTAssertEqualObjects
, XCTAssertGreaterThan
, XCTAssertNil
, XCTAssertTrue
oder XCTAssertThrows
.
Hier können Sie mehr über verfügbare Assert-Methoden lesen.
Wenn Sie den Test jetzt ausführen, wird die Testmethode bestanden. Sie haben Ihre erste Testmethode erfolgreich erstellt, aber sie ist noch nicht ganz bereit für die Hauptsendezeit. Diese Testmethode hat noch drei Probleme (ein großes und zwei kleinere), wie unten beschrieben.
Problem 1: Sie verwenden die echte Implementierung des NetworkService-Protokolls
Eines der Kernprinzipien von Unit-Tests ist, dass jeder Test unabhängig von äußeren Faktoren oder Abhängigkeiten sein sollte. Komponententests sollten atomar sein.
Wenn Sie eine Methode testen, die irgendwann eine API-Methode vom Server aufruft, hängt Ihr Test von Ihrem Netzwerkcode und der Verfügbarkeit des Servers ab. Wenn der Server zum Zeitpunkt des Tests nicht funktioniert, schlägt Ihr Test fehl, wodurch Ihrer getesteten Methode fälschlicherweise vorgeworfen wird, nicht zu funktionieren.
In diesem Fall testen Sie eine Methode des RegistrationViewModel
.
RegistrationViewModel
hängt von der NetworkServiceImpl
-Klasse ab, obwohl Sie wissen, dass Ihre getestete Methode emailValid
nicht direkt von NetworkServiceImpl
abhängt.
Beim Schreiben von Komponententests sollten alle externen Abhängigkeiten entfernt werden. Aber wie sollten Sie die NetworkService-Abhängigkeit entfernen, ohne die Implementierung der RegistrationViewModel
-Klasse zu ändern?
Es gibt eine einfache Lösung für dieses Problem, und sie heißt Object Mocking . Wenn Sie sich das RegistrationViewModel
genau ansehen, werden Sie feststellen, dass es tatsächlich vom NetworkService
-Protokoll abhängt.
class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...
Wenn das RegistrationViewModel
initialisiert wird, wird dem RegistrationViewModel
-Objekt eine Implementierung des NetworkService
-Protokolls gegeben (oder injiziert).
Dieses Prinzip wird Abhängigkeitsinjektion über den Konstruktor genannt ( es gibt mehr Arten von Abhängigkeitsinjektionen ).
Es gibt viele interessante Artikel über Abhängigkeitsinjektion online, wie zum Beispiel diesen Artikel auf objc.io.
Es gibt auch einen kurzen, aber interessanten Artikel, der Abhängigkeitsinjektion auf einfache und unkomplizierte Weise erklärt hier.
Darüber hinaus ist im Toptal-Blog ein großartiger Artikel über das Prinzip der Einzelverantwortung und DI verfügbar.
Wenn das RegistrationViewModel
instanziiert wird, fügt es eine NetworkService-Protokollimplementierung in seinen Konstruktor ein (daher der Name des Abhängigkeitsinjektionsprinzips):
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
Da unsere Ansichtsmodellklasse nur vom Protokoll abhängt, hindert uns nichts daran, unsere benutzerdefinierte (oder verspottete) NetworkService
-Implementierungsklasse zu erstellen und die verspottete Klasse in unser Ansichtsmodellobjekt einzufügen.
Lassen Sie uns unsere verspottete NetworkService
-Protokollimplementierung erstellen.
Fügen Sie unserem Testziel eine neue Swift-Datei hinzu, indem Sie im Projektnavigator mit der rechten Maustaste auf den Ordner TestingIOSTests
klicken, „Neue Datei“ auswählen, „Swift-Datei“ auswählen und ihr den Namen NetworkServiceMock
.
So sollte unsere verspottete Klasse aussehen:
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) }) } }
An diesem Punkt unterscheidet es sich nicht wesentlich von unserer tatsächlichen Implementierung ( NetworkServiceImpl
), aber in einer realen Situation hätte das tatsächliche NetworkServiceImpl
einen Netzwerkcode, Antwortbehandlung und ähnliche Funktionen.
Unsere verspottete Klasse tut nichts, was der Sinn einer verspotteten Klasse ist. Wenn es nichts tut, wird es unsere Tests nicht stören.
Um das erste Problem unseres Tests zu beheben, aktualisieren wir unsere Testmethode, indem wir Folgendes ersetzen:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
mit:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())
Problem 2: Sie instanziieren die Registrierungs-VM im Hauptteil der Testmethode
Es gibt die Methoden setUp
und tearDown
aus einem bestimmten Grund.
Diese Methoden werden verwendet, um alle erforderlichen Objekte, die in einem Test benötigt werden, zu initialisieren oder einzurichten. Sie sollten diese Methoden verwenden, um Codeduplizierung zu vermeiden, indem Sie in jeder Testmethode dieselben Init- oder Setup-Methoden schreiben. Die Nichtverwendung von Setup- und TearDown-Methoden ist nicht immer ein großes Problem, insbesondere wenn Sie eine wirklich spezifische Konfiguration für eine bestimmte Testmethode haben.
Da unsere Initialisierung der RegistrationViewModel
-Klasse ziemlich einfach ist, werden Sie Ihre Testklasse so umgestalten, dass sie die setup- und tearDown-Methoden verwendet.
RegistrationViewModelTests
sollten wie folgt aussehen:
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") ... } }
Problem 3: Sie haben mehrere Assertionen in einer Testmethode
Auch wenn dies kein großes Problem ist, gibt es einige Befürworter eines Asserts pro Methode.
Der Hauptgrund für dieses Prinzip ist die Fehlererkennung.
Wenn eine Testmethode mehrere Assertionen hat und die erste fehlschlägt, wird die gesamte Testmethode als fehlgeschlagen markiert. Andere Assertionen werden nicht einmal getestet.

Auf diese Weise würden Sie jeweils nur einen Fehler entdecken. Sie würden nicht wissen, ob andere Behauptungen scheitern oder erfolgreich sein würden.
Es ist nicht immer schlecht, mehrere Asserts in einer Methode zu haben, da Sie jeweils nur einen Fehler beheben können, sodass es möglicherweise kein so großes Problem ist, jeweils einen Fehler zu erkennen.
In unserem Fall wird eine Gültigkeit eines E-Mail-Formats getestet. Da dies nur eine Funktion ist, könnte es logischer sein, alle Asserts in einer Methode zusammenzufassen, um den Test leichter lesbar und verständlich zu machen.
Da dieses Problem eigentlich kein großes Problem ist und einige vielleicht sogar argumentieren, dass es überhaupt kein Problem ist, behalten Sie Ihre Testmethode so bei, wie sie ist.
Wenn Sie eigene Unit-Tests schreiben, ist es Ihnen überlassen, welchen Weg Sie für jede Testmethode einschlagen möchten. Höchstwahrscheinlich werden Sie feststellen, dass es Stellen gibt, an denen die eine Behauptung pro Testphilosophie sinnvoll ist, und andere, an denen dies nicht der Fall ist.
Testmethoden mit asynchronen Aufrufen
Unabhängig davon, wie einfach die Anwendung ist, besteht eine hohe Wahrscheinlichkeit, dass eine Methode asynchron in einem anderen Thread ausgeführt werden muss, insbesondere da Sie normalerweise möchten, dass die Benutzeroberfläche in einem eigenen Thread ausgeführt wird.
Das Hauptproblem bei Komponententests und asynchronen Aufrufen besteht darin, dass ein asynchroner Aufruf einige Zeit in Anspruch nimmt, aber der Komponententest wartet nicht, bis er abgeschlossen ist. Da der Komponententest abgeschlossen ist, bevor der Code innerhalb eines asynchronen Blocks ausgeführt wird, endet unser Test immer mit demselben Ergebnis (egal, was Sie in Ihren asynchronen Block schreiben).
Um dies zu demonstrieren, erstellen wir einen Test für die Methode 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") } }
Hier möchten Sie testen, ob eine registrationEnabled-Variable auf „false“ gesetzt wird, nachdem unsere Methode Ihnen mitteilt, dass die E-Mail nicht verfügbar ist (bereits von einem anderen Benutzer verwendet).
Wenn Sie diesen Test ausführen, wird er bestanden. Aber probiere einfach noch eins aus. Ändern Sie Ihre Behauptung in:
XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")
Wenn Sie den Test erneut ausführen, wird er erneut bestanden.
Das liegt daran, dass unser Assert nicht einmal behauptet wurde. Der Komponententest endete, bevor der Callback-Block ausgeführt wurde (denken Sie daran, dass er in unserer simulierten Netzwerkdienstimplementierung so eingestellt ist, dass er eine Sekunde wartet, bevor er zurückkehrt).
Glücklicherweise hat Apple mit Xcode 6 Testerwartungen als XCTestExpectation
-Klasse zum XCTest-Framework hinzugefügt. Die Klasse XCTestExpectation
funktioniert wie folgt:
- Zu Beginn des Tests legen Sie Ihre Testerwartung fest - mit einem einfachen Text, der beschreibt, was Sie von dem Test erwarten.
- In einem asynchronen Block, nachdem Ihr Testcode ausgeführt wurde, erfüllen Sie dann die Erwartung.
- Am Ende des Tests müssen Sie den Block
waitForExpectationWithTimer
setzen. Es wird ausgeführt, wenn die Erwartung erfüllt ist oder der Timer abgelaufen ist – je nachdem, was zuerst eintritt. - Jetzt wird der Komponententest erst beendet, wenn die Erwartung erfüllt ist oder der Erwartungs-Timer abgelaufen ist.
Lassen Sie uns unseren Test so umschreiben, dass er die XCTestExpectation
-Klasse verwendet.
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") } } }
Wenn Sie den Test jetzt ausführen, schlägt er fehl - wie er sollte. Lassen Sie uns den Test reparieren, damit er besteht. Ändere das Assert zu:
XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")
Führen Sie den Test erneut aus, um zu sehen, ob er bestanden wird. Sie können versuchen, die Verzögerungszeit in der verspotteten Implementierung des Netzwerkdienstes zu ändern, um zu sehen, was passiert, wenn der Erwartungszeitgeber abläuft.
Testen von Methoden mit asynchronen Aufrufen ohne Rückruf
Unsere Beispielprojektmethode attemptUserRegistration
verwendet die NetworkService.attemptRegistration
-Methode, die Code enthält, der asynchron ausgeführt wird. Die Methode versucht, einen Benutzer beim Back-End-Dienst zu registrieren.
In unserer Demoanwendung wartet die Methode nur eine Sekunde, um einen Netzwerkanruf zu simulieren und eine erfolgreiche Registrierung vorzutäuschen. Wenn die Registrierung erfolgreich war, wird der Wert loginSuccessful
auf true gesetzt. Lassen Sie uns einen Komponententest durchführen, um dieses Verhalten zu überprüfen.
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }
Wenn dieser Test ausgeführt wird, schlägt dieser Test fehl, da der Wert loginSuccessful
nicht auf „true“ gesetzt wird, bis die asynchrone Methode „ networkService.attemptRegistration
“ abgeschlossen ist.
Da Sie ein mockiertes NetworkServiceImpl
erstellt haben, bei dem die Methode attemptRegistration
eine Sekunde wartet, bevor sie eine erfolgreiche Registrierung zurückgibt, können Sie einfach Grand Central Dispatch (GCD) verwenden und die Methode asyncAfter
verwenden, um Ihre Behauptung nach einer Sekunde zu überprüfen. Nach dem Hinzufügen des asyncAfter der GCD sieht unser asyncAfter
so aus:
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") } }
Wenn Sie darauf geachtet haben, wissen Sie, dass dies immer noch nicht funktioniert, da die Testmethode ausgeführt wird, bevor der asyncAfter
Block ausgeführt wird, und die Methode als Ergebnis immer erfolgreich bestanden wird. Glücklicherweise gibt es die Klasse XCTestException
.
Schreiben wir unsere Methode neu, um die Klasse XCTestException
zu verwenden:
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") } } }
Mit Einheitentests, die unser RegistrationViewModel
abdecken, können Sie jetzt sicherer sein, dass das Hinzufügen neuer oder das Aktualisieren vorhandener Funktionen nichts beschädigt.
Wichtiger Hinweis: Komponententests verlieren ihren Wert, wenn sie nicht aktualisiert werden, wenn sich die Funktionalität der Methoden, die sie abdecken, ändert. Das Schreiben von Unit-Tests ist ein Prozess, der mit dem Rest der Anwendung Schritt halten muss.
Tipp: Schieben Sie Schreibtests nicht bis zum Ende auf. Schreiben Sie während der Entwicklung Tests. Auf diese Weise haben Sie ein besseres Verständnis dafür, was getestet werden muss und welche Grenzfälle es gibt.
Schreiben von UI-Tests
Nachdem alle Komponententests vollständig entwickelt und erfolgreich ausgeführt wurden, können Sie sehr sicher sein, dass jede Codeeinheit korrekt funktioniert, aber bedeutet das, dass Ihre Anwendung als Ganzes wie beabsichtigt funktioniert?
Hier kommen Integrationstests ins Spiel, von denen UI-Tests ein wesentlicher Bestandteil sind.
Bevor Sie mit UI-Tests beginnen, müssen einige UI-Elemente und Interaktionen (oder User Stories) getestet werden. Lassen Sie uns eine einfache Ansicht und ihren Ansichtscontroller erstellen.
- Öffnen Sie das
Main.storyboard
und erstellen Sie einen einfachen View-Controller, der wie im Bild unten aussieht.
Setzen Sie das E-Mail-Textfeld-Tag auf 100, das Passwort-Textfeld-Tag auf 101 und das Passwort-Bestätigungs-Tag auf 102.
- Fügen Sie eine neue View-Controller-Datei
RegistrationViewController.swift
und verbinden Sie alle Outlets mit dem 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() } }
Hier fügen Sie der Klasse IBOutlets
und eine TextFieldTags
Struktur hinzu.
Dadurch können Sie erkennen, welches Textfeld bearbeitet wird. Um die dynamischen Eigenschaften im Ansichtsmodell zu nutzen, müssen Sie dynamische Eigenschaften im Ansichtscontroller 'binden'. Sie können dies in der bindViewModel
Methode tun:
fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }
Lassen Sie uns nun eine Textfeld-Delegatmethode hinzufügen, um zu verfolgen, wann eines der Textfelder aktualisiert wird:
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 }
- Aktualisieren
AppDelegate
, um den Ansichtscontroller an das entsprechende Ansichtsmodell zu binden (beachten Sie, dass dieser Schritt eine Anforderung der MVVM-Architektur ist). Der aktualisierteAppDelegate
-Code sollte dann so aussehen:
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 } }
Die Storyboard-Datei und der RegistrationViewController
sind wirklich einfach, aber sie reichen aus, um zu demonstrieren, wie automatisierte UI-Tests funktionieren.
Wenn alles richtig eingerichtet ist, sollte die Registrierungsschaltfläche beim Start der App deaktiviert sein. Wenn und nur wenn alle Felder ausgefüllt und gültig sind, sollte die Registrierungsschaltfläche aktiviert werden.
Sobald dies eingerichtet ist, können Sie Ihren ersten UI-Test erstellen.
Unser UI-Test sollte überprüfen, ob die Schaltfläche „Registrieren“ nur dann aktiviert wird, wenn eine gültige E-Mail-Adresse, ein gültiges Passwort und eine gültige Passwortbestätigung eingegeben wurden. So richten Sie das ein:
- Öffnen Sie die Datei
TestingIOSUITests.swift
. - Löschen Sie die Methode
testExample()
und fügen Sie eine MethodetestRegistrationButtonEnabled()
hinzu. - Setzen Sie den Cursor in die
testRegistrationButtonEnabled
Methode, als würden Sie dort etwas schreiben. - Drücken Sie die Schaltfläche UI-Test aufzeichnen (roter Kreis unten auf dem Bildschirm).
- Wenn die Aufnahmetaste gedrückt wird, wird die Anwendung gestartet
- Nachdem die Anwendung gestartet wurde, tippen Sie in das E-Mail-Textfeld und schreiben Sie „[email protected]“. Sie werden feststellen, dass der Code automatisch im Hauptteil der Testmethode erscheint.
Sie können alle UI-Anweisungen mit dieser Funktion aufzeichnen, aber Sie werden möglicherweise feststellen, dass das manuelle Schreiben einfacher Anweisungen viel schneller ist.
Dies ist ein Beispiel für eine Recorder-Anweisung zum Tippen auf ein Passwort-Textfeld und Eingeben einer E-Mail-Adresse „[email protected]“.
let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
- Nachdem die UI-Interaktionen, die Sie testen möchten, aufgezeichnet wurden, drücken Sie erneut die Stopptaste (die Beschriftung der Aufnahmetaste änderte sich zu Stopp, als Sie mit der Aufzeichnung begannen), um die Aufzeichnung zu stoppen.
- Nachdem Sie Ihren UI-Interaktionsrekorder erstellt haben, können Sie nun verschiedene
XCTAsserts
hinzufügen, um verschiedene Zustände der Anwendung oder UI-Elemente zu testen.
Aufgezeichnete Anweisungen sind nicht immer selbsterklärend und können sogar das gesamte Testverfahren etwas schwer lesbar und verständlich machen. Glücklicherweise können Sie UI-Anweisungen manuell eingeben.
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.