Jak pisać testy automatyczne na iOS
Opublikowany: 2022-03-11Jako dobry programista dokładasz wszelkich starań, aby przetestować całą funkcjonalność i każdą możliwą ścieżkę kodu oraz wynik w oprogramowaniu, które piszesz. Jednak możliwość ręcznego przetestowania każdego możliwego wyniku i każdej możliwej ścieżki, jaką użytkownik może obrać, jest niezwykle rzadka i niezwykła.
W miarę jak aplikacja staje się coraz większa i bardziej złożona, prawdopodobieństwo, że coś przeoczysz podczas ręcznego testowania, znacznie wzrasta.
Zautomatyzowane testowanie, zarówno interfejsu użytkownika, jak i interfejsów API usług zaplecza, da Ci większą pewność, że wszystko działa zgodnie z założeniami i zmniejszy stres podczas opracowywania, refaktoryzacji, dodawania nowych funkcji lub zmiany istniejących.
Dzięki testom automatycznym możesz:
- Redukcja błędów: Nie ma metody, która całkowicie usunie możliwość wystąpienia błędów w kodzie, ale automatyczne testy mogą znacznie zmniejszyć liczbę błędów.
- Pewnie wprowadzaj zmiany: unikaj błędów podczas dodawania nowych funkcji, co oznacza, że możesz wprowadzać zmiany szybko i bezboleśnie.
- Dokumentuj nasz kod: Przeglądając testy, możemy wyraźnie zobaczyć, czego oczekuje się od niektórych funkcji, jakie są warunki, a jakie przypadki narożnikowe.
- Refaktoryzacja bezboleśnie: Jako programista możesz czasem obawiać się refaktoryzacji, zwłaszcza jeśli potrzebujesz zrefaktoryzować duży fragment kodu. Testy jednostkowe są tutaj, aby upewnić się, że zrefaktoryzowany kod nadal działa zgodnie z przeznaczeniem.
Z tego artykułu dowiesz się, jak organizować i przeprowadzać testy automatyczne na platformie iOS.
Testy jednostkowe a testy interfejsu użytkownika
Ważne jest, aby rozróżnić testy jednostkowe i testy interfejsu użytkownika.
Test jednostkowy testuje określoną funkcję w określonym kontekście . Testy jednostkowe sprawdzają, czy testowana część kodu (zwykle pojedyncza funkcja) robi to, co powinna. Istnieje wiele książek i artykułów o testach jednostkowych, więc nie będziemy tego omawiać w tym poście.
Testy interfejsu użytkownika służą do testowania interfejsu użytkownika. Na przykład pozwala przetestować, czy widok jest aktualizowany zgodnie z zamierzeniami, czy określona akcja jest wyzwalana tak, jak powinna, gdy użytkownik wchodzi w interakcję z określonym elementem interfejsu użytkownika.
Każdy test interfejsu użytkownika testuje określoną interakcję użytkownika z interfejsem użytkownika aplikacji. Testowanie automatyczne może i powinno być wykonywane zarówno na poziomie testu jednostkowego, jak i testu interfejsu użytkownika.
Konfigurowanie testów automatycznych
Ponieważ XCode obsługuje testy jednostkowe i interfejsów użytkownika od razu, dodanie ich do projektu jest łatwe i proste. Tworząc nowy projekt, po prostu zaznacz „Dołącz testy jednostkowe” i „Dołącz testy interfejsu użytkownika”.
Po utworzeniu projektu, po zaznaczeniu tych dwóch opcji, do projektu zostaną dodane dwa nowe cele. Nowe nazwy docelowe mają na końcu dodany tekst „Tests” lub „UITests”.
Otóż to. Jesteś gotowy do pisania testów automatycznych dla swojego projektu.
Jeśli masz już istniejący projekt i chcesz dodać obsługę UI i testów jednostkowych, będziesz musiał wykonać trochę więcej pracy, ale jest to również bardzo proste i proste.
Przejdź do Plik → Nowy → Cel i wybierz Pakiet testów jednostkowych iOS dla testów jednostkowych lub Pakiet testów interfejsu użytkownika iOS dla testów interfejsu użytkownika.
Naciśnij Dalej .
Na ekranie opcji celu możesz pozostawić wszystko bez zmian (jeśli masz wiele celów i chcesz przetestować tylko określone cele, wybierz cel z listy rozwijanej Cel do przetestowania).
Naciśnij Zakończ . Powtórz ten krok dla testów interfejsu użytkownika, a wszystko będzie gotowe do rozpoczęcia pisania testów automatycznych w istniejącym projekcie.
Pisanie testów jednostkowych
Zanim zaczniemy pisać testy jednostkowe, musimy zrozumieć ich anatomię. Po dołączeniu testów jednostkowych do projektu zostanie utworzona przykładowa klasa testowa. W naszym przypadku będzie to wyglądać tak:
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. } } }
Najważniejszymi metodami zrozumienia są setUp
i tearDown
. Metoda setUp
jest wywoływana przed każdą metodą testową, natomiast metoda tearDown
jest wywoływana po każdej metodzie testowej. Jeśli uruchomimy testy zdefiniowane w tej przykładowej klasie testowej, metody będą działać w następujący sposób:
konfiguracja → testExample → tearDown konfiguracja → testPerformanceExample → tearDown
Wskazówka: Testy uruchamia się, naciskając cmd + U, wybierając opcję Produkt → Test lub klikając i przytrzymując przycisk Uruchom, aż pojawi się menu opcji, a następnie wybierz z menu opcję Test.
Jeśli chcesz uruchomić tylko jedną konkretną metodę testową, naciśnij przycisk po lewej stronie nazwy metody (pokazany na obrazku poniżej).
Teraz, gdy masz już wszystko gotowe do pisania testów, możesz dodać przykładową klasę i kilka metod do przetestowania.
Dodaj klasę, która będzie odpowiedzialna za rejestrację użytkownika. Użytkownik wprowadza adres e-mail, hasło i potwierdzenie hasła. Nasza przykładowa klasa sprawdzi poprawność danych wejściowych, sprawdzi dostępność adresu e-mail i spróbuje zarejestrować użytkownika.
Uwaga: ten przykład używa wzorca architektonicznego MVVM (lub Model-View-ViewModel).
MVVM jest używany, ponieważ sprawia, że architektura aplikacji jest czystsza i łatwiejsza do przetestowania.
Dzięki MVVM łatwiej jest oddzielić logikę biznesową od logiki prezentacji, unikając w ten sposób ogromnego problemu z kontrolerem widoku.
Szczegóły dotyczące architektury MVVM są poza zakresem tego artykułu, ale możesz przeczytać więcej na ten temat w tym artykule.
Stwórzmy klasę modelu widoku odpowiedzialną za rejestrację użytkownika. .
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 } }
Najpierw dodaliśmy kilka właściwości, właściwości dynamiczne i metodę init.
Nie martw się o typ Dynamic
. Jest częścią architektury MVVM.
Gdy wartość Dynamic<Bool>
jest ustawiona na true, kontroler widoku, który jest powiązany (połączony) z RegistrationViewModel
, włączy przycisk rejestracji. Gdy loginSuccessful
ma wartość true, połączony widok zaktualizuje się.
Dodajmy teraz kilka metod sprawdzania poprawności hasła i formatu e-maila.
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 }
Za każdym razem, gdy użytkownik wpisze coś w e-mailu lub polu hasła, metoda enableRegistrationAttempt
sprawdzi, czy adres e-mail i hasło są w odpowiednim formacie i włączy lub wyłączy przycisk rejestracji poprzez dynamiczną właściwość registrationEnabled
.
Aby przykład był prosty, dodaj dwie proste metody – jedną na sprawdzenie dostępności e-maila i drugą na próbę rejestracji przy użyciu podanej nazwy użytkownika i hasła.
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 } } }
Te dwie metody wykorzystują usługę sieciową do sprawdzenia, czy wiadomość e-mail jest dostępna, i do próby rejestracji.
Aby uprościć ten przykład, implementacja NetworkService nie korzysta z żadnego interfejsu API zaplecza, ale jest tylko skrótem, który fałszuje wyniki. NetworkService jest zaimplementowany jako protokół i jego klasa implementacyjna.
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 jest bardzo prostym protokołem zawierającym tylko dwie metody: próbę rejestracji i metody sprawdzania dostępności wiadomości e-mail. Implementacja protokołu to klasa 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) }) } }
Obie metody po prostu czekają przez pewien czas (udając opóźnienie czasowe żądania sieciowego), a następnie wywołują odpowiednie metody wywołań zwrotnych.
Wskazówka: Dobrą praktyką jest używanie protokołów (znanych również jako interfejsy w innych językach programowania). Możesz przeczytać więcej na ten temat, jeśli wyszukujesz „zasada programowania do interfejsów”. Zobaczysz również, jak dobrze sprawdza się w testach jednostkowych.
Teraz, gdy zostanie podany przykład, możemy napisać testy jednostkowe, aby objąć metody tej klasy.
Utwórz nową klasę testową dla naszego modelu widoku. Kliknij prawym przyciskiem myszy folder
TestingIOSTests
w okienku Nawigator projektu, wybierz Nowy plik → Klasa przypadku testu jednostkowego i nadaj mu nazwęRegistrationViewModelTests
.Usuń metody
testExample
itestPerformanceExample
, ponieważ chcemy tworzyć własne metody testowe.Ponieważ Swift używa modułów, a nasze testy znajdują się w innym module niż kod naszej aplikacji, musimy zaimportować moduł naszej aplikacji jako
@testable
. Poniżej instrukcji import i definicji klasy dodaj@testable import TestingIOS
(lub nazwę modułu aplikacji). Bez tego nie bylibyśmy w stanie odwołać się do żadnej z klas lub metod naszej aplikacji.Dodaj zmienną
registrationViewModel
.
Oto jak teraz wygląda nasza pusta klasa testowa:
import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }
Spróbujmy napisać test dla metody emailValid
. Stworzymy nową metodę testową o nazwie testEmailValid
. Ważne jest, aby na początku nazwy dodać słowo kluczowe test
. W przeciwnym razie metoda nie zostanie uznana za metodę testową.
Nasza metoda testowa wygląda tak:
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") }
Nasza metoda testowa wykorzystuje metodę asercji, XCTAssert
, która w naszym przypadku sprawdza, czy warunek jest prawdziwy czy fałszywy.
Jeśli warunek jest fałszywy, potwierdzenie zakończy się niepowodzeniem (wraz z testem), a nasza wiadomość zostanie napisana.
Istnieje wiele metod asercji, których możesz użyć w swoich testach. Opisanie i pokazanie każdej metody asercji może z łatwością stworzyć osobny artykuł, więc nie będę tu wchodzić w szczegóły.
Niektóre przykłady dostępnych metod potwierdzania to: XCTAssertEqualObjects
, XCTAssertGreaterThan
, XCTAssertNil
, XCTAssertTrue
lub XCTAssertThrows
.
Możesz przeczytać więcej o dostępnych metodach asercji tutaj.
Jeśli uruchomisz test teraz, metoda testowa przejdzie pomyślnie. Pomyślnie utworzyłeś swoją pierwszą metodę testową, ale nie jest ona jeszcze gotowa na prime time. Ta metoda testowa nadal ma trzy problemy (jeden duży i dwa mniejsze), jak opisano poniżej.
Zagadnienie 1: Używasz prawdziwej implementacji protokołu NetworkService
Jedną z podstawowych zasad testowania jednostkowego jest to, że każdy test powinien być niezależny od jakichkolwiek zewnętrznych czynników lub zależności. Testy jednostkowe powinny być atomowe.
Jeśli testujesz metodę, która w pewnym momencie wywołuje metodę API z serwera, Twój test jest zależny od kodu sieciowego i dostępności serwera. Jeśli serwer nie działa w czasie testowania, test zakończy się niepowodzeniem, tym samym niesłusznie oskarżając testowaną metodę o niedziałanie.
W takim przypadku testujesz metodę RegistrationViewModel
.
RegistrationViewModel
zależy od klasy NetworkServiceImpl
, nawet jeśli wiesz, że testowana metoda, emailValid
, nie jest bezpośrednio zależna od NetworkServiceImpl
.
Podczas pisania testów jednostkowych należy usunąć wszystkie zewnętrzne zależności. Ale jak usunąć zależność NetworkService bez zmiany implementacji klasy RegistrationViewModel
?
Istnieje proste rozwiązanie tego problemu i nazywa się Object Mocking . Jeśli przyjrzysz się uważnie RegistrationViewModel
, zobaczysz, że faktycznie zależy on od protokołu NetworkService
.
class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...
Podczas inicjowania RegistrationViewModel
implementacja protokołu NetworkService
jest podawana (lub wstrzykiwana) do obiektu RegistrationViewModel
.
Ta zasada nazywa się wstrzykiwaniem zależności za pośrednictwem konstruktora ( jest więcej rodzajów wstrzykiwania zależności ).
Istnieje wiele interesujących artykułów na temat wstrzykiwania zależności w Internecie, takich jak ten artykuł na objc.io.
Jest też krótki, ale ciekawy artykuł wyjaśniający wstrzykiwanie zależności w prosty i bezpośredni sposób.
Ponadto na blogu Toptal jest dostępny świetny artykuł o zasadzie pojedynczej odpowiedzialności i DI.
Podczas tworzenia instancji RegistrationViewModel
wstrzykuje implementację protokołu NetworkService w swoim konstruktorze (stąd nazwa zasady wstrzykiwania zależności):
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
Ponieważ nasza klasa modelu widoku zależy tylko od protokołu, nic nie stoi na przeszkodzie, aby utworzyć naszą niestandardową (lub zafałszowaną) klasę implementacji NetworkService
i wstrzyknąć zafałszowaną klasę do naszego obiektu modelu widoku.
Stwórzmy naszą przeklętą implementację protokołu NetworkService
.
Dodaj nowy plik Swift do naszego celu testowego, klikając prawym przyciskiem myszy folder TestingIOSTests
w Nawigatorze projektu, wybierz „Nowy plik”, wybierz „Plik Swift” i nazwij go NetworkServiceMock
.
Tak powinna wyglądać nasza wyśmiewana klasa:
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) }) } }
W tym momencie nie różni się ona zbytnio od naszej rzeczywistej implementacji ( NetworkServiceImpl
), ale w rzeczywistej sytuacji rzeczywisty NetworkServiceImpl
miałby kod sieciowy, obsługę odpowiedzi i podobną funkcjonalność.
Nasza wyśmiewana klasa nic nie robi, o co chodzi w wyszydzanej klasie. Jeśli nic nie zrobi, to nie będzie kolidować z naszymi testami.
Aby naprawić pierwszy problem naszego testu, zaktualizujmy naszą metodę testową, zastępując:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
z:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())
Problem 2: Tworzysz wystąpienie rejestracjiVM w treści metody testowej
Nie bez powodu istnieją metody setUp
i tearDown
.
Te metody są używane do inicjowania lub konfigurowania wszystkich wymaganych obiektów wymaganych w teście. Należy użyć tych metod, aby uniknąć powielania kodu, pisząc te same metody init lub setup w każdej metodzie testowej. Nieużywanie metod setup i tearDown nie zawsze jest dużym problemem, zwłaszcza jeśli masz naprawdę konkretną konfigurację dla określonej metody testowej.
Ponieważ inicjalizacja klasy RegistrationViewModel
jest dość prosta, zrefaktoryzujesz swoją klasę testową, aby użyć metod setup i tearDown.
RegistrationViewModelTests
powinny wyglądać tak:
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") ... } }
Zagadnienie 3: Masz wiele asercji w jednej metodzie testowej
Chociaż nie jest to duży problem, istnieją zwolennicy posiadania jednego asercji na metodę.
Głównym uzasadnieniem tej zasady jest wykrywanie błędów.
Jeśli jedna metoda testowa ma wiele potwierdzeń, a pierwsza zakończy się niepowodzeniem, cała metoda testowa zostanie oznaczona jako nie powiodła się. Inne twierdzenia nie będą nawet testowane.
W ten sposób odkryjesz tylko jeden błąd na raz. Nie wiedziałbyś, czy inne twierdzenia zawiodą lub odniosą sukces.

Nie zawsze jest rzeczą złą posiadanie wielu asercji w jednej metodzie, ponieważ można naprawić tylko jeden błąd na raz, więc wykrycie jednego błędu na raz może nie być tak dużym problemem.
W naszym przypadku sprawdzana jest poprawność formatu e-maila. Ponieważ jest to tylko jedna funkcja, bardziej logiczne może być zgrupowanie wszystkich asercji w jednej metodzie, aby ułatwić czytanie i zrozumienie testu.
Ponieważ ten problem nie jest w rzeczywistości dużym problemem, a niektórzy mogą nawet twierdzić, że wcale nie jest problemem, zachowasz metodę testową taką, jaka jest.
Kiedy piszesz własne testy jednostkowe, od Ciebie zależy, jaką ścieżkę chcesz obrać dla każdej metody testowej. Najprawdopodobniej zauważysz, że są miejsca, w których jedno twierdzenie zgodnie z filozofią testu ma sens, a inne nie.
Testowanie metod za pomocą wywołań asynchronicznych
Bez względu na to, jak prosta jest aplikacja, istnieje duże prawdopodobieństwo, że pojawi się metoda, która będzie musiała zostać wykonana asynchronicznie w innym wątku, zwłaszcza że zazwyczaj lubisz, gdy interfejs użytkownika działa we własnym wątku..
Głównym problemem związanym z testowaniem jednostkowym i wywołaniami asynchronicznymi jest to, że wywołanie asynchroniczne zajmuje trochę czasu, ale test jednostkowy nie będzie czekał na zakończenie. Ponieważ test jednostkowy kończy się przed wykonaniem dowolnego kodu wewnątrz bloku asynchronicznego, nasz test zawsze kończy się tym samym wynikiem (bez względu na to, co napiszesz w swoim bloku asynchronicznym).
Aby to zademonstrować, stwórzmy test dla metody 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") } }
Tutaj chcesz przetestować, czy zmienna registrationEnabled zostanie ustawiona na wartość false po tym, jak nasza metoda powie Ci, że e-mail nie jest dostępny (już zajęty przez innego użytkownika).
Jeśli uruchomisz ten test, przejdzie. Ale spróbuj jeszcze jednej rzeczy. Zmień asercję na:
XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")
Jeśli uruchomisz test ponownie, ponownie się powiedzie.
To dlatego, że nasze twierdzenie nie zostało nawet potwierdzone. Test jednostkowy zakończył się przed wykonaniem bloku wywołania zwrotnego (pamiętaj, że w naszej implementacji usługi sieciowej makiety jest ustawiony na oczekiwanie przez jedną sekundę, zanim zwróci).
Na szczęście, dzięki Xcode 6, Apple dodał oczekiwania testowe do frameworka XCTest jako klasę XCTestExpectation
. Klasa XCTestExpectation
działa tak:
- Na początku testu ustalasz swoje oczekiwania testowe - za pomocą prostego tekstu opisującego, czego oczekiwałeś po teście.
- W bloku asynchronicznym po wykonaniu kodu testowego spełniasz oczekiwanie.
- Na koniec testu musisz ustawić blok
waitForExpectationWithTimer
. Zostanie wykonany, gdy oczekiwanie zostanie spełnione lub gdy skończy się czas - w zależności od tego, co nastąpi wcześniej. - Teraz test jednostkowy nie zakończy się, dopóki oczekiwanie nie zostanie spełnione lub dopóki nie skończy się czas oczekiwania.
Przepiszmy nasz test tak, aby używał klasy 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") } } }
Jeśli uruchomisz teraz test, zakończy się niepowodzeniem – tak jak powinien. Naprawmy test, aby zdał. Zmień potwierdzenie na:
XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")
Uruchom test ponownie, aby sprawdzić, czy przeszedł pomyślnie. Możesz spróbować zmienić czas opóźnienia w implementacji symulowanej usługi sieciowej, aby zobaczyć, co się stanie, jeśli skończy się czas oczekiwania.
Testowanie metod z wywołaniami asynchronicznymi bez wywołania zwrotnego
Nasza przykładowa metoda projektu attemptUserRegistration
używa metody NetworkService.attemptRegistration
, która zawiera kod, który jest wykonywany asynchronicznie. Metoda próbuje zarejestrować użytkownika w usłudze backendu.
W naszej aplikacji demonstracyjnej metoda poczeka tylko jedną sekundę, aby zasymulować połączenie sieciowe i sfałszować udaną rejestrację. Jeśli rejestracja się powiodła, wartość loginSuccessful
zostanie ustawiona na true. Zróbmy test jednostkowy, aby zweryfikować to zachowanie.
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }
Jeśli zostanie uruchomiony, ten test zakończy się niepowodzeniem, ponieważ wartość loginSuccessful
nie zostanie ustawiona na true do momentu zakończenia asynchronicznej metody networkService.attemptRegistration
.
Ponieważ utworzyłeś wykpiwany NetworkServiceImpl
, w którym metoda attemptRegistration
będzie czekała jedną sekundę przed zwróceniem udanej rejestracji, możesz po prostu użyć Grand Central Dispatch (GCD) i użyć metody asyncAfter
, aby sprawdzić potwierdzenie po jednej sekundzie. Po dodaniu asyncAfter
, nasz kod testowy będzie wyglądał tak:
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") } }
Jeśli zwróciłeś uwagę, będziesz wiedział, że to nadal nie zadziała, ponieważ metoda testowa zostanie wykonana przed wykonaniem bloku asyncAfter
, a metoda zawsze pomyślnie przejdzie w wyniku. Na szczęście istnieje klasa XCTestException
.
Przepiszmy naszą metodę tak, aby korzystała z klasy 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") } } }
Dzięki testom jednostkowym obejmującym nasz RegistrationViewModel
, możesz mieć teraz większą pewność, że dodanie nowych lub zaktualizowanie istniejących funkcji niczego nie zepsuje.
Ważna uwaga: Testy jednostkowe stracą swoją wartość, jeśli nie zostaną zaktualizowane, gdy zmieni się funkcjonalność metod, które obejmują. Pisanie testów jednostkowych to proces, który musi nadążać za resztą aplikacji.
Wskazówka: nie odkładaj pisania testów na koniec. Pisz testy podczas programowania. W ten sposób lepiej zrozumiesz, co należy przetestować i jakie są przypadki graniczne.
Pisanie testów interfejsu użytkownika
Gdy wszystkie testy jednostkowe zostaną w pełni opracowane i pomyślnie wykonane, możesz mieć pewność, że każda jednostka kodu działa poprawnie, ale czy oznacza to, że Twoja aplikacja jako całość działa zgodnie z przeznaczeniem?
Tu właśnie pojawiają się testy integracyjne, których podstawowym składnikiem są testy interfejsu użytkownika.
Przed rozpoczęciem testowania interfejsu użytkownika należy przetestować pewne elementy interfejsu użytkownika i interakcje (lub historyjki użytkownika). Stwórzmy prosty widok i jego kontroler widoku.
- Otwórz
Main.storyboard
i utwórz prosty kontroler widoku, który będzie wyglądał jak ten na poniższym obrazku.
Ustaw znacznik pola tekstowego wiadomości e-mail na 100, znacznik pola tekstowego hasła na 101, a znacznik potwierdzenia hasła na 102.
- Dodaj nowy plik kontrolera widoku
RegistrationViewController.swift
i połącz wszystkie gniazda ze scenorysem.
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() } }
Tutaj dodajesz do klasy IBOutlets
i strukturę TextFieldTags
.
Umożliwi to określenie, które pole tekstowe jest edytowane. Aby skorzystać z właściwości dynamicznych w modelu widoku, musisz „powiązać” właściwości dynamiczne w kontrolerze widoku. Możesz to zrobić w metodzie bindViewModel
:
fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }
Dodajmy teraz metodę delegata pola tekstowego, aby śledzić, kiedy którekolwiek z pól tekstowych jest aktualizowane:
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 }
- Zaktualizuj
AppDelegate
, aby powiązać kontroler widoku z odpowiednim modelem widoku (należy zauważyć, że ten krok jest wymaganiem architektury MVVM). Zaktualizowany kodAppDelegate
powinien wtedy wyglądać tak:
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 } }
Plik scenorysu i RegistrationViewController
są naprawdę proste, ale są wystarczające, aby zademonstrować, jak działa automatyczne testowanie interfejsu użytkownika.
Jeśli wszystko jest poprawnie skonfigurowane, przycisk rejestracji powinien być wyłączony podczas uruchamiania aplikacji. Kiedy i tylko wtedy, gdy wszystkie pola są wypełnione i ważne, przycisk rejestracji powinien być włączony.
Po skonfigurowaniu możesz utworzyć swój pierwszy test interfejsu użytkownika.
Nasz test interfejsu użytkownika powinien sprawdzić, czy przycisk Zarejestruj się zostanie włączony wtedy i tylko wtedy, gdy wprowadzono prawidłowy adres e-mail, prawidłowe hasło i prawidłowe potwierdzenie hasła. Oto jak to skonfigurować:
- Otwórz plik
TestingIOSUITests.swift
. - Usuń
testExample()
i dodaj metodętestRegistrationButtonEnabled()
. - Umieść kursor w metodzie
testRegistrationButtonEnabled
tak, jakbyś miał zamiar coś tam napisać. - Naciśnij przycisk testu Record UI (czerwone kółko na dole ekranu).
- Po naciśnięciu przycisku Nagraj aplikacja zostanie uruchomiona
- Po uruchomieniu aplikacji dotknij pola tekstowego e-maila i wpisz „[email protected]”. Zauważysz, że kod automatycznie pojawia się w treści metody testowej.
Za pomocą tej funkcji możesz nagrywać wszystkie instrukcje interfejsu użytkownika, ale może się okazać, że ręczne pisanie prostych instrukcji będzie znacznie szybsze.
To jest przykład instrukcji rejestratora dotyczącej stukania w pole tekstowe hasła i wpisywania adresu e-mail '[email protected]'
let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
- Po zarejestrowaniu interakcji interfejsu użytkownika, które chcesz przetestować, naciśnij ponownie przycisk zatrzymania (etykieta przycisku nagrywania zmieniła się na zatrzymaną po rozpoczęciu nagrywania), aby zatrzymać nagrywanie.
- Po uzyskaniu rejestratora interakcji z interfejsem użytkownika możesz teraz dodawać różne
XCTAsserts
, aby testować różne stany aplikacji lub elementów interfejsu użytkownika.
Nagrane instrukcje nie zawsze są oczywiste, a nawet mogą sprawić, że cała metoda testowa będzie trochę trudna do odczytania i zrozumienia. Na szczęście możesz ręcznie wprowadzić instrukcje interfejsu użytkownika.
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.