Matteo Manferdini

Mange udviklere synes, at enhedstest er forvirrende. Dette forværres af de avancerede teknikker, du har brug for til at teste klasser eller asynkron kode, som f.eks. til netværksforespørgsler.

I virkeligheden er der enkle grundlæggende begreber, som ligger til grund for unit testing. Når man først har forstået dem, bliver unit testing pludselig mere lettilgængeligt.

Indhold

  • Unit testing forklaret på enkel, forståelige termer
  • Unit testing giver dig mulighed for at kontrollere svært tilgængelige edge cases og forebygge fejl
  • Skrivning af unit tests i Xcode
  • En god arkitektur gør din kode lettere at teste
  • Behandler værdityper som rene funktioner for at forenkle unit testing
  • Verificering af din kode ved hjælp af assertions-funktionerne fra XCTest-rammen
  • Måling af kodedækningen af en test i Xcode
  • Hvorfor det er meningsløst at sigte efter specifik kodedækning, og hvad du i stedet bør gøre

Unittest forklaret på en enkel måde, forståelige udtryk

Når du skriver en app, gennemgår du en cyklus, hvor du skriver kode, kører den og kontrollerer, om den fungerer efter hensigten. Men efterhånden som din kode vokser, bliver det sværere at teste hele din app manuelt.

Automatiseret testning kan udføre disse test for dig. Så praktisk talt er enhedstestning kode til at sikre, at din app-kode er korrekt.

Den anden grund til, at enhedstestning kan være svær at forstå, er, at der er mange begreber omkring det: test i Xcode, app-arkitektur, testdobler, injektion af afhængighed osv.

Men i bund og grund er idéen om enhedstestning ret ligetil. Du kan skrive enkle enhedstest i almindeligt Swift. Du har ikke brug for nogen særlig Xcode-funktion eller sofistikeret teknik, og du kan endda køre dem inde i en Swift Playground.

Lad os se på et eksempel. Funktionen nedenfor beregner faktorialen af et heltal, som er produktet af alle positive heltal, der er mindre end eller lig med indgangen.

1
2
3
4
5
6
7

func faktorial(af tal: Int) -> Int {
var result = 1
for factor in 1…antal {
resultat = resultat * faktor
}
return result
}

For at teste denne funktion manuelt, skal du fodre den med et par tal og kontrollere, at resultatet er det, du forventer. Så du ville f.eks. prøve den på fire og kontrollere, at resultatet er 24.

Du kan automatisere dette ved at skrive en Swift-funktion, der udfører testen for dig. For eksempel:

1
2
3
4
5

func testFactorial() {
if factorial(of: 4) != 24 {
print(“Faktorial af 3 er forkert”)
} }
}

Dette er simpel Swift-kode, som alle kan forstå. Det eneste, den gør, er at kontrollere funktionens output og udskrive en meddelelse, hvis det ikke er korrekt.

Det er enhedstest i en nøddeskal. Alt andet er klokker og fløjter, som Xcode har tilføjet for at gøre testningen enklere.

Og det ville i hvert fald være tilfældet, hvis koden i vores apps var lige så enkel som den faktorfunktion, vi lige har skrevet. Derfor har du brug for Xcodes understøttelse og avancerede teknikker som testdoblinger.

Nu er denne idé ikke desto mindre nyttig til både enkle og komplekse tests.

Enhedstest giver dig mulighed for at kontrollere svært tilgængelige edge cases og forebygge fejl

Du kan få flere fordele ved enhedstest.

Den mest indlysende er, at du ikke behøver at teste din kode manuelt hele tiden. Det kan være besværligt at køre din app og komme til et bestemt sted med den funktionalitet, du ønsker at teste.

Men det er ikke alt. Enhedstests giver dig også mulighed for at teste edge cases, som kan være svære at skabe i en kørende app. Og edge cases er der, hvor de fleste fejl bor.

For eksempel, hvad sker der, hvis vi fodrer nul eller et negativt tal til vores faktorial(of:)-funktion fra ovenfor?

1
2
3
4
5

func testFactorialOfZero() {
if factorial(of: 0) != 1 {
print(“Faktorial af 0 er forkert”)
}
}

Vores kode går i stykker:

I en rigtig app ville denne kode forårsage et nedbrud. Nul er uden for intervallet i for-loop’en.

Men vores kode fejler ikke på grund af en begrænsning af Swift-intervallerne. Den fejler, fordi vi glemte at tage hensyn til ikke-positive hele tal i vores kode. Per definition findes der ikke en faktorial af et negativt heltal, mens faktorial af 0 er 1.

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

func faktorial(af tal: Int) -> Int? {
if (antal < 0) {
return nil
}
if (antal == 0) {
return 1
}
var resultat = 1
for faktor i 1…antal {
resultat = resultat * faktor
}
return result
}

Vi kan nu køre vores test igen og endda tilføje en kontrol for negative tal.

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(of: -1) != nil {
print(“Faktorial af -1 er forkert”)
}
}

Her ser du den sidste og, efter min mening, vigtigste fordel ved enhedstest. De tests, vi skrev tidligere, sikrer, at vi, når vi opdaterer vores factorial(of:)-funktion, ikke ødelægger dens eksisterende kode.

Dette er afgørende i komplekse programmer, hvor man hele tiden skal tilføje, opdatere og slette kode. Enhedstest giver dig sikkerhed for, at dine ændringer ikke ødelagde kode, der fungerede godt.

Skrivning af enhedstest i Xcode

Med en forståelse af enhedstest kan vi nu se på testfunktionerne i Xcode.

Når du opretter et nyt Xcode-projekt, får du mulighed for at tilføje enhedstest med det samme. Jeg vil som eksempel bruge en app til at spore kalorier. Du kan finde det fulde Xcode-projekt her.

Når du markerer denne mulighed, vil dit skabelonprojekt indeholde et testmål, der allerede er konfigureret. Du kan altid tilføje et senere, men jeg markerer normalt indstillingen som standard. Selv hvis jeg ikke har tænkt mig at skrive tests med det samme, vil alt være konfigureret, når jeg beslutter mig for at gøre det.

Testmålet starter allerede med noget skabelonkode til vores 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

import XCTest
@testable import Calories
klasse CaloriesTests: XCTestCase {
override func setUpWithError() throws {
// Sæt opsætningskode her. Denne metode kaldes før kaldelsen af hver testmetode i klassen.
}
override func tearDownWithError() throws {
// Sæt teardown-kode her. Denne metode kaldes efter påkaldelse af hver testmetode i klassen.
}
func testExample() throws {
// Dette er et eksempel på en funktionel testcase.
// Brug XCTAssert og relaterede funktioner til at verificere, at dine tests giver de korrekte resultater.
}
func testPerformanceExample() throws {
// Dette er et eksempel på en testcase for ydeevne.
self.measure {
// Indsæt den kode, du vil måle tiden på, her.
}
}
}

  • Retten @testable import Calories giver dig adgang til alle typerne i dit projekts hovedmål. Koden i et Swift-modul er ikke tilgængelig udefra, medmindre alle typer og metoder udtrykkeligt er deklareret som offentlige. Med nøgleordet @testable kan du omgå denne begrænsning og få adgang til selv privat kode.
  • Klassen CaloriesTests repræsenterer en testcase, dvs. en gruppering af relaterede tests. En testcase-klasse skal nedstamme fra XCTestCase.
  • Med metoden setUpWithError() kan du opsætte et standardmiljø for alle testene i en testcase. Hvis du har brug for at foretage en oprydning efter dine tests, bruger du tearDownWithError(). Disse metoder kører før og efter hver test i en testcase. Vi får ikke brug for dem, så du kan slette dem.
  • Metoden testExample() er en test som dem, vi skrev til vores faktoreksempel. Du kan navngive testmetoder som du vil, men de skal starte med test-præfikset, for at Xcode kan genkende dem.
  • Og endelig viser testPerformanceExample() dig, hvordan du kan måle din kodes ydeevne. Ydelsestests er mindre standard end enhedstests, og du bruger dem kun til afgørende kode, som skal være hurtig. Vi vil heller ikke bruge denne metode.

Du kan finde alle dine testtilfælde og relative tests opført i Test-navigatoren i Xcode.

Du kan tilføje nye testtilfælde til dit projekt ved blot at oprette nye .swift-filer med kode som ovenstående, men det er nemmere at bruge tilføjelsesmenuen nederst i Testnavigatoren.

En god arkitektur gør din kode nemmere at teste

I et rigtigt projekt har du normalt ikke løse funktioner som i vores faktoriel eksempel. I stedet har du strukturer, enumerationer og klasser, der repræsenterer de forskellige dele af din app.

Det er afgørende at organisere din kode i henhold til et designmønster som MVC eller MVVM. At have velarkitektonisk kode gør ikke kun din kode velorganiseret og lettere at vedligeholde. Det gør også unit testing lettere.

Dette hjælper os også med at besvare et almindeligt spørgsmål: Hvilken kode skal du teste først?

Svaret er: Start med dine modeltyper.

Der er flere grunde til dette:

  • Hvis du følger MVC-mønstret eller en af dets afledninger, vil dine modeltyper indeholde domæneforretningslogikken. Hele din app afhænger af en sådan kodes korrekthed, så selv nogle få tests har en betydelig indvirkning.
  • Modeltyper er ofte strukturer, som er værdityper. Disse er langt nemmere at teste end referencetyper (vi vil se hvorfor om lidt).
  • Referencetyper, dvs. klasser, har afhængigheder og forårsager sideeffekter. For at skrive enhedstests for disse har du brug for testdoubler (dummies, stubs, fakes, spies og mocks) og skal bruge sofistikerede teknikker.
  • Controllere (i MVC-termer) eller viewmodeller (i MVVM-termer) er ofte forbundet med ressourcer som f.eks. disklagring, en database, netværket og enhedssensorer. De kan også køre parallelkode. Disse gør enhedstest vanskeligere.
  • View-kode er den kode, der ændres hyppigst, hvilket giver skøre tests, der let går i stykker. Og ingen kan lide at rette ødelagte tests.

Behandler værdityper som rene funktioner for at forenkle enhedstestning

Så, lad os tilføje nogle modeltyper til vores app.

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

struct FoodItem {
lad name: String
let caloriesFor100Grams: Int
let gram: Int
}
struct Meal {
private(set) var items: =
var calories: =
var calories: Int {
var calories = 0
for item in items {
calories += (item.caloriesFor100Grams / 100) * item.grams
}
return calories
}
mutating func add(_ item: FoodItem) {
items.append(item)
}
}

Måltidsstrukturen indeholder noget simpel logik til at tilføje madvarer og beregne de samlede kalorier for den mad, den indeholder.

Enhedstest har et sådant navn, fordi du bør teste din kode i separate enheder og fjerne alle afhængigheder (at teste afhængigheder sammen kaldes i stedet for integrationstest). Hver enhed behandles så som en black box. Du fodrer den med noget input og tester dens output for at kontrollere, om det er det, du forventer.

Det var nemt i tilfældet med vores faktoreksempel, fordi det var en ren funktion, dvs. en funktion, hvor output kun afhænger af input, og som ikke skaber nogen sideeffekt.

Men det ser ikke ud til at være tilfældet for vores Meal-type, som indeholder state.

Værdien, der returneres af calories computed property, har intet input. Den afhænger kun af indholdet af arrayet items. Metoden add(_:) returnerer i stedet ikke nogen værdi og ændrer kun den interne tilstand for et måltid.

Men strukturer er værdityper, og vi kan betragte dem som rene funktioner. Da de ikke har afhængigheder, kan man betragte en strukturs oprindelige tilstand som input til en metode og dens tilstand efter at have kaldt metoden som output.

(Dette er en af grundene til, at man bør afholde sig fra at sætte referencetyper inde i værdityper).

Verificering af din kode ved hjælp af assertions-funktionerne fra XCTest-rammen

Vi har nu noget kode, der skal testes, og et sted i vores Xcode-projekt, hvor vi kan skrive testene.

Vi mangler en sidste ingrediens: en måde at udtrykke, om vores kode er korrekt eller ej.

I eksemplet i begyndelsen af denne artikel brugte jeg simple print-meddelelser. Det er ikke praktisk til rigtig enhedstest. Du ønsker ikke at spilde tid på at gennemgå logfiler for at finde ud af, hvilke tests der mislykkedes. Vi har brug for en måde, der peger direkte på fejlslagne tests.

I XCTest-rammen finder du en række assertion-funktioner, der får Xcode til at pege på tests, der ikke består.

Med disse kan vi skrive vores første test.

Som jeg nævnte ovenfor, er enhedstests nyttige til at kontrollere edge cases, så lad os begynde med at sikre os, at et tomt måltid ikke har nogen kalorier.

1
2
3
4
5
6

klasse CaloriesTests: XCTestCase {
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, “Et tomt måltid skal have 0 kalorier”)
}
}

XCTest tilbyder en generisk XCTAssert-funktion, der giver dig mulighed for at bekræfte enhver betingelse. Du kan bruge den til enhver test, du skriver, men det er bedre at bruge mere specialiserede assertion-funktioner, når det er muligt, såsom XCTAssertEqual (ovenfor), XCTAssertNil og andre. Disse giver bedre fejl i Xcode end XCTAssert.

Du kan køre en enkelt test ved at klikke på diamanten ud for dens navn i kodeeditorens rille.

Når en test består, bliver den grøn, mens fejlslagne test markeres med rødt. Du kan ændre 0’et i testen til et andet tal for at se, hvordan en fejlslagen test ser ud.

Du kan køre alle testene i en testcase ved at klikke på diamanten ud for klassedeklarationen. Du kan også bruge testnavigatoren, som vi så ovenfor, til at køre individuelle test eller testtilfælde.

Du vil ofte køre alle testene i dit projekt på én gang, hvilket du hurtigt kan gøre ved at trykke cmd+U på dit tastatur eller vælge indstillingen Test i Xcodes produktmenu.

Måling af kodedækning af en test i Xcode

Det næste, mest almindelige spørgsmål om enhedstest er: Hvor meget af din kode skal du teste?

Dette måles normalt ved hjælp af kodedækning, dvs, den procentdel af koden, der er dækket af dine tests. Så før vi besvarer det spørgsmål, skal vi se, hvordan du kan måle dine testers kodedækning i Xcode.

Først og fremmest skal du lade Xcode indsamle dækningen for dit testmål. Klik på knappen med projektnavnet i den segmenterede kontrol i Xcode-værktøjslinjen (ved siden af knappen stop). Vælg derefter Rediger skema… i popup-menuen.

Der skal du vælge Test i omridset og derefter gå til fanen Indstillinger. Og til sidst skal du vælge Indsamle dækning for. Du kan lade dens indstilling stå til alle mål.

Du kan nu køre dine tests igen, hvilket vil give Xcode mulighed for at indsamle dækningsdataene. Du finder resultatet i rapportnavigatoren ved at vælge punktet Kodedækning i oversigten.

Her kan du kontrollere procentdelen af den kode, der er dækket af dine tests for hele projektet og for hver enkelt fil.

Disse tal er ikke så væsentlige for vores eksempel, da vi næsten ikke har skrevet nogen kode. Alligevel kan vi se, at add(_:)-metoden for Meal type har en dækning på 0 %, hvilket betyder, at vi ikke har testet den endnu.

Den beregnede egenskab calories har i stedet en dækning på 85,7 %, hvilket betyder, at der er nogle udførelsesveje i vores kode, som vores test ikke udløste.

I vores enkle eksempel er det let at forstå, hvilken vej det er. Vi testede kun kalorierne i et tomt måltid, så koden i for-loop’en blev ikke kørt.

I mere sofistikerede metoder er det dog måske ikke så ligetil.

Dertil kan du fremkalde kodedækningsstriben i Xcode-editoren. Du kan finde den i menuen Juster editorindstillinger i øverste højre hjørne.

Derved kommer en stribe frem, der viser dig dækningen for hver kodelinje.

Tallene fortæller dig, hvor mange gange hver linje blev udført under testen. Med rødt kan du se, hvilke linjer der slet ikke blev kørt (fuld) eller kun blev udført delvist (stribet).

Hvorfor det er meningsløst at sigte efter en bestemt kodedækning, og hvad du bør gøre i stedet

Så, hvilken procentdel er en god kodedækningsprocent?

Opfattelserne er vidt forskellige. Nogle udviklere mener, at 20 % er nok. Andre går efter højere tal, f.eks. 70 % eller 80 %. Der er endda udviklere, der mener, at kun 100 % er acceptabelt.

Men efter min mening er kodedækning en meningsløs målestok. Du kan bruge den til at informere dine beslutninger om testning, men du bør ikke behandle den som et mål, du skal nå.

For at se hvorfor, lad os skrive endnu en test for at dække den kode, vi endnu ikke har testet.

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

klasse CaloriesTests: XCTestCase {
let banana = FoodItem(name: “Banana”, caloriesFor100Grams: 89, grams: 118)
let steak = FoodItem(name: “Steak”, caloriesFor100Grams: 271, grams: 225)
let goatCheese = FoodItem(name: “Goat Cheese”, caloriesFor100Grams: 364, grams: 28)
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, “Et tomt måltid skal have 0 kalorier”)
}
func testCalories() {
var meal = Meal()
meal.add(banan)
meal.add(steak)
meal.add(goatCheese)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Hvis du genudfører alle testene, får du en dækning på 100 % for filen Model.swift. Det ser godt ud, ikke sandt?

Nu skal du gå ind og fjerne metoden testEmptyMeal(). Hvis du kører den resterende test alene, vil du se, at dækningen for vores Meal-typer stadig er 100 %.

Det viser dig allerede nu, at tallet 100 % kan give dig en falsk følelse af sikkerhed. Du ved, at vi nu ikke tester alle edge cases for den beregnede egenskab Kalorier. Og alligevel afspejler vores testdækning det ikke.

Jeg vil tilføje, at 100 % dækning ikke kun er misvisende, men endda skadeligt. Noget kode i dit projekt er tilbøjelig til at blive ændret konstant, især den nyeste kode og især i visninger.

Dækning af 100 % af det betyder, at du kun vil have produceret skøre tests, der løbende går i stykker, og som du skal rette. Disse tests opdager ikke fejl. De går i stykker, fordi funktionaliteten i din kode ændres.

Og her kommer vi til det punkt, som ingen taler om.

At skrive tests er ikke sjovt, på trods af hvad nogle udviklere hævder. Det er endnu mindre sjovt at rette tests. Gør det for meget, og du vil hade unit testing og skubbe det helt til side.

Psykologi er lige så vigtigt for softwareudvikling som bedste praksis. Vi er ikke maskiner.

Så, hvilket tal skal du sigte efter? Det kræver en mere uddybende diskussion, men her vil jeg give dig et resumé af min mening.

Skriv brugbare tests, startende med den ældste kode i dit projekt, som ikke vil blive ændret. Brug kun kodedækning til at identificere hvilken kode der endnu ikke er testet, men brug det ikke som et mål eller til at beslutte hvornår du skal skrive flere tests. 20 % dækning er bedre end 80 %, hvis dine tests er meningsfulde, og du tester den rigtige kode.

Som jeg nævner ovenfor, indeholder modeltyper den mest kritiske kode i dit projekt, som også har en tendens til at ændre sig mindst. Så start der.

Jeg gider normalt ikke teste hver eneste udførelsesvej. I et rigtigt projekt ville jeg f.eks. ikke skrive den testEmptyMeal()-metode, som jeg viste dig ovenfor. Ja, det er afgørende at teste edge cases, men de er heller ikke alle vigtige.

Jeg ville typisk kun skrive testCalories()-testen. Den fortæller mig, at min kode virker, og den vil advare mig, hvis jeg senere laver en fejl, når jeg ændrer denne metode.

Sikkert, den dækker ikke alle veje, men denne kode er så simpel, at det bare er spild af tid. Det er vigtigere at bruge sin tid på at skrive rigtig kode til funktioner, der hjælper brugerne, end at teste hver eneste udførelsesvej.

Jeg ved godt, at jeg vil få nogle skældsord fra nogle udviklere for denne mening, men jeg er ligeglad. Testdækning er en af de debatter, der aldrig vil ende.

Den kode, du skriver, er som regel for det meste rigtig. Der er ingen grund til at være paranoid. At have en overflod af tests, der går i stykker, hver gang du ændrer noget, er en belastning, ikke et aktiv.

Din tid og din viljestyrke er begrænset. Brug dem på vigtigere opgaver.

Arkitektur af SwiftUI-apps med MVC og MVVM

Det er nemt at lave en app ved at smide noget kode sammen. Men uden bedste praksis og en robust arkitektur ender du hurtigt med uoverskuelig spaghettikode, som du ikke kan administrere. I denne guide viser jeg dig, hvordan du strukturerer SwiftUI-apps korrekt.

FÅ DEN GRATIS BOG NU

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.