Matteo Manferdini

Veel ontwikkelaars vinden unit testing verwarrend. Dit wordt nog verergerd door de geavanceerde technieken die je nodig hebt om classes of asynchrone code te testen, zoals die voor netwerk requests.

In werkelijkheid, aan de basis van unit testing, zijn er eenvoudige fundamentele concepten. Als je die eenmaal begrijpt, wordt unit testing ineens een stuk toegankelijker.

Content

  • Unit testing uitgelegd in eenvoudige, begrijpelijke termen
  • Unit testen stelt u in staat om moeilijk te bereiken randgevallen te controleren en bugs te voorkomen
  • Unit testen in Xcode
  • Een goede architectuur maakt uw code gemakkelijker te testen
  • Waardetypen behandelen als pure functies om unit testen te vereenvoudigen
  • Het verifiëren van uw code met behulp van de assertions functies uit het XCTest framework
  • Het meten van de code coverage van een test in Xcode
  • Waarom het streven naar specifieke code coverage zinloos is en wat u in plaats daarvan moet doen

Unit testen uitgelegd in eenvoudige, begrijpelijke termen

Wanneer u een app schrijft, doorloopt u een cyclus van het schrijven van code, het uitvoeren ervan, en controleren of het werkt zoals bedoeld. Maar naarmate je code groeit, wordt het moeilijker om je hele app handmatig te testen.

Automatisch testen kan die tests voor je uitvoeren. Dus, praktisch gesproken, unit testing is code om ervoor te zorgen dat uw app code correct.

De andere reden waarom unit testing kan moeilijk te begrijpen zijn, is dat veel concepten omringen het: testen in Xcode, app architectuur, test dubbels, dependency injection, enzovoort.

Maar, in de kern, het idee van unit testing is vrij eenvoudig. U kunt eenvoudige unit tests schrijven in gewone Swift. Je hebt geen speciale Xcode-functie of verfijnde techniek nodig, en je kunt ze zelfs binnen een Swift Playground uitvoeren.

Laten we eens naar een voorbeeld kijken. De functie hieronder berekent de factorial van een geheel getal, dat is het product van alle positieve gehele getallen kleiner dan of gelijk aan de input.

1
2
3
4
5
6
7

func factorial(van getal: Int) -> Int {
var result = 1
for factor in 1…getal {
resultaat = resultaat * factor
}
return resultaat
}

Om deze functie handmatig te testen, voert u het een aantal getallen in en controleert u of het resultaat is wat u verwacht. Dus, bijvoorbeeld, u zou het proberen op vier en controleren of het resultaat 24 is.

U kunt dat automatiseren, door een Swift-functie te schrijven die de test voor u doet. Bijvoorbeeld:

1
2
3
4
5

func testFactorial() {
if factorial(van: 4) != 24 {
print(“De factorial van 3 is fout”)
}
}

Dit is eenvoudige Swift-code die iedereen kan begrijpen. Het enige wat het doet is de uitvoer van de functie controleren en een bericht afdrukken als het niet correct is.

Dat is unit-testing in een notendop. Al het andere is toeters en bellen toegevoegd door Xcode om het testen eenvoudiger te maken.

Of althans, dat zou het geval zijn als de code in onze apps zo eenvoudig was als de factorial-functie die we zojuist hebben geschreven. Daarom heb je de ondersteuning van Xcode nodig en geavanceerde technieken zoals testdubbel.

Niettemin is dit idee nuttig voor zowel eenvoudige als complexe tests.

Unit testing stelt u in staat om moeilijk te bereiken randgevallen te controleren en bugs te voorkomen

U kunt verschillende voordelen halen uit unit testing.

Het meest voor de hand liggende is dat u uw code niet voortdurend handmatig hoeft te testen. Het kan vervelend zijn om je app te draaien en op een specifieke locatie te komen met de functionaliteit die je wilt testen.

Maar dat is niet alles. Unit tests stellen je ook in staat om randgevallen te testen die misschien moeilijk te maken zijn in een draaiende app. En randgevallen zijn waar de meeste bugs wonen.

Bijvoorbeeld, wat gebeurt er als we voeren nul of een negatief getal aan onze factorial(van:) functie van hierboven?

1
2
3
4
5

func testFactorialOfZero() {
if factorial(van: 0) != 1 {
print(“De factorial van 0 is fout”)
}
}

Onze code breekt:

In een echte app zou deze code een crash veroorzaken. Nul is buiten het bereik van de for-lus.

Maar onze code faalt niet vanwege een beperking van Swift-bereiken. Hij faalt omdat we vergeten zijn om niet-positieve gehele getallen in aanmerking te nemen in onze code. Per definitie bestaat de factoriaal van een negatief geheel getal niet, terwijl de factoriaal van 0 1 is.

1
2
3
4
5
6
7
8
9
10
11
12
13

func factorial(van getal: Int) -> Int? {
if (getal < 0) {
return nil
}
if (getal == 0) {
return 1
}
var result = 1
for factor in 1…number {
result = result * factor
}
return resultaat
}

We kunnen nu onze tests opnieuw uitvoeren en zelfs een controle voor negatieve getallen toevoegen.

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(van: -1) != nihil {
print(“De factorial van -1 is fout”)
}
}

Hier zie je het laatste en, naar mijn mening, belangrijkste voordeel van unit testen. De tests die we eerder hebben geschreven, zorgen ervoor dat we, als we onze factorial(van:)-functie bijwerken, de bestaande code niet breken.

Dit is cruciaal in complexe apps, waar je voortdurend code moet toevoegen, bijwerken en verwijderen. Unit-tests geven u het vertrouwen dat uw wijzigingen geen code hebben gebroken die goed werkte.

Unitests schrijven in Xcode

Met een goed begrip van unit-tests, kunnen we nu kijken naar de testfuncties van Xcode.

Wanneer u een nieuw Xcode-project maakt, krijgt u meteen de kans om unit-tests toe te voegen. Ik zal, als voorbeeld, een app gebruiken om calorieën bij te houden. Het volledige Xcode-project is hier te vinden.

Als je die optie aanvinkt, bevat je sjabloonproject een testdoel dat al is geconfigureerd. Je kunt er later altijd een toevoegen, maar ik vink deze optie meestal standaard aan. Zelfs als ik niet meteen tests ga schrijven, is alles al ingesteld als ik besluit dat wel te doen.

Het test target begint al met wat template code voor onze tests.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

importeer XCTest
@testable importeer Calories
klasse CaloriesTests: XCTestCase {
override func setUpWithError() throws {
// Zet hier setup code neer. Deze methode wordt aangeroepen voor het aanroepen van elke testmethode in de klasse.
}
override func tearDownWithError() throws {
// Zet hier teardown code. Deze methode wordt aangeroepen na het aanroepen van elke testmethode in de klasse.
}
func testExample() throws {
// Dit is een voorbeeld van een functionele testcase.
// Gebruik XCTAssert en verwante functies om te controleren of uw tests de juiste resultaten opleveren.
}
func testPerformanceExample() throws {
// Dit is een voorbeeld van een performance test case.
self.measure {
// Zet hier de code waarvan je de tijd wilt meten.
}
}
}

  • De regel @testable import Calories geeft je toegang tot alle typen in het hoofddoel van je project. De code van een Swift-module is van buitenaf niet toegankelijk, tenzij alle typen en methoden expliciet als openbaar zijn verklaard. Met het @testable keyword kun je deze beperking omzeilen en zelfs private code benaderen.
  • De CaloriesTests klasse vertegenwoordigt een test case, dat wil zeggen, een groepering van gerelateerde tests. Een test case klasse moet afstammen van de XCTestCase.
  • De setUpWithError() methode stelt je in staat om een standaard omgeving op te zetten voor alle tests in een test case. Als je na je tests nog wat moet opruimen, gebruik je tearDownWithError(). Deze methodes lopen voor en na elke test in een testcase. We hebben ze niet nodig, dus je kunt ze verwijderen.
  • De testExample() methode is een test zoals we die hebben geschreven voor ons factorial voorbeeld. Je kunt testmethoden een naam geven die je zelf wilt, maar ze moeten wel beginnen met het test-voorvoegsel, zodat Xcode ze herkent.
  • En ten slotte laat de testPerformanceExample() zien hoe je de prestaties van je code kunt meten. Performance tests zijn minder standaard dan unit tests, en je gebruikt ze alleen voor cruciale code dan snel moet zijn. Ook deze methode zullen we niet gebruiken.

U kunt al uw testgevallen en relatieve tests vinden in de Test navigator van Xcode.

U kunt nieuwe testgevallen aan uw project toevoegen door simpelweg nieuwe .swift-bestanden aan te maken met code zoals hierboven, maar het is gemakkelijker om het menu Toevoegen onderaan de Testnavigator te gebruiken.

Een goede architectuur maakt uw code gemakkelijker te testen

In een echt project hebt u meestal geen losse functies zoals in ons factorial-voorbeeld. In plaats daarvan heb je structuren, opsommingen en klassen die de verschillende onderdelen van je app vertegenwoordigen.

Het is van cruciaal belang om je code te organiseren volgens een ontwerppatroon zoals MVC of MVVM. Het hebben van goed gearchitectureerde code maakt niet alleen uw code goed georganiseerd en gemakkelijker te onderhouden. Het maakt ook unit testing eenvoudiger.

Dit helpt ons ook een veelgestelde vraag te beantwoorden: welke code moet je eerst testen?

Het antwoord is: begin bij je modeltypes.

Er zijn verschillende redenen voor:

  • Als je het MVC-patroon of een van de afgeleiden daarvan volgt, bevatten je modeltypes de business logica van het domein. Je hele app hangt af van de juistheid van die code, dus zelfs een paar tests hebben een grote impact.
  • Model types zijn vaak structuren, die value types zijn. Deze zijn veel gemakkelijker te testen dan referentietypen (we zullen zo zien waarom).
  • Referentietypen, d.w.z. klassen, hebben afhankelijkheden en veroorzaken neveneffecten. Om unit tests hiervoor te schrijven, heb je test dubbels nodig (dummies, stubs, fakes, spionnen, en mocks) en gebruik je geavanceerde technieken.
  • Controllers (in MVC termen) of view models (in MVVM termen) zijn vaak verbonden met resources zoals disk storage, een database, het netwerk, en apparaat sensoren. Ze kunnen ook parallelle code uitvoeren. Dit maakt unit testen moeilijker.
  • View code is degene die het vaakst verandert, wat broze tests oplevert die gemakkelijk breken. En niemand repareert graag kapotte tests.

Waardetypes behandelen als pure functies om unit testing te vereenvoudigen

Dus, laten we wat modeltypes aan onze app toevoegen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

struct FoodItem {
let name: String
let caloriesFor100Grams: Int
let grammen: Int
}
struct Maaltijd {
private(set) var items: =
var calorieën: Int {
var calorieën = 0
for item in items {
calorieën += (item.calorieënVoor100Grammen / 100) * item.grammen
}
calorieën terug
}
mutating func add(_ item: FoodItem) {
items.append(item)
}
}

De maaltijdstructuur bevat wat eenvoudige logica om voedselitems toe te voegen en het totaal aantal calorieën te berekenen van het voedsel dat het bevat.

Unit-testen heeft zo’n naam omdat je je code in afzonderlijke eenheden zou moeten testen, waarbij je alle afhankelijkheden verwijdert (het samen testen van afhankelijkheden wordt in plaats daarvan integratietesten genoemd). Elke eenheid wordt dan behandeld als een zwarte doos. Je voert het wat invoer en test de uitvoer om te controleren of het is wat je verwacht.

Dat was gemakkelijk in het geval van ons factorial-voorbeeld omdat het een zuivere functie was, dat wil zeggen, een functie waarbij de uitvoer alleen van de invoer afhangt, en die geen neveneffecten creëert.

Maar dat lijkt niet het geval voor ons type Meal, dat state bevat.

De waarde die door de calories computed property wordt geretourneerd, heeft geen invoer. Het hangt alleen af van de inhoud van de items array. De methode add(_:) retourneert daarentegen geen waarde en verandert alleen de interne toestand van een maaltijd.

Maar structuren zijn waardetypen, en we kunnen ze als zuivere functies beschouwen. Omdat ze geen afhankelijkheden hebben, kun je de begintoestand van een structuur beschouwen als de invoer van een methode en zijn toestand na het aanroepen van de methode als de uitvoer.

(Dit is een van de redenen waarom je moet afzien van het plaatsen van referentietypen binnen waardetypen).

Het verifiëren van uw code met behulp van de assertions-functies van het XCTest framework

We hebben nu wat code om te testen, en een plaats in ons Xcode-project waar we de tests kunnen schrijven.

We missen nog een laatste ingrediënt: een manier om uit te drukken of onze code correct is of niet.

In het voorbeeld aan het begin van dit artikel, heb ik eenvoudige print statements gebruikt. Dat is niet praktisch voor echte unit testing. Je wilt geen tijd verspillen met het doorspitten van logs om te zien welke tests mislukt zijn. We hebben een manier nodig die direct naar falende tests wijst.

In het XCTest framework, vind je een serie assertion functies die Xcode laten wijzen naar tests die niet slagen.

Met deze, kunnen we onze eerste test schrijven.

Zoals ik al zei, zijn unit tests nuttig om randgevallen te controleren, dus laten we beginnen met te controleren of een lege maaltijd geen calorieën bevat.

1
2
3
4
5
6

class CaloriesTests: XCTestCase {
func testEmptyMeal() gooit {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, “Een lege maaltijd moet 0 calorieën hebben”)
}
}

De XCTest biedt een generieke XCTAssert-functie waarmee u elke voorwaarde kunt laten gelden. U kunt die gebruiken voor elke test die u schrijft, maar het is beter om meer gespecialiseerde assertiefuncties te gebruiken wanneer dat mogelijk is, zoals XCTAssertEqual (hierboven), XCTAssertNil, en andere. Deze produceren betere fouten in Xcode dan XCTAssert.

U kunt een enkele test uitvoeren door op het diamantje naast de naam te klikken in de goot van de code-editor.

Wanneer een test slaagt, wordt deze groen, terwijl falende tests rood worden gemarkeerd. U kunt de 0 in de test in een willekeurig ander getal veranderen om te zien hoe een falende test eruit ziet.

U kunt alle tests in een testgeval uitvoeren door op het diamantje naast de klasse-declaratie te klikken. U kunt ook de Test-navigator gebruiken die we hierboven hebben gezien om afzonderlijke tests of testgevallen uit te voeren.

U wilt vaak alle tests in uw project in één keer uitvoeren, wat u snel kunt doen door cmd+U op uw toetsenbord in te drukken of de optie Test te selecteren in het menu Product van Xcode.

De codedekking van een test in Xcode meten

De volgende, meest voorkomende vraag over unit testing is: hoeveel van je code moet je testen?

Dit wordt meestal gemeten aan de hand van code coverage, d.w.z., het percentage van de code dat door je tests wordt gedekt. Dus, voordat we die vraag beantwoorden, laten we eens kijken hoe u de codedekking van uw tests in Xcode kunt meten.

Op de eerste plaats moet u Xcode de dekking voor uw testdoel laten verzamelen. Klik op de knop met de projectnaam in het gesegmenteerde besturingselement in de Xcode-werkbalk (naast de stopknop). Selecteer vervolgens Schema bewerken… in het pop-upmenu.

Selecteer daar Test in de outline en ga vervolgens naar het tabblad Opties. En selecteer ten slotte Gather coverage for (Dekking verzamelen voor). U kunt de optie op alle doelen laten staan.

U kunt nu uw tests opnieuw uitvoeren, zodat Xcode de dekkingsgegevens kan verzamelen. U vindt het resultaat in de Report navigator, door het item Code coverage in de outline te selecteren.

Hier kunt u de percentages code controleren die door uw tests voor het hele project en elk bestand worden bestreken.

Deze getallen zijn voor ons voorbeeld niet zo significant, omdat we nauwelijks code hebben geschreven. Toch kunnen we zien dat de methode add(_:) van het type Meal een 0%-dekking heeft, wat betekent dat we het nog niet hebben getest.

De berekende eigenschap calorieën heeft in plaats daarvan een 85,7%-dekking, wat betekent dat er een aantal uitvoeringspaden in onze code zijn die onze test niet heeft geactiveerd.

In ons eenvoudige voorbeeld is het gemakkelijk te begrijpen welk pad dat is. We hebben alleen de calorieën van een lege maaltijd getest, dus de code in de for-lus is niet uitgevoerd.

In meer geavanceerde methoden is het echter misschien niet zo eenvoudig.

Daarvoor kun je de code coverage strip in de Xcode editor tevoorschijn halen. U vindt deze in het menu Adjust Editor Options in de rechterbovenhoek.

Dit onthult een strook die u de dekking voor elke regel code laat zien.

De getallen vertellen u hoe vaak elke regel tijdens het testen is uitgevoerd. In het rood ziet u welke regels helemaal niet (volledig) of slechts gedeeltelijk (gestreept) zijn uitgevoerd.

Waarom het zinloos is om een specifieke code coverage na te streven en wat u in plaats daarvan zou moeten doen

Dus, welk percentage is een goed code coverage percentage?

De meningen lopen sterk uiteen. Sommige ontwikkelaars denken dat 20% genoeg is. Anderen gaan voor hogere cijfers, zoals 70% of 80%. Er zijn zelfs ontwikkelaars die vinden dat alleen 100% acceptabel is.

In mijn ogen is code coverage een nietszeggende metric. Je kunt het gebruiken om je beslissingen over het testen te informeren, maar je moet het niet behandelen als een doel dat je moet halen.

Om te zien waarom, laten we nog een test schrijven om de code te dekken die we nog niet getest hebben.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

class CaloriesTests: XCTestCase {
laat banaan = Voedingsmiddel(naam: “Banaan”, calorieenVoor100Grammen: 89, grammen: 118)
laat biefstuk = Voedingsmiddel(naam: “Biefstuk”, calorieenVoor100Grammen: 271, grammen: 225)
laat geitenkaas = Voedingsmiddel(naam: “Geitenkaas”, calorieënVoor100Grammen: 364, grammen: 28)
func testEmptyMeal() gooit {
laat maaltijd = Maaltijd()
XCTAssertEqual(maaltijd.calories, 0, “An empty meal should have 0 calories”)
}
func testCalories() {
var meal = Meal()
meal.add(banana)
meal.add(steak)
meal.add(goatCheese)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Als je alle tests opnieuw uitvoert, krijg je een 100% dekking voor het bestand Model.swift. Ziet er goed uit, toch?

Nu, ga en verwijder de testEmptyMeal() methode. Als je de overgebleven test alleen uitvoert, zul je zien dat de dekking voor onze Meal types nog steeds 100% is.

Dit laat je al zien dat het 100% getal je een vals gevoel van veiligheid kan geven. Je weet dat we nu niet alle randgevallen voor de calorieën berekende eigenschap testen. En toch geeft onze testdekking dat niet weer.

Ik zou daaraan willen toevoegen dat 100% dekking niet alleen misleidend is, maar zelfs schadelijk. Sommige code in uw project is gevoelig voor constante verandering, vooral de nieuwste code en vooral in views.

Het bedekken van 100% daarvan betekent dat je alleen maar broze tests zult produceren die voortdurend breken, en die je moet repareren. Deze tests detecteren geen bugs. Ze breken omdat de functionaliteit van je code verandert.

En hier komen we bij het punt waar niemand over praat.

Het schrijven van tests is geen pretje, ondanks wat sommige ontwikkelaars beweren. Tests repareren is nog minder leuk. Doe het te veel, en je zult unit testen haten en het helemaal aan de kant schuiven.

Psychologie is net zo essentieel voor software ontwikkeling als best practices. We zijn geen machines.

Dus, naar welk getal moet je streven? Dat vereist een uitgebreidere discussie, maar hier geef ik u een samenvatting van mijn mening.

Schrijf nuttige tests, te beginnen met de oudste code in uw project die niet gaat veranderen. Gebruik code coverage alleen om te bepalen welke code nog niet getest is, maar gebruik het niet als doel of om te beslissen wanneer je meer tests moet schrijven. 20% dekking is beter dan 80% als uw tests zinvol zijn, en u de juiste code test.

Zoals ik hierboven al zei, bevatten modeltypes de meest kritische code in uw project, die ook de neiging heeft het minst te veranderen. Dus, begin daar.

Ik doe meestal geen moeite om elk afzonderlijk uitvoeringstraject te testen. In een echt project zou ik bijvoorbeeld niet de testEmptyMeal()-methode schrijven die ik je hierboven heb laten zien. Ja, het is cruciaal om randgevallen te testen, maar ze zijn ook niet allemaal belangrijk.

Ik zou meestal alleen de testCalories() schrijven. Dat vertelt me dat mijn code werkt, en het zal me waarschuwen als ik later een fout maak bij het veranderen van deze methode.

Zeker, het dekt niet elk pad, maar deze code is zo eenvoudig dat dat gewoon tijdverspilling is. Je tijd besteden aan het schrijven van echte code voor functies die uw gebruikers helpen is belangrijker dan het testen van elk pad.

Ik weet dat ik wat flack zal krijgen van sommige ontwikkelaars voor deze mening, maar het kan me niet schelen. Test coverage is een van die debatten die nooit zullen eindigen.

De code die je schrijft is meestal goed. Er is geen noodzaak om paranoïde te zijn. Het hebben van een overvloed aan tests die breken elke keer als je iets verandert is een verplichting, niet een troef.

Jouw tijd en wilskracht zijn beperkt. Besteed ze aan belangrijkere taken.

Architecting SwiftUI-apps met MVC en MVVM

Het is gemakkelijk om een app te maken door wat code bij elkaar te gooien. Maar zonder best practices en een robuuste architectuur eindig je al snel met onhanteerbare spaghetticode. In deze gids laat ik je zien hoe je SwiftUI-apps op de juiste manier structureert.

GET THE FREE BOOK NOW

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.