Matteo Manferdini

Många utvecklare tycker att enhetstestning är förvirrande. Detta förvärras av de avancerade tekniker du behöver för att testa klasser eller asynkron kod, som den för nätverksförfrågningar.

I verkligheten finns det enkla grundläggande begrepp som ligger till grund för enhetstestning. När man väl har förstått dessa blir enhetstestning plötsligt mer lättillgängligt.

Innehåll

  • Unittestning förklarat på ett enkelt sätt, begripliga termer
  • Enhetstestning gör att du kan kontrollera svåråtkomliga kantfall och förebygga buggar
  • Skrivning av enhetstester i Xcode
  • En bra arkitektur gör din kod lättare att testa
  • Behandla värdtyper som rena funktioner för att förenkla enhetstestning
  • Verifiera din kod med hjälp av assertionsfunktionerna från ramverket XCTest
  • Mätning av kodtäckningen för ett test i Xcode
  • Varför det är meningslöst att sikta på specifik kodtäckning och vad du bör göra istället

Enhetstestning förklarat på ett enkelt sätt, begripliga termer

När du skriver en app går du igenom en cykel där du skriver kod, kör den och kontrollerar om den fungerar som avsett. Men när din kod växer blir det svårare att testa hela appen manuellt.

Automatiserad testning kan utföra dessa tester åt dig. Så i praktiken är enhetstestning kod för att se till att koden i din app är korrekt.

Den andra anledningen till att enhetstestning kan vara svårt att förstå är att många begrepp omger den: testning i Xcode, app-arkitektur, testdubbletter, injektion av beroenden och så vidare.

Men i grund och botten är idén med enhetstestning ganska okomplicerad. Du kan skriva enkla enhetstester i vanlig Swift. Du behöver ingen speciell Xcode-funktion eller sofistikerad teknik, och du kan till och med köra dem inne i en Swift Playground.

Låt oss titta på ett exempel. Funktionen nedan beräknar faktorn för ett heltal, vilket är produkten av alla positiva heltal som är mindre än eller lika med inmatningen.

1
2
3
4
5
6
7

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

För att testa den här funktionen manuellt skulle du mata den med ett par siffror och kontrollera att resultatet är det du förväntar dig. Så du skulle till exempel prova den på fyra och kontrollera att resultatet är 24.

Du kan automatisera detta genom att skriva en Swift-funktion som gör testet åt dig. Till exempel:

1
2
3
4
5

func testFactorial() {
if factorial(of: 4) != 24 {
print(”Faktorn av 3 är fel”)
} }
}

Detta är enkel Swiftkod som alla kan förstå. Allt den gör är att kontrollera funktionens utdata och skriva ut ett meddelande om det inte är korrekt.

Det är enhetstestning i ett nötskal. Allt annat är klockor och visselpipor som Xcode lagt till för att göra testningen enklare.

Och det skulle åtminstone vara fallet om koden i våra appar var lika enkel som faktorfunktionen vi just skrev. Det är därför du behöver Xcodes stöd och avancerade tekniker som testdubbletter.

Den här idén är ändå användbar för både enkla och komplexa tester.

Enhetstestning gör det möjligt att kontrollera svåråtkomliga kantfall och förebygga buggar

Du kan få flera fördelar av enhetstestning.

Den mest uppenbara är att du slipper att kontinuerligt testa din kod manuellt. Det kan vara tråkigt att köra din app och komma till en specifik plats med den funktionalitet du vill testa.

Men det är inte allt. Enhetstester gör det också möjligt att testa kantfall som kan vara svåra att skapa i en app som körs. Och kantfall är där de flesta buggar lever.

Till exempel, vad händer om vi matar noll eller ett negativt tal till vår faktorial(of:)-funktion från ovan?

1
2
3
4
5

func testFactorialOfZero() {
if factorial(of: 0) != 1 {
print(”Faktorn av 0 är fel”)
} }
}

Vår kod går sönder:

I en riktig app skulle denna kod orsaka en krasch. Noll ligger utanför intervallet i for-slingan.

Men vår kod misslyckas inte på grund av en begränsning av Swift-områden. Den misslyckas på grund av att vi glömde att ta hänsyn till icke-positiva heltal i vår kod. Per definition existerar inte faktorn för ett negativt heltal, medan faktorn för 0 är 1.

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

func faktoriell(av tal: Int) -> Int? {
if (number < 0) {
return nil
}
if (number == 0) {
return 1
} }
var result = 1
for factor in 1…number {
result = result * factor
}
return result
}

Vi kan nu köra våra tester igen och även lägga till en kontroll för negativa tal.

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(of: -1) != nil {
print(”Faktorn av -1 är fel”)
} }
}

Här ser du den sista och, enligt mig, viktigaste fördelen med enhetstestning. Testerna som vi skrev tidigare ser till att när vi uppdaterar vår funktion factorial(of:) inte bryter mot dess befintliga kod.

Detta är avgörande i komplexa appar, där du måste lägga till, uppdatera och ta bort kod kontinuerligt. Enhetstester ger dig trygghet i att dina ändringar inte förstörde kod som fungerade bra.

Skrivning av enhetstester i Xcode

Med en förståelse för enhetstester kan vi nu titta på testfunktionerna i Xcode.

När du skapar ett nytt Xcode-projekt får du chansen att lägga till enhetstester direkt. Jag kommer som exempel att använda en app för att spåra kalorier. Du hittar hela Xcode-projektet här.

När du markerar det alternativet kommer ditt mallprojekt att innehålla ett redan konfigurerat testmål. Du kan alltid lägga till ett senare, men jag brukar kryssa för alternativet som standard. Även om jag inte tänker skriva tester direkt kommer allt att vara konfigurerat när jag bestämmer mig för att göra det.

Testmålet börjar redan med lite mallkod för våra tester.

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

importera XCTest
@testable importera Kalorier
klass KalorierTester: XCTestCase {
override func setUpWithError() throws {
// Lägg installationskoden här. Den här metoden anropas innan varje testmetod i klassen anropas.
}
override func tearDownWithError() throws {
// Sätt in teardown-kod här. Den här metoden anropas efter anropet av varje testmetod i klassen.
}
func testExample() throws {
// Detta är ett exempel på ett funktionellt testfall.
// Använd XCTAssert och relaterade funktioner för att kontrollera att dina tester ger rätt resultat.
}
func testPerformanceExample() throws {
// Detta är ett exempel på ett testfall för prestanda.
self.measure {
// Lägg koden som du vill mäta tiden för här.
}
}
}

  • Raden @testable import Calories ger dig tillgång till alla typer i ditt projekts huvudmål. Koden i en Swift-modul är inte tillgänglig utifrån om inte alla typer och metoder uttryckligen deklareras som offentliga. Nyckelordet @testable gör att du kan kringgå denna begränsning och få tillgång till även privat kod.
  • Klassen CaloriesTests representerar ett testfall, det vill säga en gruppering av relaterade tester. En testfallsklass måste härstamma från XCTestCase.
  • Med metoden setUpWithError() kan du ställa in en standardmiljö för alla tester i ett testfall. Om du behöver göra lite städning efter dina tester använder du tearDownWithError(). Dessa metoder körs före och efter varje test i ett testfall. Vi kommer inte att behöva dem, så du kan ta bort dem.
  • Metoden testExample() är ett test som liknar de vi skrev för vårt faktoriella exempel. Du kan namnge testmetoder som du vill, men de måste börja med testprefixet för att Xcode ska känna igen dem.
  • Och slutligen visar testPerformanceExample() hur du kan mäta kodens prestanda. Prestandatester är mindre standardiserade än enhetstester, och du använder dem endast för viktig kod som behöver vara snabb. Vi kommer inte heller att använda den här metoden.

Du hittar alla dina testfall och relativa tester listade i testnavigatorn i Xcode.

Du kan lägga till nya testfall i ditt projekt genom att helt enkelt skapa nya .swift-filer med kod som den ovan, men det är enklare att använda menyn Lägg till längst ner i testnavigatorn.

En bra arkitektur gör din kod lättare att testa

I ett riktigt projekt har du vanligtvis inte lösa funktioner som i vårt exempel med faktorn. Istället har du strukturer, uppräkningar och klasser som representerar de olika delarna av din app.

Det är viktigt att organisera din kod enligt ett designmönster som MVC eller MVVM. Att ha välarkitekterad kod gör inte bara din kod välorganiserad och lättare att underhålla. Det gör också enhetstestning enklare.

Detta hjälper oss också att besvara en vanlig fråga: vilken kod ska du testa först?

Svaret är: börja med dina modelltyper.

Det finns flera anledningar till detta:

  • Om du följer MVC-mönstret eller ett av dess derivat kommer dina modelltyper att innehålla domänens affärslogik. Hela din app är beroende av att sådan kod är korrekt, så även ett fåtal tester har en betydande inverkan.
  • Modelltyper är ofta strukturer, som är värdetyper. Dessa är mycket lättare att testa än referenstyper (vi kommer att se varför om en stund).
  • Referenstyper, dvs. klasser, har beroenden och orsakar sidoeffekter. För att skriva enhetstester för dessa behöver man testdubbletter (dummies, stubs, fakes, spioner och mocks) och använda sofistikerade tekniker.
  • Controllers (i MVC-termer) eller viewmodeller (i MVVM-termer) är ofta kopplade till resurser som disklagring, en databas, nätverket och enhetssensorer. De kan också köra parallell kod. Dessa gör enhetstestning svårare.
  • View-kod är den som ändras mest frekvent, vilket ger spröda tester som lätt går sönder. Och ingen gillar att fixa trasiga tester.

Behandla värdetyper som rena funktioner för att förenkla enhetstestning

Så, låt oss lägga till några modelltyper i vår app.

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 gram: Int
}
struct Meal {
private(set) var items: =
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 innehåller lite enkel logik för att lägga till matartiklar och beräkna den totala kalorinivån för den mat som den innehåller.

Enhetstestning har ett sådant namn eftersom du bör testa din kod i separata enheter, och ta bort alla beroenden (att testa beroenden tillsammans kallas istället för integrationstestning). Varje enhet behandlas då som en svart låda. Du matar den med viss input och testar dess output för att kontrollera om det är vad du förväntar dig.

Det var lätt i fallet med vårt exempel med faktorial eftersom det var en ren funktion, det vill säga en funktion där output endast beror på input och som inte skapar några bieffekter.

Men det verkar inte vara fallet med vår typ Meal, som innehåller state.

Värdet som returneras av den beräknade egenskapen calories har ingen input. Det beror endast på innehållet i arrayen items. Metoden add(_:) returnerar istället inget värde och ändrar bara det interna tillståndet för en måltid.

Men strukturer är värdetyper och vi kan betrakta dem som rena funktioner. Eftersom de inte har några beroenden kan man betrakta det initiala tillståndet i en struktur som ingången till en metod och dess tillstånd efter att ha anropat metoden som utgången.

(Detta är en av anledningarna till att man bör avstå från att placera referenstyper inuti värdetyper).

Verifiera din kod med hjälp av assertionsfunktionerna från ramverket XCTest

Vi har nu lite kod att testa och en plats i vårt Xcode-projekt där vi ska skriva testerna.

Vi saknar en sista ingrediens: ett sätt att uttrycka om vår kod är korrekt eller inte.

I exemplet i början av den här artikeln använde jag enkla utskriftsinstruktioner. Det är inte praktiskt för riktig enhetstestning. Du vill inte slösa tid på att gå igenom loggar för att identifiera vilka tester som misslyckades. Vi behöver ett sätt som pekar på misslyckade tester direkt.

I XCTest-ramverket hittar du en rad assertion-funktioner som får Xcode att peka på tester som inte klarar sig.

Med dessa kan vi skriva vårt första test.

Som jag nämnde ovan är enhetstester användbara för att kontrollera kantfall, så låt oss börja med att kontrollera att en tom måltid inte har några kalorier.

1
2
3
4
5
6

klass KalorierTest: XCTestCase {
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, ”En tom måltid bör ha 0 kalorier”)
}
}

XCTest erbjuder en generisk XCTAssert-funktion som gör det möjligt att bekräfta vilket villkor som helst. Du kan använda den för alla tester du skriver, men det är bättre att använda mer specialiserade assertion-funktioner när det är möjligt, som XCTAssertEqual (ovan), XCTAssertNil och andra. Dessa producerar bättre fel i Xcode än XCTAssert.

Du kan köra ett enskilt test genom att klicka på diamanten bredvid dess namn i kodredigerarens ränna.

När ett test godkänns blir det grönt, medan misslyckade test markeras med rött. Du kan ändra 0 i testet till en annan siffra för att se hur ett misslyckat test ser ut.

Du kan köra alla tester i ett testfall genom att klicka på diamanten bredvid klassdeklarationen. Du kan också använda testnavigatorn som vi såg ovan för att köra enskilda tester eller testfall.

Du vill ofta köra alla tester i ditt projekt på en gång, vilket du snabbt kan göra genom att trycka cmd+U på tangentbordet eller välja alternativet Test i Xcodes produktmeny.

Mätning av kodtäckning för ett test i Xcode

Nästa, vanligaste frågan om enhetstestning är: hur mycket av din kod ska du testa?

Detta mäts vanligen med kodtäckning, dvs, den procentandel av koden som täcks av dina tester. Så innan vi svarar på den frågan ska vi se hur du kan mäta dina testens kodtäckning i Xcode.

För det första måste du låta Xcode samla in täckningen för ditt testmål. Klicka på knappen med projektnamnet i den segmenterade kontrollen i Xcode-verktygsfältet (bredvid stoppknappen). Välj sedan Redigera schema… i snabbmenyn.

Där väljer du Test i översikten och går sedan till fliken Alternativ. Till sist väljer du Gather coverage for (Samla in täckning för). Du kan lämna dess alternativ till alla mål.

Du kan nu köra om dina tester, vilket gör att Xcode kan samla in täckningsdata. Du hittar resultatet i rapportnavigatorn genom att välja objektet Kodtäckning i översikten.

Här kan du kontrollera procentsatserna av kod som täcks av dina tester för hela projektet och varje fil.

De här siffrorna är inte så betydelsefulla för vårt exempel, eftersom vi knappt har skrivit någon kod. Ändå kan vi se att add(_:)-metoden för typen Meal har en täckning på 0 %, vilket innebär att vi inte har testat den ännu.

Den beräknade egenskapen calories har istället en täckning på 85,7 %, vilket innebär att det finns några exekveringsvägar i vår kod som vårt test inte utlöste.

I vårt enkla exempel är det lätt att förstå vilken väg det är. Vi testade bara kalorierna i en tom måltid, så koden i for-slingan kördes inte.

I mer sofistikerade metoder är det dock kanske inte lika enkelt.

För det kan du ta fram kodtäckningsremsan i Xcode-redigeraren. Du hittar den i menyn Adjust Editor Options i det övre högra hörnet.

Detta kommer att visa en remsa som visar täckningen för varje kodrad.

Siffrorna talar om hur många gånger varje rad utfördes under testningen. I rött kan du se vilka rader som inte kördes alls (fullt) eller som bara utfördes delvis (randigt).

Varför det är meningslöst att sikta på specifik kodtäckning och vad du bör göra i stället

Så, vilken procentsats är en bra procentsats för kodtäckning?

Opinionerna är väldigt olika. Vissa utvecklare anser att 20 procent är tillräckligt. Andra strävar efter högre siffror, som 70 % eller 80 %. Det finns till och med utvecklare som anser att endast 100 % är acceptabelt.

Enligt min åsikt är kodtäckning ett meningslöst mått. Du kan använda den för att informera dina beslut om testning, men du bör inte behandla den som ett mål som du måste uppnå.

För att se varför låt oss skriva ytterligare ett test för att täcka den kod som vi inte testat ännu.

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

klass KalorierTester: 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, gram: 28)
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, ”En tom måltid ska ha 0 kalorier”)
}
func testCalories() {
var meal = Meal()
meal.add(banana)
meal.add(steak)
meal.add(goatCheese)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Om du kör alla tester på nytt får du en 100-procentig täckning för filen Model.swift. Det ser bra ut, eller hur?

Nu går du och tar bort metoden testEmptyMeal(). Om du kör de återstående testerna ensamma kommer du att se att täckningen för våra Meal-typer fortfarande är 100 %.

Detta visar dig redan nu att siffran 100 % kan ge dig en falsk känsla av säkerhet. Du vet att vi nu inte testar alla kantfall för den beräknade egenskapen kalorier. Ändå återspeglar inte vår testtäckning detta.

Jag vill tillägga att 100 % täckning inte bara är vilseledande utan till och med skadligt. En del kod i ditt projekt är utsatt för ständiga förändringar, särskilt den nyaste koden och särskilt i vyer.

Täckning till 100 % innebär att du bara måste producera sköra tester som kontinuerligt går sönder och som du måste åtgärda. Dessa tester upptäcker inte buggar. De går sönder för att funktionaliteten i din kod förändras.

Och här kommer vi till den punkt som ingen pratar om.

Att skriva tester är inte roligt, trots vad vissa utvecklare hävdar. Att rätta tester är ännu mindre roligt. Gör du det för mycket kommer du att hata enhetstester och skjuta dem åt sidan helt och hållet.

Psykologi är lika viktigt för mjukvaruutveckling som bästa praxis. Vi är inte maskiner.

Så, vilken siffra ska du sikta på? Det kräver en mer omfattande diskussion, men här ger jag dig en sammanfattning av min åsikt.

Skriv användbara tester och börja med den äldsta koden i ditt projekt som inte kommer att ändras. Använd kodtäckning endast för att identifiera vilken kod som ännu inte testats, men använd den inte som ett mål eller för att bestämma när du ska skriva fler tester. 20 % täckning är bättre än 80 % om dina tester är meningsfulla och du testar rätt kod.

Som jag nämnde ovan innehåller modelltyper den mest kritiska koden i ditt projekt, som också tenderar att ändras minst. Så börja där.

Jag brukar inte bry mig om att testa varje enskild exekveringsväg. I ett riktigt projekt skulle jag till exempel inte skriva metoden testEmptyMeal() som jag visade dig ovan. Ja, det är viktigt att testa edge cases, men de är inte heller alla viktiga.

Jag skulle vanligtvis bara skriva testet testCalories(). Det talar om för mig att min kod fungerar, och det kommer att varna mig om jag senare gör ett misstag när jag ändrar den här metoden.

Säkerligen täcker det inte alla vägar, men den här koden är så enkel att det bara är slöseri med tid. Att ägna sin tid åt att skriva riktig kod för funktioner som hjälper användarna är viktigare än att testa varje exekveringsväg.

Jag vet att jag kommer att få en del kritik från vissa utvecklare för den här åsikten, men jag bryr mig inte. Testtäckning är en av de debatter som aldrig kommer att ta slut.

Koden du skriver är oftast oftast rätt. Det finns ingen anledning att vara paranoid. Att ha en uppsjö av tester som går sönder varje gång du ändrar något är en belastning, inte en tillgång.

Din tid och din viljestyrka är begränsade. Använd dem på viktigare uppgifter.

Arkitektur av SwiftUI-appar med MVC och MVVM

Det är lätt att göra en app genom att slänga ihop lite kod. Men utan bästa praxis och robust arkitektur hamnar du snart i oöverskådlig spaghettikod. I den här guiden visar jag dig hur du strukturerar SwiftUI-appar på rätt sätt.

FÅ DEN GRATIS BOKEN NU

Lämna ett svar

Din e-postadress kommer inte publiceras.