Swift Testing framework: Komplet guide til @Test, @Suite og migration fra XCTest (2026)
En praktisk guide til Swift Testing i Xcode 26: makroer, suiter, parameterized tests, traits og hvordan du migrerer eksisterende XCTest-suiter uden at brænde dig.
Swift Testing er Apples moderne testframework, der erstatter XCTest med et makro-baseret API (@Test, @Suite, #expect) og indbygget parallelisering. Frameworket er open source, følger med Xcode 26 og Swift 6.2, og lader dig skrive kortere, mere udtryksfulde tests med typede parametre, tags og traits, mens dine eksisterende XCTest-suiter kører uændret side om side. I denne guide dækker jeg hele API'et, viser konkrete kodeeksempler og forklarer, hvordan du migrerer en produktions-testsuite trin for trin uden at bryde CI.
Swift Testing bruger makroerne @Test, @Suite, #expect og #require i stedet for XCTests klassehierarki og XCTAssert-familien.
Tests kører som frie funktioner eller metoder (én instans per test), hvilket eliminerer delt tilstand mellem tests som standard.
Parallelisering er slået til som default; brug .serialized-traitet på en suite for at tvinge sekventiel eksekvering.
Parameterized tests via arguments: giver Cartesian-produkter og bedre fejlrapportering end en for-loop.
Traits som .tags, .disabled, .timeLimit og .bug giver deklarativ testkonfiguration.
Migration er inkrementel: Swift Testing og XCTest kan sameksistere i samme test-target uden konfigurationsændringer.
Hvad er Swift Testing framework?
Swift Testing er et open source testframework udviklet af Apple og Swift-fællesskabet som en moderne afløser for XCTest. Frameworket blev annonceret på WWDC 2024, gik GA sammen med Swift 6 i september 2024 og er fuldt integreret i Xcode 16 og senere, inklusive Xcode 26, som er den aktuelle version i 2026. Kilden ligger på github.com/swiftlang/swift-testing, og API'et er tilgængeligt på alle Apple-platforme samt Linux og Windows.
Den vigtigste forskel til XCTest er faktisk filosofisk: Swift Testing behandler en test som en funktion med metadata, ikke en metode på en klasse. Du deklarerer en test med @Test-makroen, og runtime opretter en frisk instans af den omsluttende type per testfunktion. Det betyder, at delt mutabel tilstand mellem tests forsvinder som default. Det er præcis den klassiske kilde til flaky tests i XCTest, hvor setUp/tearDown-cyklussen ofte lækkede tilstand.
Frameworket er også bygget til Swift Concurrency fra bunden. Tests er markeret async uden ceremoni, throws integrerer direkte med #expect(throws:), og parallel eksekvering er default på tværs af suiter. Hvis du allerede har læst vores guide til Swift 6.2 approachable concurrency, vil isolationsmodellen føles bekendt: en test-funktion arver isolation fra sin suite-type, medmindre du eksplicit markerer den @concurrent.
Kom i gang: din første @Test
Swift Testing er inkluderet i Xcode 26 uden ekstra installation. Opret et nyt test-target via File → New → Target → Unit Testing Bundle, og vælg "Swift Testing" som testing system i wizarden. Hvis du bruger Swift Package Manager, tilføj testTarget som normalt. Swift Testing er en indbygget del af toolchain'et fra Swift 6.0.
Bemærk, hvor lidt boilerplate der er: ingen klasse, ingen XCTestCase-arv, ingen func test-prefix. Testens navn i test-navigatoren er funktionsnavnet, men du kan give den en menneskelig titel via @Test-argumentet:
@Test("Formaterer fulde navn korrekt")
func brugerFormatererFuldeNavn() {
let user = User(firstName: "Ada", lastName: "Lovelace")
#expect(user.fullName == "Ada Lovelace")
}
Titlen vises i Xcodes test-navigator og i test-rapporter, og den behøver ikke være en gyldig Swift-identifier. Brug dansk, mellemrum og tegnsætning frit.
#expect vs. #require: hvornår bruger du hvad?
#expect og #require er de to primære assertions i Swift Testing, og de dækker næsten alle XCTest-varianter. Forskellen er simpel: #expect rapporterer en fejl og fortsætter testen; #require rapporterer en fejl og stopper testen ved at kaste. Brug #require, når resten af testen ikke giver mening uden den, typisk til unwrapping af optionals.
@Test func henterFørsteBruger() throws {
let users = fetchUsers()
// Stop testen hvis listen er tom, ellers crasher vi på users[0]
try #require(!users.isEmpty)
#expect(users[0].id == 1)
#expect(users[0].email.contains("@"))
}
Makroerne er skrevet, så compileren udvider udtrykket til en form, hvor fejlmeddelelsen indeholder både venstre og højre side af sammenligningen. Med XCTest måtte du skrive XCTAssertEqual(a, b, "a: \(a), b: \(b)") for at få læselige fejl; her får du det gratis. Kilden er Apples Testing Expectations-dokumentation, som lister alle understøttede former, inklusive #expect(throws:), #expect(performing:sends:) og confirmation-baseret event-testing.
Test af thrown errors
@Test func decoderKasterPåUgyldigJSON() {
let ugyldigJSON = Data("{ ikke gyldig }".utf8)
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(User.self, from: ugyldigJSON)
}
}
Du kan også matche en specifik error-instans eller et closure, der inspicerer error'en. Det erstatter det klodsede XCTAssertThrowsError-pattern med et do-catch inde i closuren.
@Suite og organisering af tests
En suite grupperer relaterede tests og deler setup-logik. Enhver type (struct, class, actor) markeret med @Suite, eller bare en type der indeholder @Test-funktioner, bliver en suite. Runtime opretter en ny instans per test, så initializeren fungerer som setUp:
Bemærk, at der ikke er nogen tearDown. Brug deinit, hvis du skal frigive ressourcer, eller defer inde i testen. Fordi hver test får sin egen suite-instans, er der ingen risiko for lækket tilstand.
Suiter kan nestes ved at putte en @Suite-type inde i en anden. Det giver en hierarkisk visning i test-navigatoren og bruges typisk til at gruppere edge cases eller platform-specifikke tests. Suiter kan også arve traits: et .tags(.integration) på den ydre suite propagerer til alle indre tests.
Parameterized tests med arguments:
Parameterized tests er en af Swift Testings mest værdifulde features. I XCTest måtte du enten skrive én test per case eller loope med for. Begge dele giver dårlig fejlrapportering. Med arguments: kalder runtime din test én gang per input og rapporterer hver fejl separat:
For to eller flere argumenter kan du bruge zippede sekvenser eller Cartesian-produkter. Cartesian-produktet er default, når du giver to separate arrays:
Brug zip(...), hvis du kun vil have parvise kombinationer. Xcode 26 viser hver iteration som en separat række i test-rapporten, og du kan re-køre en enkelt input direkte fra navigatoren. Ærligt talt sparer det timer, når kun én case fejler i en tabel med 50 rækker.
Traits: tags, disabled, timeLimit og bug
Traits er metadata, du hæfter på tests eller suiter for at styre eksekvering og organisering. De vigtigste built-in traits:
.tags(.integration, .slow): filtrer tests i CI eller i Xcodes test-plan.
.disabled("Fejler på simulator, se FB12345"): spring testen over med en obligatorisk kommentar.
.timeLimit(.minutes(1)): fejl testen, hvis den ikke er færdig inden for grænsen.
.bug("https://linear.app/team/issue/APP-123"): link testen til en bug-tracker.
.serialized: tving suiten til sekventiel eksekvering (default er parallel).
extension Tag {
@Tag static var integration: Self
@Tag static var slow: Self
}
@Suite(.tags(.integration), .serialized)
struct DatabaseIntegrationTests {
@Test(.timeLimit(.minutes(2)))
func migrationKørerTilNyesteVersion() async throws {
// ...
}
@Test(.bug("APP-456", "Skema-migrering fejler på tomme databaser"),
.disabled("Aktiveres når APP-456 er fixet"))
func migrationHåndtererTomDatabase() async throws {
// ...
}
}
Tags skal deklareres som @Tag-egenskaber på Tag-typen, hvilket giver dig type-safety og autocomplete. Du kan derefter filtrere fra kommandolinjen med swift test --filter-tag integration eller ekskludere med --skip-tag slow. Det er perfekt til at holde CI hurtig ved kun at køre integrations-tests i nightly builds.
Async, throwing og concurrency i tests
Async-tests kræver ingen speciel opsætning: markér funktionen async, og du er i gang. Det samme gælder throws. Det gør patterns, der var smertefulde i XCTest (såsom testing af AsyncSequence eller Task-orkestrering), nærmest triviale:
@Test func streamerOpdateringer() async throws {
let sut = LiveScoreStream()
var modtagne: [Score] = []
for try await score in sut.updates.prefix(3) {
modtagne.append(score)
}
#expect(modtagne.count == 3)
try #require(modtagne.first != nil)
}
Til test af events, callbacks eller notifikationer bruger du confirmation-funktionen, som erstatter XCTestExpectation:
@Test func notificerVedBrugerLogin() async {
await confirmation("Login-notifikation blev sendt") { bekræft in
let observer = NotificationCenter.default.addObserver(
forName: .userDidLogin,
object: nil,
queue: nil
) { _ in bekræft() }
await AuthService.shared.login(email: "[email protected]", password: "x")
NotificationCenter.default.removeObserver(observer)
}
}
confirmation tager også et expectedCount:-argument, hvis du forventer flere kald, og fejler testen, hvis antallet ikke matcher. Kombineret med Swift Testings automatiske parallelisering betyder det, at du kan køre 500 uafhængige tests på flere kerner uden at gøre noget, og med garanti mod race conditions, fordi hver test har sin egen instans.
Migration fra XCTest til Swift Testing
Migration er inkrementel: Swift Testing og XCTest kører side om side i samme test-target uden konfigurationsændringer. Apples officielle anbefaling (se Migrating a test from XCTest) er at migrere en fil ad gangen og lade det gamle framework håndtere de tests, der endnu ikke er konverteret.
Da jeg selv konverterede en 400-tests-suite i en klient-app tidligere i år, tog jeg de mest afhængige filer først. Det gjorde det nemt at spotte skjulte fælles-tilstands-antagelser, som ville have været umulige at finde, hvis jeg havde startet med de simple tests. Her er den typiske konvertering:
// FØR: XCTest
import XCTest
@testable import MyApp
final class UserFormatterTests: XCTestCase {
var sut: UserFormatter!
override func setUp() {
super.setUp()
sut = UserFormatter(locale: Locale(identifier: "da_DK"))
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testFullName() {
let user = User(firstName: "Ada", lastName: "Lovelace")
XCTAssertEqual(sut.fullName(for: user), "Ada Lovelace")
}
func testGreeting() throws {
let user = try XCTUnwrap(User(firstName: "Ada", lastName: "Lovelace"))
XCTAssertTrue(sut.greeting(for: user).hasPrefix("Hej"))
}
}
// EFTER: Swift Testing
import Testing
@testable import MyApp
@Suite struct UserFormatterTests {
let sut = UserFormatter(locale: Locale(identifier: "da_DK"))
@Test func fullName() {
let user = User(firstName: "Ada", lastName: "Lovelace")
#expect(sut.fullName(for: user) == "Ada Lovelace")
}
@Test func greeting() throws {
let user = try #require(User(firstName: "Ada", lastName: "Lovelace"))
#expect(sut.greeting(for: user).hasPrefix("Hej"))
}
}
Konverteringstabel for de mest brugte assertions:
XCTest
Swift Testing
Note
XCTAssert(x)
#expect(x)
1:1 erstatning
XCTAssertEqual(a, b)
#expect(a == b)
Bruger nu almindelige operatorer
XCTAssertNil(x)
#expect(x == nil)
Direkte sammenligning
XCTUnwrap(x)
try #require(x)
Skal kastes, ellers stopper testen
XCTAssertThrowsError(try f())
#expect(throws: MyError.self) { try f() }
Type-sikker error matching
XCTestExpectation
confirmation(...)
Async-native API
setUp() / tearDown()
init() / deinit
Frisk instans per test
testInvocations til parameterized
@Test(arguments: ...)
Første-klasses support
CI-integration og code coverage
På kommandolinjen kører du Swift Testing-tests med det velkendte swift test, og alle CI-systemer, der understøtter Swift Package Manager (GitHub Actions, GitLab CI, Xcode Cloud, Bitrise), virker uden ændringer. Xcode 26 skriver stadig JUnit-XML og xcresult-bundles, så testrapportering i din CI-pipeline fortsætter med at fungere.
For at køre kun tagged tests:
# Kør kun integration-tests
swift test --filter-tag integration
# Skip slow tests i PR-builds
swift test --skip-tag slow
# Kør en specifik suite
swift test --filter "UserRepositoryTests"
Code coverage aktiveres som før med --enable-code-coverage på SwiftPM eller via scheme-indstillinger i Xcode. Swift Testing rapporterer dækning på samme måde som XCTest, så eksisterende værktøjer som xcov, Codecov og Sonarqube kræver ingen konfigurationsændringer.
Almindelige faldgruber og fejl
Efter at have migreret flere produktions-suiter er der især fire ting, der bider udviklere: parallelisering af tests der deler ressourcer, glemte throws på testfunktioner, misbrug af #expect hvor #require var korrekt, og forventninger om at init kaldes én gang per suite (den kaldes én gang per test).
1. Delt tilstand crasher parallelle tests
Hvis to tests skriver til den samme fil, database eller singleton, vil parallel eksekvering give race conditions. Løsningen er enten .serialized-traitet på suiten, eller endnu bedre at give hver test sin egen isolerede ressource. SwiftUIs Observation-framework gør det let at injecte fresh state per test via @Observable-typer i initializeren.
2. Glemt throws
try #require(...) og #expect(throws:) kan kaste, så testfunktionen skal markeres throws. Compileren fanger det, men fejlmeddelelsen er ikke altid indlysende for nye brugere.
3. Forkert brug af #expect med optional
// Dårligt: crasher hvis user er nil
@Test func brugerHarEmail() {
let user = fetchUser()
#expect(user!.email.contains("@")) // 💥 force unwrap
}
// Godt: stop testen kontrolleret
@Test func brugerHarEmail() throws {
let user = try #require(fetchUser())
#expect(user.email.contains("@"))
}
4. Init-forventninger
Hvis din init() opretter en dyr ressource (en database-forbindelse, en fil på disk), oprettes den én gang per test. Det er ofte det du vil have (isolation!), men for meget dyre setups bør du lave en shared, immutabel fixture på suite-typen eller bruge SwiftPMs @_fixed_layout-mønstre.
Ofte stillede spørgsmål
Skal jeg skifte fra XCTest til Swift Testing nu?
For nye tests: ja. Swift Testing er GA, mere ergonomisk og bedre integreret med moderne Swift-features. For eksisterende XCTest-suiter er der ingen grund til hastværk. Begge frameworks sameksisterer, og du kan migrere fil for fil, når du alligevel rører koden.
Kan Swift Testing og XCTest bruges i samme test-target?
Ja. Xcode 16+ og SwiftPM 6+ opdager begge frameworks automatisk og kører dem i samme testkørsel. Du kan endda have XCTest- og Swift Testing-tests i samme fil, selvom det bliver rodet i praksis.
Hvordan skriver jeg parameterized tests i Swift Testing?
Brug @Test(arguments: [...]) og accepter argumentet som en parameter til testfunktionen. For flere argumenter kan du enten give separate arrays (Cartesian-produkt) eller zip(a, b) for parvise kombinationer. Hver iteration rapporteres som en separat række i test-navigatoren.
Hvad er forskellen på #expect og #require?
#expect rapporterer en fejl og lader testen fortsætte. #require kaster og stopper testen. Brug #require til preconditions (fx optional-unwrapping), hvor resten af testen ikke giver mening uden.
Understøtter Swift Testing UI-tests?
Nej. Pr. Swift 6.2 er XCUITest og performance-testing (measure { }) stadig kun tilgængelige via XCTest. Behold UI-testene i XCTest, og migrér kun unit- og integrationstests foreløbig.
Hvordan kører jeg kun tests med et bestemt tag?
Fra kommandolinjen: swift test --filter-tag integration. I Xcode kan du oprette en test-plan, der filtrerer på tags. Kombiner med --skip-tag slow for at ekskludere langsomme tests i PR-builds.
Swift 6.2 introducerer Approachable Concurrency med single-threaded by default, @concurrent og nonisolated(nonsending). Lær de nye værktøjer med praktiske kodeeksempler, migrationsstrategi og bedste praksis for 2026.
Komplet guide til Observation-frameworket i SwiftUI. Lær @Observable-makroen, finkornet sporing, @State, @Bindable og @Environment med praktiske kodeeksempler og migreringstips.