Testningens nya era i Swift
Under WWDC24 presenterade Apple ett helt nytt testramverk byggt från grunden för Swift: Swift Testing. Det här var inte bara en ansiktslyftning av XCTest – det var ett fundamentalt nytänk kring hur vi skriver, organiserar och kör tester i våra Swift-projekt. Och med Swift 6.2 och Xcode 26, som dök upp under WWDC25, har ramverket mognat rejält med funktioner som exit tests, förbättrade scoping traits och djupare integration med Swift concurrency.
Frågan är inte längre om du ska byta till Swift Testing – utan när.
XCTest är visserligen inte deprecated, men det nya ramverket erbjuder så pass många fördelar att det snabbt håller på att bli branschstandard. I den här guiden går vi igenom allt du behöver veta: från grunderna med @Test och @Suite till avancerade mönster som parametriserade tester, anpassade traits, confirmation-API:et och de nya exit tests i Swift 6.2. Vi jämför även med XCTest och ger praktiska tips för migrering.
Oavsett om du skriver dina första tester eller har hundratals XCTest-klasser att migrera – den här artikeln är för dig. Så, låt oss dyka in.
Varför ett nytt testramverk?
XCTest har tjänat oss väl i över tio år. Men ramverket bär på ett arv från Objective-C som blir allt mer påtagligt i en modern Swift-kodbas. Låt oss vara ärliga – vissa delar känns riktigt ålderdomliga vid det här laget.
XCTests arvssyndrom
I XCTest måste varje testklass ärva från XCTestCase. Testmetoder identifieras genom namnkonventioner – alla metoder som börjar med test körs automatiskt. Det är en mekanism som härstammar från Objective-C:s runtime och som, ärligt talat, känns ganska daterad i ett språk med makron, protokoll och generics.
// XCTest - den gamla världen
class UserServiceTests: XCTestCase {
var sut: UserService!
override func setUp() {
super.setUp()
sut = UserService()
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testFetchUserReturnsValidUser() throws {
let user = try sut.fetchUser(id: 42)
XCTAssertNotNil(user)
XCTAssertEqual(user?.name, "Alice")
}
}
Problemet? Massvis med boilerplate. Obligatoriskt arv, namnkonventioner, över fyrtio varianter av XCTAssert-funktioner att hålla reda på, och ett omständligt setUp/tearDown-mönster som kräver att du manuellt hanterar livscykeln för dina testobjekt. Det blir tröttsamt efter ett tag.
Asynkront testande var smärtsamt
XCTests hantering av asynkron kod var... ja, krånglig. XCTestExpectation och waitForExpectations(timeout:) var funktionellt men långt ifrån elegant:
// XCTest - asynkront testande med expectations
func testAsyncDataFetch() {
let expectation = expectation(description: "Data ska hämtas")
service.fetchData { result in
switch result {
case .success(let data):
XCTAssertFalse(data.isEmpty)
case .failure:
XCTFail("Förväntade framgång, fick fel")
}
expectation.fulfill()
}
waitForExpectations(timeout: 5)
}
Visst, XCTest fick stöd för async/await i nyare versioner, men det underliggande ramverket designades aldrig med Swift concurrency i åtanke. Och det märks, tyvärr.
Grunderna i Swift Testing
Swift Testing bygger på tre grundpelare: @Test-makrot för att definiera tester, @Suite-makrot för att organisera dem, och #expect/#require-makrona för att göra påståenden. Inte mer, inte mindre. Låt oss titta på var och en.
@Test – deklarativa tester
Istället för att namnge metoder med test-prefix använder du helt enkelt @Test-makrot:
import Testing
struct UserServiceTests {
let service = UserService()
@Test("Hämtning av användare returnerar korrekt namn")
func fetchUser() throws {
let user = try service.fetchUser(id: 42)
#expect(user.name == "Alice")
}
}
Lägg märke till ett par saker här. Vi behöver inget arv – vi använder en vanlig struct. Det innebär att varje testinstans är helt isolerad utan att vi behöver manuellt hantera setUp och tearDown. Strukturens initialiserare fungerar som setup, och deinitialisering sker automatiskt. Smidigt, eller hur?
@Test-makrot tar en valfri visningsnamn-sträng som första argument. Det namnet syns i Xcodes testnavigator och i loggar, vilket gör det mycket enklare att förstå vad ett test faktiskt verifierar utan att behöva läsa koden.
@Suite – gruppering och struktur
@Suite-makrot låter dig explicit gruppera relaterade tester och kan nästlas hierarkiskt:
@Suite("Användarhantering")
struct UserManagementTests {
@Suite("Skapande")
struct CreationTests {
@Test("Skapar användare med korrekt e-post")
func createWithEmail() throws {
let user = User(email: "[email protected]")
#expect(user.email == "[email protected]")
}
@Test("Avvisar ogiltig e-postadress")
func rejectInvalidEmail() {
#expect(throws: ValidationError.self) {
try User(email: "inte-en-epost")
}
}
}
@Suite("Autentisering")
struct AuthTests {
@Test("Lyckas med korrekta uppgifter")
func successfulLogin() async throws {
let result = try await AuthService.login(
email: "[email protected]",
password: "säkertLösenord123"
)
#expect(result.isAuthenticated)
}
}
}
Suites kan använda klasser istället för strukturer om du behöver delat tillstånd med init och deinit. Men i de allra flesta fall är strukturer att föredra – de ger naturlig isolering mellan tester.
#expect och #require – moderna påståenden
Istället för XCTests fyrtio-plus varianter av XCTAssert-funktioner har Swift Testing två makron: #expect och #require. Bara två. Det är befriande.
#expect är ett mjukt påstående – testet fortsätter även om det misslyckas. #require är ett hårt påstående – testet avbryts omedelbart vid misslyckande. Tänk på det så här: #expect samlar alla fel, medan #require stoppar testet vid första problemet.
@Test func verifieraAnvändardata() throws {
let user = try #require(fetchUser(id: 1)) // Avbryt om nil
#expect(user.name == "Alice") // Fortsätt även vid fel
#expect(user.age >= 18) // Fortsätt även vid fel
#expect(user.isActive) // Fortsätt även vid fel
}
@Test func kontrolleraFelhantering() {
#expect(throws: NetworkError.timeout) {
try networkService.fetchWithTimeout(seconds: 0)
}
}
Det eleganta med #require är att det även fungerar som en ersättning för XCTUnwrap. Du kan använda det för att säkert packa upp optionella värden:
@Test func hämtaProduktdetaljer() throws {
let products = try productService.fetchAll()
let firstProduct = try #require(products.first)
#expect(firstProduct.price > 0)
#expect(!firstProduct.name.isEmpty)
}
Och här kommer det riktigt snygga: när ett #expect-makro misslyckas får du oerhört tydliga felmeddelanden. Ramverket visar inte bara att uttrycket var falskt – det expanderar hela uttrycket och visar de faktiska värdena. Istället för ett generiskt "XCTAssertEqual failed: (3) is not equal to (5)" får du en detaljerad beskrivning av vad som gick fel. Det sparar en massa tid vid felsökning.
Parametriserade tester: Mer täckning, mindre kod
En av de mest kraftfulla funktionerna i Swift Testing är parametriserade tester. Konceptet är enkelt men otroligt effektivt: du skriver ett test en gång och kör det med flera uppsättningar indata. Jag önskar ärligt talat att vi hade haft det här i XCTest för länge sedan.
Grundläggande parametrisering
@Test("Validering av e-postformat", arguments: [
"[email protected]",
"[email protected]",
"[email protected]"
])
func validEmail(_ email: String) {
#expect(EmailValidator.isValid(email))
}
@Test("Avvisning av ogiltiga e-postadresser", arguments: [
"inteenepost",
"@saknarlokal.com",
"saknar@domän",
""
])
func invalidEmail(_ email: String) {
#expect(!EmailValidator.isValid(email))
}
Varje argument genererar ett separat testfall i Xcodes testnavigator. Det betyder att om ett argument misslyckas kan du se exakt vilket utan att köra om alla andra. Du kan till och med köra om enskilda testfall – förutsatt att argumenttypen uppfyller Codable.
Flera parametrar
Du kan ha flera parametrar, och Swift Testing skapar automatiskt den kartesiska produkten av alla argument. Det låter kanske lite akademiskt, men i praktiken är det extremt användbart:
enum Currency: String, CaseIterable {
case sek, usd, eur
}
@Test("Valutakonvertering ger positivt resultat",
arguments: [100.0, 0.01, 999999.99],
Currency.allCases
)
func currencyConversion(amount: Double, currency: Currency) throws {
let result = try CurrencyConverter.convert(
amount: amount,
from: .sek,
to: currency
)
#expect(result > 0)
}
I exemplet ovan genereras nio testfall (3 belopp × 3 valutor). Varje kombination körs oberoende och kan till och med köras parallellt, vilket ger dig snabbare testkörningar utan extra kodarbete. Rätt coolt, faktiskt.
Anpassade testargument med struct
För mer komplexa scenarier kan du skapa egna typer som testargument:
struct LoginScenario: CustomStringConvertible, Sendable {
let email: String
let password: String
let expectedResult: Bool
var description: String
static let testCases: [LoginScenario] = [
LoginScenario(
email: "[email protected]", password: "Password1!",
expectedResult: true, description: "Giltig inloggning"
),
LoginScenario(
email: "", password: "Password1!",
expectedResult: false, description: "Tom e-post"
),
LoginScenario(
email: "[email protected]", password: "",
expectedResult: false, description: "Tomt lösenord"
),
LoginScenario(
email: "[email protected]", password: "kort",
expectedResult: false, description: "För kort lösenord"
),
]
}
@Test("Inloggningsvalidering", arguments: LoginScenario.testCases)
func loginValidation(_ scenario: LoginScenario) {
let result = AuthValidator.validate(
email: scenario.email,
password: scenario.password
)
#expect(result == scenario.expectedResult)
}
Genom att låta typen uppfylla CustomStringConvertible ser du meningsfulla beskrivningar i testnavigatorn istället för kryptiska minnesadresser. En liten detalj som gör stor skillnad i vardagen.
Traits: Anpassa och annotera tester
Traits är Swift Testings mekanism för att modifiera och annotera testbeteende. Tänk på dem som dekoratörer som du kan fästa på enskilda tester eller hela suites.
Inbyggda traits
Ramverket levereras med flera användbara traits direkt ur lådan:
// Tidsgräns - testet misslyckas om det tar för lång tid
@Test(.timeLimit(.minutes(2)))
func longRunningOperation() async throws {
let result = try await heavyComputation()
#expect(result.isComplete)
}
// Villkorlig körning - hoppa över baserat på plattform
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func onlyOnCI() {
// Kör bara på CI-servern
}
// Bugg-referens - dokumentera kända problem
@Test(.bug("https://github.com/example/project/issues/42",
"Kraschar vid tom indata"))
func knownCrashScenario() {
// Test som relaterar till en specifik bugg
}
// Inaktiverat test med förklaring
@Test(.disabled("Väntar på server-API v2"))
func futureFeature() {
// Kommer aktiveras när API:et är klart
}
Taggar för flexibel gruppering
Taggar låter dig kategorisera tester oberoende av deras fysiska placering i koden. Det här är en funktion jag personligen använder väldigt mycket. Du definierar taggar som en extension på Tag:
extension Tag {
@Tag static var networking: Self
@Tag static var database: Self
@Tag static var ui: Self
@Tag static var slow: Self
@Tag static var critical: Self
}
@Test(.tags(.networking, .critical))
func apiHealthCheck() async throws {
let response = try await APIClient.healthCheck()
#expect(response.statusCode == 200)
}
@Test(.tags(.database))
func databaseMigration() throws {
try DatabaseManager.migrate(to: .version3)
#expect(DatabaseManager.currentVersion == 3)
}
@Suite(.tags(.networking))
struct NetworkTests {
// Alla tester i denna suite ärver taggen .networking
@Test(.tags(.slow))
func downloadLargeFile() async throws {
// Har både .networking (ärvd) och .slow
let data = try await client.download(url: largeFileURL)
#expect(data.count > 1_000_000)
}
}
I Xcodes testnavigator kan du filtrera och köra tester baserat på taggar. Det är oerhört praktiskt i CI/CD-pipelines där du kanske vill köra alla .critical-tester vid varje commit men bara köra .slow-tester nattetid.
Anpassade scoping traits (Swift 6.1+)
Från Swift 6.1 kan du skapa anpassade traits som kör kod före och efter tester. Det här öppnar upp för riktigt kraftfulla mönster – speciellt för att dela setup-logik utan att använda globalt tillstånd:
struct MockServerTrait: TestTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
// Starta mock-server före testet
let server = try await MockServer.start(port: 8080)
defer { server.stop() }
// Sätt upp miljövariabel
setenv("API_BASE_URL", "http://localhost:8080", 1)
// Kör det faktiska testet
try await function()
}
}
extension TestTrait where Self == MockServerTrait {
static var mockServer: Self { MockServerTrait() }
}
// Användning
@Test(.mockServer)
func fetchUsersFromAPI() async throws {
let users = try await APIClient.fetchUsers()
#expect(!users.isEmpty)
}
Det fina med den här designen är att den undviker onödigt djupa anropskedjor. Bara traits som faktiskt implementerar TestScoping deltar i exekveringskedjan – en trait som bara lägger till metadata (som en tagg) skapar inget extra overhead.
Asynkront testande: Naturligt och elegant
Swift Testing designades med Swift concurrency som grundförutsättning. Det betyder att asynkrona tester fungerar precis som du förväntar dig – inga konstigheter, inga workarounds:
@Test func hämtaVäderdata() async throws {
let weather = try await WeatherService.fetch(city: "Stockholm")
#expect(weather.temperature != nil)
#expect(weather.humidity >= 0 && weather.humidity <= 100)
}
@Test func parallellaAnrop() async throws {
async let users = UserService.fetchAll()
async let products = ProductService.fetchAll()
let (fetchedUsers, fetchedProducts) = try await (users, products)
#expect(!fetchedUsers.isEmpty)
#expect(!fetchedProducts.isEmpty)
}
Inget behov av XCTestExpectation eller waitForExpectations. Ramverket hanterar väntandet automatiskt tack vare Swift concurrency. Så som det borde vara.
Confirmation – för callback-baserade API:er
Men vad gör du med äldre API:er som fortfarande använder completion handlers? Eller med delegatmönster där du behöver verifiera att en viss callback faktiskt anropas? Här kommer confirmation-funktionen in:
@Test func notifikationSkickas() async {
await confirmation("Notifikation ska tas emot") { confirmed in
let observer = NotificationCenter.default.addObserver(
forName: .dataDidUpdate,
object: nil,
queue: nil
) { _ in
confirmed() // Markera som bekräftad
}
DataManager.shared.triggerUpdate()
// Rensa upp
NotificationCenter.default.removeObserver(observer)
}
}
@Test func fleraDelegatAnrop() async {
await confirmation(
"Delegaten ska anropas tre gånger",
expectedCount: 3
) { delegateCall in
let handler = KeyEventHandler()
handler.onKeyPress = { event in
if event.type == .keyDown {
delegateCall()
}
}
// Simulera tre tangenttryckningar
handler.simulateKeyPress(.a)
handler.simulateKeyPress(.b)
handler.simulateKeyPress(.c)
}
}
confirmation väntar tills det förväntade antalet bekräftelser har skett. Som standard förväntas exakt en bekräftelse, men du kan ange ett annat antal med expectedCount. Om bekräftelsen inte sker inom en rimlig tid misslyckas testet – precis som förväntat.
Completion handlers med continuations
För att testa äldre completion handler-baserade API:er kan du kombinera withCheckedContinuation med Swift Testing. Det är ett vanligt mönster som fungerar riktigt bra:
@Test func legacyAPIHämtning() async throws {
let data = try await withCheckedThrowingContinuation { continuation in
LegacyNetworkClient.fetchData(from: "/api/users") { result in
continuation.resume(with: result)
}
}
#expect(!data.isEmpty)
}
Felhantering och kända problem
Swift Testing erbjuder eleganta verktyg för att hantera och dokumentera kända problem i din kodbas. Det är en av de där funktionerna som inte låter speciellt spännande på papper, men som gör enorm skillnad i praktiken.
withKnownIssue
Ibland vet du att ett test kommer misslyckas på grund av en känd bugg. Istället för att inaktivera testet helt (och glömma bort det) kan du använda withKnownIssue:
@Test func sorteringAvSpecialtecken() {
withKnownIssue("Unicode-sortering hanterar inte å/ä/ö korrekt") {
let sorted = ["öl", "ål", "äl"].sorted(using: SwedishCollation())
#expect(sorted == ["äl", "ål", "öl"])
}
}
// För intermittenta problem
@Test func externAPITillgänglighet() async {
await withKnownIssue(
"Extern API kan vara tillfälligt otillgängligt",
isIntermittent: true
) {
let response = try await ExternalAPI.healthCheck()
#expect(response.isHealthy)
}
}
Med isIntermittent: true passerar testet om det lyckas, men markeras som ett förväntat fel om det misslyckas. Det ger dig synlighet i testrapporterna utan att bryta din CI-pipeline. Riktigt smart lösning, tycker jag.
Issue.record för explicita fel
När du behöver markera ett testfel utan ett uttryck att utvärdera:
@Test func verifieraKonfiguration() throws {
guard let config = ConfigManager.load() else {
Issue.record("Konfigurationsfilen kunde inte laddas")
return
}
#expect(config.apiKey != nil)
#expect(config.environment == .production)
}
Exit Tests: Nytt i Swift 6.2
En spännande nyhet i Swift 6.2 är exit tests – möjligheten att testa kod som förväntas avsluta processen. Det här är något som utvecklare har efterfrågat länge, och nu har det äntligen landat. Det är användbart för att verifiera att fatalError, preconditionFailure eller andra programavslutande tillstånd faktiskt triggas korrekt.
#if os(macOS) || os(Linux)
@Test func fatalErrorVidOgiltigtIndex() async {
await #expect(processExitsWith: .failure) {
let array = [1, 2, 3]
_ = array[10] // Ska trigga ett avslut
}
}
#endif
Tekniskt sett fungerar exit tests genom att ramverket skapar en subprocess (fork), kör den angivna koden i subprocessen och sedan undersöker hur processen avslutades. Det innebär en viktig begränsning: exit tests fungerar bara på plattformar som stöder subprocesser – alltså macOS, Linux, FreeBSD, OpenBSD och Windows. iOS, watchOS och tvOS stöds inte.
Trots den begränsningen är exit tests ett oerhört värdefullt verktyg för biblioteksutvecklare och de som bygger ramverk. Att kunna verifiera att dina preconditions och fatalErrors faktiskt triggas ger en trygghet som helt enkelt var omöjlig att uppnå med enhetstester innan.
Parallell exekvering: Snabbare tester som standard
En fundamental skillnad mellan XCTest och Swift Testing är exekveringsmodellen. XCTest kör alla tester seriellt – en i taget, i ordning. Swift Testing, däremot, utnyttjar Swift structured concurrency för att köra tester parallellt som standard.
Varje @Test-funktion körs i sin egen Task, och varje test i en suite skapar en ny instans av suite-typen. Det innebär att tester som inte delar muterbart tillstånd kan köras helt oberoende av varandra.
@Suite struct OrderProcessingTests {
// Varje test skapar en ny instans av OrderProcessingTests
let processor = OrderProcessor()
@Test func beräknaSubtotal() {
let order = Order(items: [
Item(name: "Bok", price: 199),
Item(name: "Penna", price: 29)
])
#expect(processor.subtotal(for: order) == 228)
}
@Test func tillämpRabatt() {
let order = Order(items: [
Item(name: "Laptop", price: 12999)
])
let discounted = processor.applyDiscount(0.1, to: order)
#expect(discounted.total == 11699.1)
}
// Dessa tester kan köras parallellt utan problem
// eftersom varje test har sin egen processor-instans
}
Serialisering vid behov
Ibland behöver tester köras i sekvens – exempelvis om de delar en databas eller filsystem. Då markerar du helt enkelt en suite som serialiserad:
@Suite(.serialized)
struct DatabaseIntegrationTests {
@Test func skapaTabell() throws {
try Database.shared.createTable("users")
#expect(Database.shared.tableExists("users"))
}
@Test func infogaData() throws {
try Database.shared.insert(
into: "users",
values: ["name": "Erik", "age": "30"]
)
let count = try Database.shared.count(in: "users")
#expect(count == 1)
}
}
Migrering från XCTest: En praktisk guide
Du behöver inte migrera allt på en gång. Faktum är att XCTest och Swift Testing kan samexistera i samma testmål utan problem. Här är den strategi jag rekommenderar.
Steg 1: Skriv nya tester med Swift Testing
Den enklaste startpunkten är att börja skriva alla nya tester med Swift Testing. Konfigurera ditt testmål för att stödja båda ramverken – det fungerar direkt utan extra konfiguration.
Steg 2: Migrera vid kontakt
När du behöver ändra ett befintligt XCTest – för att fixa en bugg eller lägga till logik – ta tillfället i akt och migrera hela testklassen till Swift Testing. Det är ett naturligt tillfälle att modernisera.
Steg 3: Systematisk omskrivning
Här är en snabb referens för hur XCTest-mönster översätts till Swift Testing:
// XCTest → Swift Testing
// ─────────────────────────────────────────────────
// class MyTests: XCTestCase → struct MyTests (eller @Suite)
// func testSomething() → @Test func something()
// override func setUp() → init()
// override func tearDown() → deinit (klass) eller automatisk
// XCTAssertEqual(a, b) → #expect(a == b)
// XCTAssertTrue(x) → #expect(x)
// XCTAssertFalse(x) → #expect(!x)
// XCTAssertNil(x) → #expect(x == nil)
// XCTAssertNotNil(x) → #expect(x != nil)
// XCTAssertThrowsError(expr) → #expect(throws: Error.self) { expr }
// XCTUnwrap(optional) → try #require(optional)
// XCTFail("meddelande") → Issue.record("meddelande")
// XCTestExpectation → confirmation { ... }
// XCTSkip("anledning") → .disabled("anledning") trait
Praktiskt migreringsexempel
Låt oss ta en verklig XCTest-klass och konvertera den:
// FÖRE: XCTest
class ShoppingCartTests: XCTestCase {
var cart: ShoppingCart!
override func setUp() {
super.setUp()
cart = ShoppingCart()
}
override func tearDown() {
cart = nil
super.tearDown()
}
func testAddItem() throws {
let item = try XCTUnwrap(Product(name: "Bok", price: 199))
cart.add(item)
XCTAssertEqual(cart.items.count, 1)
XCTAssertEqual(cart.total, 199)
}
func testRemoveItem() {
let item = Product(name: "Bok", price: 199)!
cart.add(item)
cart.remove(item)
XCTAssertTrue(cart.items.isEmpty)
XCTAssertEqual(cart.total, 0)
}
func testApplyDiscount() {
let item = Product(name: "Laptop", price: 10000)!
cart.add(item)
cart.applyDiscountCode("SOMMAR25")
XCTAssertEqual(cart.total, 7500)
}
}
// EFTER: Swift Testing
@Suite("Kundvagn")
struct ShoppingCartTests {
let cart = ShoppingCart()
@Test("Lägga till produkt")
func addItem() throws {
let item = try #require(Product(name: "Bok", price: 199))
cart.add(item)
#expect(cart.items.count == 1)
#expect(cart.total == 199)
}
@Test("Ta bort produkt")
func removeItem() throws {
let item = try #require(Product(name: "Bok", price: 199))
cart.add(item)
cart.remove(item)
#expect(cart.items.isEmpty)
#expect(cart.total == 0)
}
@Test("Tillämpa rabattkod", arguments: [
("SOMMAR25", 7500.0),
("VINTER10", 9000.0),
("VIP50", 5000.0)
])
func applyDiscount(code: String, expectedTotal: Double) throws {
let item = try #require(Product(name: "Laptop", price: 10000))
cart.add(item)
cart.applyDiscountCode(code)
#expect(cart.total == expectedTotal)
}
}
Notera hur det sista testet i Swift Testing-versionen har parametriserats för att täcka flera rabattkoder med bara en testfunktion. I XCTest hade du behövt skriva separata tester eller en loop. Det är den typen av förenkling som gör att parametriserade tester snabbt blir en favorit.
Vad du INTE kan migrera (ännu)
Det är viktigt att veta att vissa saker fortfarande kräver XCTest:
- UI-tester: Swift Testing stöder inte UI-automation. Alla
XCUIApplication-baserade tester måste förbli i XCTest. - Prestandatester:
XCTMetric-baserade prestandamätningar har inget motsvarande i Swift Testing. - Objective-C-tester: Swift Testing är ett rent Swift-ramverk.
Best practices och rekommenderade mönster
Låt oss avsluta med en samling beprövade mönster och rekommendationer. De här tipsen bygger på erfarenheter från riktiga projekt och hjälper dig att få ut det mesta av Swift Testing.
1. Använd strukturer som standard
Föredra strukturer framför klasser för dina testsuites. Strukturer ger naturlig isolering – varje test skapar en ny instans, precis som XCTestCase men utan arvsoverhead:
// Rekommenderat
@Suite struct PaymentTests {
let service = PaymentService(environment: .test)
@Test func processPayment() async throws {
let result = try await service.process(amount: 100, currency: .sek)
#expect(result.isSuccessful)
}
}
// Undvik om möjligt
@Suite class PaymentTests {
// Klass behövs bara om du har resursintensiv setup
// som du vill dela mellan tester
}
2. Namnge dina tester beskrivande
Utnyttja visningsnamn-strängen i @Test-makrot. Bra testnamn fungerar som dokumentation:
// Bra: Tydlig avsikt
@Test("Användare utan premium kan inte komma åt exklusivt innehåll")
func accessControl() { ... }
// Undvik: Namn som bara upprepar implementationen
@Test("testCheckAccessReturnsFalse")
func accessCheck() { ... }
3. Utnyttja parametrisering strategiskt
Parametriserade tester är fantastiska, men använd dem rätt. De passar bäst för gränsvärdesanalys, validering av olika indata-varianter, och för att testa samma logik med olika konfigurationer:
// Gränsvärdesanalys med parametrisering
@Test("Åldersvalidering", arguments: [
(age: -1, valid: false),
(age: 0, valid: false),
(age: 1, valid: true),
(age: 17, valid: false),
(age: 18, valid: true),
(age: 150, valid: true),
(age: 151, valid: false)
])
func ageValidation(age: Int, valid: Bool) {
#expect(AgeValidator.isValid(age) == valid)
}
4. Strukturera med nästlade suites
Använd nästlade suites för att skapa en tydlig hierarki som speglar din applikations domän:
@Suite("Orderhantering")
struct OrderTests {
@Suite("Skapande")
struct Creation {
@Test func medGiltigaVärden() { ... }
@Test func medOgiltigKvantitet() { ... }
}
@Suite("Betalning")
struct Payment {
@Test func kortbetalning() async throws { ... }
@Test func swishBetalning() async throws { ... }
}
@Suite("Leverans")
struct Shipping {
@Test func inrikesLeverans() { ... }
@Test func utrikesLeverans() { ... }
}
}
5. Kombinera traits för maximal kontroll
Traits kan kombineras fritt för att ge dig exakt det beteende du behöver:
@Test(
"Komplex integrationstest",
.tags(.integration, .slow),
.timeLimit(.minutes(5)),
.bug("https://jira.example.com/PROJ-123"),
.enabled(if: Environment.isCI)
)
func fullIntegrationFlow() async throws {
// Denna test:
// - Har taggarna .integration och .slow
// - Har en tidsgräns på 5 minuter
// - Är kopplad till en bugg-referens
// - Körs bara i CI-miljö
}
Sammanfattning och framåtblick
Swift Testing representerar ett rejält steg framåt för testning i Swift-ekosystemet. Ramverkets deklarativa syntax med @Test och @Suite, det kraftfulla #expect/#require-systemet, och funktioner som parametriserade tester och traits gör det betydligt enklare – och ärligt talat roligare – att skriva bra tester.
De viktigaste fördelarna i sammanfattning:
- Mindre boilerplate: Inget arv, inga namnkonventioner, två makron istället för fyrtio funktioner.
- Parallell körning: Tester körs parallellt som standard, vilket ger snabbare testsviter.
- Parametrisering: Skriv en testfunktion, kör den med tiotals datauppsättningar.
- Naturlig async/await: Asynkrona tester kräver noll extra ceremony.
- Traits och taggar: Flexibel organisering och anpassning av testbeteende.
- Exit tests (Swift 6.2): Testa kod som förväntas avsluta processen.
- Samexistens med XCTest: Migrera i din egen takt.
Med Swift 6.2 och Xcode 26 har ramverket nått en mognadsnivå där det finns väldigt lite anledning att inte börja använda det. Exit tests, förbättrade scoping traits och djupare Xcode-integration gör det tydligt att Apple satsar på Swift Testing som den primära testlösningen framöver.
Min rekommendation? Börja med att skriva alla nya tester i Swift Testing redan idag. Migrera befintliga tester successivt – varje gång du rör en gammal testklass, konvertera den. Inom några månader kommer din testsvit vara modernare, snabbare och framför allt mer underhållbar.
Lycka till med testandet!