I Swift finns det ett kraftfullt sätt att hantera och övervaka förändringar i värden via egenskapsobservatörer och egenskapspaket. Dessa verktyg gör det möjligt att automatisera och centralisera vissa aspekter av koden, vilket gör den mer effektiv och lättläst. I denna del av boken ska vi titta på två specifika verktyg i Swift: egenskapsobservatörer och egenskapspaket, och undersöka deras användning, fördelar och potentiella nackdelar.

En av de grundläggande egenskaperna i Swift är egenskapsobservatörer. Dessa observatörer gör det möjligt att reagera på förändringar i en egenskap innan och efter att den ändras. För att förstå hur de fungerar kan vi titta på ett exempel där vi säljer produkter från ett lager.

Anta att vi har ett system för att sälja böcker, och varje gång vi säljer en bok minskar lagerantalet. Vi kan använda en egenskapsobservatör för att trigga ett meddelande om vi inte har tillräckligt med lager för att möta efterfrågan. När vi säljer tre enheter av boken "Mastering Swift", ser vi följande resultat:

"Säljer 1 enhet av Mastering Swift. Såld en.
Säljer 1 enhet av Mastering Swift. Såld en till.
Säljer 1 enhet av Mastering Swift. Lager nivå är under minimum för Mastering Swift. Alert: Lager för Mastering Swift behöver beställas om. Såld en tredje."

I detta exempel har vi en egenskap för lagerstatus där vi använder en egenskapsobservatör för att hantera försäljningen. När lagerstatusen sjunker under ett visst minimum, triggas ett meddelande om att varan behöver beställas om. Denna funktionalitet gör det möjligt att hantera lagerstatusen mer effektivt, utan att behöva skriva mycket extra kod varje gång lagret ändras.

En annan användbar typ av observatör är willSet. Detta är en observer som reagerar innan ett värde förändras. Ett klassiskt användningsområde för willSet är loggning, där vi kan registrera förändringar innan de inträffar. Här är ett exempel på en struktur som använder willSet:

swift
struct User {
var userName: String { willSet { logger("Användarnamn ändras från \(userName) till \(newValue)") } } var password: String }

I detta exempel loggar vi användarnamnet innan det ändras. Vi använder newValue för att komma åt det nya värdet innan det sätts. På detta sätt kan vi följa alla förändringar i användarnamn på ett enkelt sätt.

Det finns dock vissa begränsningar och överväganden som bör beaktas när man använder egenskapsobservatörer. Först och främst kallas inte egenskapsobservatörer vid initialisering av en egenskap; de reagerar endast på förändringar som sker efter att egenskapen har blivit initialiserad. Detta innebär att om du behöver observera en förändring direkt vid skapandet, behöver du hantera detta på annat sätt.

En annan aspekt att tänka på är prestanda. Eftersom varje förändring av en egenskap triggar en metodkall, kan användning av observatörer påverka prestandan negativt om de används för mycket. Slutligen, om man överanvänder egenskapsobservatörer, kan koden bli svårare att följa och underhålla. Det kan vara svårt att se logiken direkt utan att noggrant inspektera varje observer, vilket gör koden mer komplex än nödvändigt.

Nu när vi har förstått hur egenskapsobservatörer fungerar, är det dags att titta på ett annat kraftfullt verktyg i Swift: egenskapspaket.

Egenskapspaket tillåter oss att flytta ansvaret för att läsa och skriva en egenskap till en separat definition. Detta minskar kodens komplexitet och duplicering och gör det möjligt att återanvända logik för att hantera lagring, validering och förändringar av egenskaper. Genom att använda egenskapspaket kan vi skapa återanvändbara komponenter som kapslar in vanliga funktioner, som datavalidering och lagring, i en elegant och modulär struktur.

Ett exempel på hur man definierar ett egenskapspaket i Swift kan se ut så här:

swift
@propertyWrapper struct MyPropertyWrapper { private var value: T var wrappedValue: T { get { return value } set { value = newValue } } init(wrappedValue initialValue: T) { self.value = initialValue } }

I detta exempel använder vi @propertyWrapper-attributet för att definiera ett egenskapspaket. Här skapar vi en generell struktur, MyPropertyWrapper, som kan användas med vilken datatyp som helst genom att använda generics. Egenskapen wrappedValue tillåter oss att hämta och sätta värdet på den lagrade egenskapen. Detta paket kapslar in all logik för att hantera värdet och gör det möjligt att återanvända samma logik för olika datatyper.

Vi kan använda detta egenskapspaket på en egenskap på följande sätt:

swift
struct MyPropertyWrapperExample {
@MyPropertyWrapper var number: Int init(number: Int) { self.number = number } }

På så sätt kan vi applicera MyPropertyWrapper på egenskapen number, vilket gör koden mer modulär och återanvändbar.

Ett annat vanligt användningsområde för egenskapspaket är att modifiera data innan det sätts. Ett exempel är att automatiskt kapitalisera en sträng varje gång den sätts. Här är ett exempel på ett egenskapspaket som gör just detta:

swift
@propertyWrapper struct Capitalized {
private var value: String = ""
var wrappedValue: String { get { return value } set { value = newValue.capitalized } } }

Genom att använda detta egenskapspaket kan vi säkerställa att värdet som sätts på egenskapen alltid är kapitaliserat. Om vi till exempel använder det i en struktur:

swift
struct Person { @Capitalized var name: String init(name: String) { self.name = name } }

När vi nu sätter en sträng till egenskapen name kommer den automatiskt att kapitaliseras. Om vi skapar en instans av Person och skriver ut namnet:

swift
let person = Person(name: "jon hoffman")
print(person.name) // Jon Hoffman

Vi ser att namnet är kapitaliserat, även om vi inte initialiserade det så.

För mer avancerade användningsområden kan vi skapa ett egenskapspaket som validerar värdet baserat på ett intervall. Till exempel kan vi definiera ett egenskapspaket som säkerställer att ett heltal alltid ligger inom ett angivet intervall:

swift
@propertyWrapper struct ValidateRange { private var value: Int private let range: ClosedRange<Int> var wrappedValue: Int { get { return value }
set { value = max(range.lowerBound, min(range.upperBound, newValue)) }
}
init(wrappedValue: Int, _ range: ClosedRange<Int>) { self.value = max(range.lowerBound, min(range.upperBound, wrappedValue)) self.range = range } }

Genom att använda detta paket kan vi försäkra oss om att egenskapens värde aldrig går utanför det definierade intervallet. Detta är användbart i många scenarier, till exempel när vi hanterar mängder eller priser.

swift
struct Item {
@ValidateRange(1...100) var quantity = 5 }

Det är viktigt att förstå att egenskapspaket och observatörer, även om de kan förenkla koden, måste användas med försiktighet för att undvika överkomplexitet och prestandaproblem.

Hur effektiviteten i Swift Testing kan förbättra enhetstester för kalkylatorapplikationer

När vi har aktiverat inställningen "Enable Testability" för att använda @testable i vår kalkylatorapplikation, kan vi börja importera kalkylatormodulen i testfilen. För detta syfte lägger vi till följande rad högst upp i vår CalculatorTests.swift-fil: @testable import Calculator. Detta gör att vi kan komma åt de interna typerna, metoderna och egenskaperna i kalkylatormodulen inom våra enhetstester. Det är viktigt att notera att även om @testable ger oss åtkomst till interna medlemmar, förblir privata egenskaper och metoder otillgängliga. För att tillåta läsåtkomst från externa källor kan vi dock använda modifieraren private(set), vilket gör att vi kan exponera privata egenskaper för testning.

Nu när vi kan åtkomma kalkylatorkoden i våra tester, kan vi börja definiera själva testerna. För att skapa en enhetstestsvit för att organisera våra tester är det bäst att skapa en strukturerad testsuite. Låt oss börja med att skapa en testsuite som heter CalculatorTest och definiera ett test för att verifiera funktionen add() i kalkylatorn.

swift
@Suite("Calculator test") struct Calculator_Test { }

Det här kodavsnittet skapar en testsuite för att organisera våra enhetstester som validerar kalkylatorns funktionalitet. Vi lägger sedan till en enhetstest i denna suite för att kontrollera funktionaliteten i add()-funktionen. Koden ser ut så här:

swift
@Test func simpleAdditionTest() {
#expect(Calculator.add(2, 2) == 4) }

I det här testet verifierar vi att add()-funktionen fungerar korrekt genom att addera två siffror och jämföra resultatet. Men även om detta test skulle passera, kan ett potentiellt problem uppstå. Tänk om vi, när vi skapade add()-funktionen, oavsiktligt introducerade ett skrivfel som gör att vi multiplicerar istället för att addera?

swift
static func add(_ one: Double, _ two: Double) -> Double { return one * two }

I detta fall skulle testet fortfarande passera eftersom 2 multiplicerat med 2 ger 4. Ett sådant misstag är vanligt förekommande i enhetstester, och därför kan vi använda argument i Swift Testing för att köra testerna flera gånger med olika värden för att minska risken för sådana fel.

För att hantera detta skapar vi en typ i CalculatorTests-modulen som håller argumenten. Denna typ innehåller tre värden: de två siffrorna för matematiska operationer och det förväntade resultatet. Här är ett exempel på hur detta kan se ut:

swift
struct TestValues { let first: Double let second: Double let answer: Double }

Med denna typ kan vi skriva om vårt test för att köra flera tester med olika värden på följande sätt:

swift
@Test("Addition Tests", arguments: [
TestValues(first: 2, second: 3, answer: 5),
TestValues(first: 10, second: 11, answer: 21),
TestValues(first: 3.5, second: 4.5, answer: 8) ]) func testAddition(_ values: TestValues) async throws { #expect(Calculator.add(values.first, values.second) == values.answer) }

I denna kod lägger vi till två attribut: ett namn för testet, "Addition Tests", och en array av TestValues som definierar de värden vi vill testa. Testfunktionen tar emot ett argument av typen TestValues, och vi använder dessa värden för att verifiera att add()-funktionen fungerar korrekt.

För att säkerställa att alla funktioner i kalkylatorn fungerar korrekt, kan vi lägga till liknande tester för subtraktion, multiplikation och division:

swift
@Test("Subtraction Tests", arguments: [
TestValues(first: 2, second: 3, answer: -1),
TestValues(first: 11, second: 10, answer: 1),
TestValues(first: 5, second: 4.5, answer: 0.5)
])
func testSubtraction(_ values: TestValues) async throws { #expect(Calculator.subtract(values.first, values.second) == values.answer) } @Test("Multiplication Tests", arguments: [ TestValues(first: 2, second: 3, answer: 6),
TestValues(first: 11, second: 10, answer: 110),
TestValues(first: 5, second: 4.5, answer: 22.5) ]) func testMultiply(_ values: TestValues) async throws { #expect(Calculator.multiply(values.first, values.second) == values.answer) } @Test("Division Tests", arguments: [
TestValues(first: 6, second: 3, answer: 2),
TestValues(first: 11, second: 1, answer: 11),
TestValues(first: 20, second: 5, answer: 4)
])
func testDivide(_ values: TestValues) async throws { #expect(Calculator.divide(values.first, values.second) == values.answer) }

När alla tester har lagts till i testsviten, kan vi köra hela sviten på en gång i Xcode genom att klicka på diamantsymbolen bredvid svitens namn. Resultatet från dessa tester kan vi sedan granska direkt i Xcode:s vänstra sidopanel, där vi får en överblick av alla tester som har körts. Om ett test misslyckas, som i följande exempel, kommer vi att få detaljer om varför det misslyckades.

swift
Figure 18.11: Identifying test failures

Det ger oss möjlighet att snabbt lokalisera den kod som orsakar felet och hjälpa oss att rätta till det. Om vi har ett känt problem som vi medvetet vill lämna för att kunna köra andra tester, kan vi använda funktionen withKnownIssue för att markera det testet som "känd fråga", vilket gör att testet kan köras utan att det påverkar andra tester.

swift
@Test func simpleAdditionTest() {
withKnownIssue("Addition will fail") { #expect(Calculator.add(2, 2) == 5) } }

Med denna funktion får testet en grå ikon istället för den vanliga röda X:en, vilket indikerar att ett känt problem finns. När problemet är åtgärdat och testet går igenom korrekt, får vi en påminnelse om att ta bort funktionen withKnownIssue.

I den här processen är det viktigt att förstå hur Swift Testing gör enheten testning mer flexibel och hanterbar, och hur man kan dra nytta av de nya funktionerna för att snabbt identifiera och åtgärda problem. Dessutom ger det oss möjlighet att gradvis byta ut äldre XCTest-tester mot Swift Testing utan att riskera att bryta befintlig funktionalitet.