Swift Testing Framework Interview 2026: Makros #expect und #require vs XCTest

Beherrsche das neue Swift Testing Framework für iOS-Interviews: Makros #expect und #require, XCTest-Migration, fortgeschrittene Muster und häufige Fallstricke.

Swift Testing Framework mit Makros #expect und #require für iOS-Interviews

Auf der WWDC 2024 vorgestellt und mit Swift 6 sowie Xcode 16 ausgeliefert, stellt Swift Testing eine vollständige Neuauffassung des Testens in Swift dar. Dieses Framework ersetzt die mehr als 40 Assertions von XCTest durch nur zwei Makros: #expect und #require. Interviewer prüfen dieses Wissen mittlerweile regelmäßig in technischen iOS-Interviews.

Aufbau des Leitfadens

Jeder Abschnitt spiegelt eine technische Interviewfrage mit detaillierten Antworten und lauffähigem Code wider. Die Progression führt von grundlegenden Konzepten zu fortgeschrittenen Mustern.

Grundlagen von Swift Testing

Frage 1: Was sind die wichtigsten Unterschiede zwischen Swift Testing und XCTest?

Swift Testing bringt fünf grundlegende Änderungen gegenüber XCTest mit:

  1. Deklarative Syntax: Attribut @Test statt test-Präfixen
  2. Zwei universelle Makros: #expect und #require ersetzen über 40 Assertions
  3. Standardmäßig parallel: Alle Tests laufen gleichzeitig
  4. Native async-Unterstützung: vollständige Integration mit Swift Concurrency
  5. Plattformübergreifend: läuft auf Apple-Plattformen, Linux und Windows
TestComparison.swiftswift
import XCTest
import Testing

// ❌ Legacy XCTest pattern
class UserServiceXCTests: XCTestCase {
    // Must start with "test"
    func testUserCreation() {
        let user = User(name: "Alice", age: 25)

        // Multiple verbose assertions
        XCTAssertNotNil(user)
        XCTAssertEqual(user.name, "Alice")
        XCTAssertGreaterThan(user.age, 18)
        XCTAssertTrue(user.isValid)
    }
}

// ✅ Modern Swift Testing pattern
@Test("User creation with valid data")
func userCreation() {
    let user = User(name: "Alice", age: 25)

    // Single macro for all verifications
    #expect(user.name == "Alice")
    #expect(user.age > 18)
    #expect(user.isValid)
}

Der entscheidende Unterschied liegt in der Ausdrucksstärke: Swift Testing nutzt Standard-Swift-Ausdrücke statt spezialisierter Assertions, was Tests lesbarer und Fehlermeldungen aussagekräftiger macht.

Frage 2: Wie funktioniert das Makro #expect?

Das Makro #expect validiert, dass ein boolescher Ausdruck wahr ist. Es erfasst automatisch die ausgewerteten Werte, um detaillierte Fehlermeldungen zu liefern. Im Gegensatz zu XCTAssert nutzt es native Swift-Syntax.

ExpectMacroBasics.swiftswift
import Testing

@Test func basicExpectations() {
    let numbers = [1, 2, 3, 4, 5]
    let user = User(name: "Bob", email: "bob@example.com")

    // Simple comparisons - standard Swift expression
    #expect(numbers.count == 5)
    #expect(user.name == "Bob")

    // Comparisons with operators
    #expect(numbers.first! < numbers.last!)
    #expect(user.email.contains("@"))

    // Nil checking
    #expect(numbers.first != nil)

    // Collection verification
    #expect(numbers.contains(3))
    #expect(!numbers.isEmpty)
}

@Test func expectWithCustomMessage() {
    let balance = 150.0
    let withdrawAmount = 200.0

    // Custom message to clarify intent
    #expect(
        balance >= withdrawAmount,
        "Insufficient funds: balance \(balance) < withdrawal \(withdrawAmount)"
    )
}

Wenn ein #expect fehlschlägt, läuft der Test weiter. Diese Eigenschaft erlaubt das Sammeln mehrerer Fehler in einem einzigen Lauf und erleichtert die Diagnose.

Frage 3: Was unterscheidet #expect und #require?

Der fundamentale Unterschied betrifft das Verhalten nach einem Fehler:

  • #expect: erfasst den Fehler und läuft weiter
  • #require: erfasst den Fehler und stoppt den Test sofort

#require muss immer mit try verwendet werden, weil es einen Fehler werfen kann.

ExpectVsRequire.swiftswift
import Testing

struct ApiResponse {
    let data: Data?
    let items: [Item]?
}

@Test func demonstrateExpectContinues() {
    let values = [1, 2, 3]

    // First #expect fails but test continues
    #expect(values.count == 10)  // ❌ Failure recorded

    // These verifications still execute
    #expect(values.first == 1)   // ✅ Success
    #expect(values.last == 3)    // ✅ Success

    // Result: 1 failure, 2 successes in the same test
}

@Test func demonstrateRequireStops() throws {
    let response = ApiResponse(data: nil, items: nil)

    // #require stops immediately if condition fails
    let data = try #require(response.data)  // ❌ Failure and STOP

    // This code NEVER executes if data is nil
    let json = try JSONDecoder().decode(User.self, from: data)
    #expect(json.name == "Alice")
}

In der Praxis ersetzt #require perfekt XCTUnwrap für das sichere Auspacken von Optionals.

Goldene Regel

Verwende #require, wenn die nachfolgenden Schritte vom Ergebnis abhängen (Auspacken, Vorbedingungen). Verwende #expect für unabhängige Prüfungen, die fehlschlagen dürfen, ohne den Rest des Tests zu blockieren.

Fortgeschrittene Muster mit #require

Frage 4: Wie verwendet man #require zum Auspacken von Optionals?

#require glänzt beim Auspacken von Optionals. Es liefert den nicht-optionalen Wert zurück, falls vorhanden, oder lässt den Test sofort fehlschlagen, wenn er nil ist.

RequireUnwrapping.swiftswift
import Testing

struct UserProfile {
    let id: Int
    let name: String
    let settings: Settings?
}

struct Settings {
    let theme: String
    let notifications: Bool
}

@Test func unwrapOptionalChain() throws {
    let profile = UserProfile(
        id: 1,
        name: "Alice",
        settings: Settings(theme: "dark", notifications: true)
    )

    // Safe unwrap - stops if nil
    let settings = try #require(profile.settings)

    // Now settings is no longer optional
    #expect(settings.theme == "dark")
    #expect(settings.notifications == true)
}

@Test func unwrapArrayElement() throws {
    let users = ["Alice", "Bob", "Charlie"]

    // Unwrap first element
    let firstUser = try #require(users.first)
    #expect(firstUser == "Alice")

    // Unwrap with safe index
    let secondUser = try #require(users[safe: 1])
    #expect(secondUser == "Bob")
}

@Test func unwrapDictionaryValue() throws {
    let config: [String: Any] = [
        "apiUrl": "https://api.example.com",
        "timeout": 30,
        "retryCount": 3
    ]

    // Unwrap and cast in a single operation
    let apiUrl = try #require(config["apiUrl"] as? String)
    let timeout = try #require(config["timeout"] as? Int)

    #expect(apiUrl.contains("https"))
    #expect(timeout > 0)
}

Dieser Ansatz beseitigt guard let-Pyramiden und macht Testcode linear und lesbar.

Frage 5: Wie testet man, dass eine Funktion einen Fehler wirft?

Swift Testing bietet #expect(throws:), um zu prüfen, ob eine Funktion einen bestimmten Fehler wirft.

ErrorTesting.swiftswift
import Testing

enum ValidationError: Error, Equatable {
    case emptyName
    case invalidEmail
    case underAge(minimum: Int)
}

struct Validator {
    static func validateUser(name: String, email: String, age: Int) throws {
        guard !name.isEmpty else {
            throw ValidationError.emptyName
        }
        guard email.contains("@") else {
            throw ValidationError.invalidEmail
        }
        guard age >= 18 else {
            throw ValidationError.underAge(minimum: 18)
        }
    }
}

@Test func testThrowsSpecificError() {
    // Verify a specific error is thrown
    #expect(throws: ValidationError.emptyName) {
        try Validator.validateUser(name: "", email: "test@mail.com", age: 25)
    }

    #expect(throws: ValidationError.invalidEmail) {
        try Validator.validateUser(name: "Alice", email: "invalid", age: 25)
    }
}

@Test func testThrowsErrorType() {
    // Verify error type without specific value
    #expect(throws: ValidationError.self) {
        try Validator.validateUser(name: "Bob", email: "bob@mail.com", age: 15)
    }
}

@Test func testThrowsWithInspection() throws {
    // Capture error for detailed inspection
    let error = try #require(
        throws: ValidationError.self
    ) {
        try Validator.validateUser(name: "Charlie", email: "charlie@mail.com", age: 16)
    }

    // Verify error details
    if case .underAge(let minimum) = error {
        #expect(minimum == 18)
    }
}

Diese Syntax ersetzt XCTAssertThrowsError durch eine klarere und typsichere API.

Bereit für deine iOS-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Testorganisation mit @Test und @Suite

Frage 6: Wie organisiert man Tests mit @Suite?

@Suite gruppiert verwandte Tests logisch. Anders als XCTestCase ist keine Klassenvererbung nötig.

TestSuiteOrganization.swiftswift
import Testing

// Suite for authentication tests
@Suite("Authentication Tests")
struct AuthenticationTests {

    // Shared property for all tests in the suite
    let authService = AuthService()

    @Test("Login with valid credentials succeeds")
    func loginWithValidCredentials() async throws {
        let result = try await authService.login(
            email: "user@example.com",
            password: "validPass123"
        )

        #expect(result.isSuccess)
        let token = try #require(result.token)
        #expect(!token.isEmpty)
    }

    @Test("Login with invalid password fails")
    func loginWithInvalidPassword() async {
        let result = await authService.login(
            email: "user@example.com",
            password: "wrong"
        )

        #expect(!result.isSuccess)
        #expect(result.error == .invalidCredentials)
    }
}

// Nested suites for hierarchical organization
@Suite("User Management")
struct UserManagementTests {

    @Suite("Creation")
    struct CreationTests {
        @Test func createUserWithValidData() {
            // Creation test
        }

        @Test func createUserWithDuplicateEmail() {
            // Duplicate error test
        }
    }

    @Suite("Deletion")
    struct DeletionTests {
        @Test func deleteExistingUser() {
            // Deletion test
        }

        @Test func deleteNonExistentUser() {
            // Error test
        }
    }
}

Suites ermöglichen es, Test-Teilmengen auszuführen und Reports übersichtlich zu strukturieren.

Frage 7: Wie konfiguriert man Tests mit Traits?

Traits modifizieren das Testverhalten: Ausführungsbedingungen, Tags, Timeouts usw.

TestTraits.swiftswift
import Testing

@Suite("API Integration Tests")
struct APITests {

    // Temporarily disabled test
    @Test(.disabled("Backend under maintenance"))
    func fetchUserProfile() async {
        // Does not execute
    }

    // Conditional test based on platform
    @Test
    @available(iOS 17, *)
    func useNewAPIFeature() {
        // Executes only on iOS 17+
    }

    // Test with tags for filtering
    @Test(.tags(.critical, .network))
    func criticalNetworkOperation() async throws {
        // Tagged test for filtering
    }

    // Test with custom timeout
    @Test(.timeLimit(.minutes(2)))
    func longRunningOperation() async {
        // Must complete within 2 minutes
    }

    // Trait combination
    @Test(
        "Complex data sync",
        .tags(.slow),
        .timeLimit(.minutes(5)),
        .bug("JIRA-1234", "Flaky on CI")
    )
    func complexDataSync() async throws {
        // Test documented with known bug
    }
}

// Custom tag definitions
extension Tag {
    @Tag static var critical: Self
    @Tag static var network: Self
    @Tag static var slow: Self
}

Traits machen Tests selbstdokumentierend und erlauben selektive Ausführung über die Kommandozeile.

Parametrisierte Tests

Frage 8: Wie erstellt man parametrisierte Tests?

Swift Testing erlaubt es, denselben Test mit verschiedenen Eingaben über Parameter auszuführen.

ParameterizedTests.swiftswift
import Testing

struct EmailValidator {
    static func isValid(_ email: String) -> Bool {
        let pattern = #"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"#
        return email.range(of: pattern, options: .regularExpression) != nil
    }
}

// Parameterized test with a collection
@Test(arguments: [
    "user@example.com",
    "test.name@domain.org",
    "contact@company.co.uk"
])
func validEmailsAreAccepted(email: String) {
    #expect(EmailValidator.isValid(email))
}

@Test(arguments: [
    "invalid",
    "@nodomain.com",
    "no@tld",
    "spaces in@email.com"
])
func invalidEmailsAreRejected(email: String) {
    #expect(!EmailValidator.isValid(email))
}

// Test with tuples for input/output cases
@Test(arguments: [
    ("hello", "HELLO"),
    ("World", "WORLD"),
    ("Swift", "SWIFT")
])
func uppercaseConversion(input: String, expected: String) {
    #expect(input.uppercased() == expected)
}

// Test with Cartesian product of two collections
@Test(arguments: [1, 2, 3], ["a", "b"])
func combinationTest(number: Int, letter: String) {
    // Runs for (1,"a"), (1,"b"), (2,"a"), (2,"b"), (3,"a"), (3,"b")
    let combined = "\(number)\(letter)"
    #expect(combined.count == 2)
}

Jede Parameterkombination erzeugt einen unabhängigen Test, was die Identifikation fehlschlagender Fälle erleichtert.

Frage 9: Wie testet man asynchronen Code?

Swift Testing integriert async/await nativ und vereinfacht asynchrone Tests drastisch.

AsyncTesting.swiftswift
import Testing

actor DataStore {
    private var items: [String] = []

    func add(_ item: String) {
        items.append(item)
    }

    func getAll() -> [String] {
        items
    }

    func clear() {
        items.removeAll()
    }
}

@Suite("Async Operations")
struct AsyncTests {

    @Test func basicAsyncOperation() async {
        // No need for expectation or wait
        let result = await fetchData()
        #expect(!result.isEmpty)
    }

    @Test func asyncWithTimeout() async throws {
        // Use Task.sleep to simulate delay
        try await Task.sleep(for: .milliseconds(100))

        let data = await loadConfiguration()
        let config = try #require(data)
        #expect(config.isValid)
    }

    @Test func testActorIsolation() async {
        let store = DataStore()

        // Operations on actor sequentially
        await store.add("Item 1")
        await store.add("Item 2")

        let items = await store.getAll()
        #expect(items.count == 2)
        #expect(items.contains("Item 1"))
    }

    @Test func testConcurrentOperations() async {
        let store = DataStore()

        // Concurrent execution with TaskGroup
        await withTaskGroup(of: Void.self) { group in
            for i in 1...10 {
                group.addTask {
                    await store.add("Item \(i)")
                }
            }
        }

        let items = await store.getAll()
        #expect(items.count == 10)
    }
}

// Async helpers for tests
func fetchData() async -> [String] {
    try? await Task.sleep(for: .milliseconds(50))
    return ["data1", "data2"]
}

func loadConfiguration() async -> Configuration? {
    Configuration(isValid: true)
}

struct Configuration {
    let isValid: Bool
}
Standardmäßige Parallelität

Swift Testing führt Tests standardmäßig parallel aus. Für Tests, die geteilten Zustand modifizieren, sollte .serialized an der Suite verwendet oder der Zustand mit Actors isoliert werden.

Migration von XCTest zu Swift Testing

Frage 10: Wie migriert man schrittweise von XCTest?

Beide Frameworks koexistieren im selben Projekt. Eine schrittweise Migration wird empfohlen.

MigrationStrategy.swiftswift
import XCTest
import Testing

// ⚠️ CRITICAL RULE: Never mix frameworks in the same test

// ❌ INCORRECT - Mixing forbidden
class BadMixedTest: XCTestCase {
    func testMixed() {
        #expect(true)  // Does not work in XCTestCase
    }
}

// ✅ CORRECT - Pure XCTest for existing tests
class LegacyUserTests: XCTestCase {
    func testUserCreation() {
        let user = User(name: "Test")
        XCTAssertNotNil(user)
        XCTAssertEqual(user.name, "Test")
    }
}

// ✅ CORRECT - Swift Testing for new tests
@Suite("User Tests - Modern")
struct ModernUserTests {
    @Test func userCreation() {
        let user = User(name: "Test")
        #expect(user.name == "Test")
    }
}

// Migration strategy by file
// 1. Identify tests to migrate (start with simplest)
// 2. Create new file with @Suite
// 3. Rewrite tests one by one
// 4. Delete old XCTest file once validated

| XCTest | Swift Testing | |--------|---------------| | XCTAssertTrue(x) | #expect(x) | | XCTAssertFalse(x) | #expect(!x) | | XCTAssertEqual(a, b) | #expect(a == b) | | XCTAssertNil(x) | #expect(x == nil) | | XCTAssertNotNil(x) | #expect(x != nil) | | XCTUnwrap(x) | try #require(x) | | XCTAssertThrowsError | #expect(throws:) |

Frage 11: Welche XCTest-Funktionen fehlen noch in Swift Testing?

Swift Testing (Swift 6) deckt noch nicht alle Anwendungsfälle von XCTest ab.

MissingFeatures.swiftswift
// ❌ NOT SUPPORTED: Performance tests
// Stick with XCTest for measuring performance
class PerformanceTests: XCTestCase {
    func testPerformance() {
        measure {
            // Code to measure
            _ = (0..<1000).map { $0 * 2 }
        }
    }
}

// ❌ NOT SUPPORTED: UI tests (XCUITest)
// Continue using XCUITest for interface tests
class UITests: XCTestCase {
    func testLoginFlow() {
        let app = XCUIApplication()
        app.launch()
        // UI tests...
    }
}

// ✅ SUPPORTED: Async integration tests
@Test func integrationTest() async throws {
    let api = APIClient()
    let response = try await api.fetchUsers()
    #expect(!response.isEmpty)
}

// ✅ SUPPORTED: Mocking with protocols
@Test func mockingWithProtocols() async {
    let mockService = MockUserService()
    let viewModel = UserViewModel(service: mockService)

    await viewModel.loadUser(id: 1)

    #expect(viewModel.user?.name == "Mock User")
}

Für Projekte mit UI- oder Performance-Tests sollte XCTest in diesen spezifischen Fällen weiter verwendet werden.

Bereit für deine iOS-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Kniffelige Interviewfragen

Frage 12: Warum braucht #require ein try, #expect aber nicht?

#require kann einen Fehler werfen, wenn die Bedingung fehlschlägt, weil es den Test unterbrechen muss. #expect erfasst den Fehler nur und läuft weiter, wirft also nichts.

RequireTryExplanation.swiftswift
import Testing

@Test func explainTryRequirement() throws {
    let optionalValue: String? = nil

    // #expect returns Void - no error thrown
    // Test continues even if it fails
    #expect(optionalValue != nil)  // No try

    // #require can throw ExpectationFailedError
    // Test stops if it fails
    // Must be marked with try
    let value = try #require(optionalValue)  // Requires try

    // If we reach here, optionalValue was not nil
    #expect(!value.isEmpty)
}

// The internal signature resembles:
// func #expect(_ condition: Bool) -> Void
// func #require<T>(_ value: T?) throws -> T

Diese architektonische Unterscheidung erlaubt dem Compiler, korrekte Fehlerbehandlung zu garantieren.

Frage 13: Wie geht Swift Testing mit Parallelität um?

Standardmäßig laufen alle Tests parallel, was Suites beschleunigt, aber saubere Zustandsisolation erfordert.

ParallelismHandling.swiftswift
import Testing

// ❌ PROBLEM: Mutable shared state
var sharedCounter = 0  // Dangerous in parallel!

@Suite("Problematic Parallel Tests")
struct ProblematicTests {
    @Test func incrementCounter1() {
        sharedCounter += 1  // Race condition!
    }

    @Test func incrementCounter2() {
        sharedCounter += 1  // Race condition!
    }
}

// ✅ SOLUTION 1: Force sequential execution
@Suite("Sequential Tests", .serialized)
struct SequentialTests {
    static var counter = 0

    @Test func first() {
        Self.counter += 1
        #expect(Self.counter == 1)
    }

    @Test func second() {
        Self.counter += 1
        #expect(Self.counter == 2)
    }
}

// ✅ SOLUTION 2: Isolate state per test
@Suite("Isolated Tests")
struct IsolatedTests {
    @Test func independentTest1() {
        var localCounter = 0
        localCounter += 1
        #expect(localCounter == 1)
    }

    @Test func independentTest2() {
        var localCounter = 0
        localCounter += 1
        #expect(localCounter == 1)
    }
}

// ✅ SOLUTION 3: Use actor for shared state
actor TestState {
    var value = 0

    func increment() -> Int {
        value += 1
        return value
    }
}

@Suite("Actor-based Tests")
struct ActorTests {
    let state = TestState()

    @Test func safeIncrement() async {
        let result = await state.increment()
        #expect(result > 0)
    }
}

Der Trait .serialized garantiert die sequenzielle Ausführung einer ganzen Suite.

Frage 14: Wie verwendet man confirmations für Callbacks?

Für APIs mit Callbacks (nicht-async) bietet Swift Testing confirmation.

ConfirmationPattern.swiftswift
import Testing

// Legacy service with callback
class LegacyService {
    func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
            completion(.success("Data loaded"))
        }
    }
}

@Test func testCallbackWithConfirmation() async {
    let service = LegacyService()

    // confirmation waits for it to be called
    await confirmation("Data callback received") { confirm in
        service.fetchData { result in
            if case .success(let data) = result {
                #expect(data == "Data loaded")
                confirm()  // Signals callback was executed
            }
        }
    }
}

// For callbacks called multiple times
@Test func testMultipleCallbacks() async {
    let publisher = EventPublisher()

    // expectedCount specifies expected call count
    await confirmation("Events received", expectedCount: 3) { confirm in
        publisher.onEvent = { event in
            #expect(!event.isEmpty)
            confirm()  // Called 3 times
        }

        publisher.emit("Event 1")
        publisher.emit("Event 2")
        publisher.emit("Event 3")
    }
}

class EventPublisher {
    var onEvent: ((String) -> Void)?

    func emit(_ event: String) {
        DispatchQueue.global().async {
            self.onEvent?(event)
        }
    }
}

confirmation ersetzt XCTestExpectation und wait(for:timeout:) auf elegante Weise.

Fazit

Swift Testing repräsentiert die Zukunft des Testens auf Apple-Plattformen. Die zwei Makros #expect und #require vereinfachen das Schreiben von Tests drastisch und verbessern gleichzeitig die Qualität der Fehlermeldungen.

Wichtige Punkte für Interviews:

  • #expect läuft nach Fehler weiter, #require stoppt sofort
  • #require benötigt try, weil es einen Fehler werfen kann
  • ✅ Swift Testing läuft standardmäßig parallel
  • ✅ Beide Frameworks koexistieren, dürfen aber nicht im selben Test gemischt werden
  • ✅ XCTest bleibt für UI- und Performance-Tests notwendig
  • ✅ Traits erlauben feingranulare Konfiguration des Testverhaltens
  • ✅ Parametrisierte Tests vermeiden Codeduplizierung

Die Migration zu Swift Testing kann schrittweise erfolgen, Datei für Datei, beginnend mit den einfachsten Tests.

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

#swift
#ios
#testing
#xctest
#interview

Teilen

Verwandte Artikel