DEV Community

ArshTechPro
ArshTechPro

Posted on

Xcode 26 Exit Tests: Testing Fatal Errors and Crashes Safely

How Exit Tests solve the decade-old problem of testing crashes safely


The Problem Every iOS Developer Faces

Picture this: You write defensive code with fatalError() and precondition() to catch bugs early. But how do you test code that's designed to crash your app?

extension Customer {
    func eat(_ food: consuming some Food) {
        precondition(food.isDelicious, "Tasty food only!")
        precondition(food.isNutritious, "Healthy food only!")
        // ... rest of implementation
    }
}

// How do you test this?
func testUndeliciousFood() {
    var food = SomeFood()
    food.isDelicious = false
    Customer.current.eat(food) // This crashes everything!
}
Enter fullscreen mode Exit fullscreen mode

Before Xcode 26: You couldn't test this without crashing your entire test suite. Most developers just... didn't test these scenarios.

Enter Exit Tests: The Game Changer

Swift Testing in Xcode 26 introduces Exit Tests - they run your crash-prone code in isolated child processes. When your code crashes, it only kills that isolated process, not your test suite.

The Magic Syntax

Use the #expect(processExitsWith:) or #require(processExitsWith:) macros:

import Testing

@Test 
func `Customer won't eat food unless it's delicious`() async {
    await #expect(processExitsWith: .failure) {
        var food = SomeFood()
        food.isDelicious = false
        Customer.current.eat(food) // Crashes safely in child process
    }
}
Enter fullscreen mode Exit fullscreen mode

The #expect(processExitsWith: .failure) tells Swift Testing: "This code should crash - run it safely in a child process."

Real-World Example

class PaymentValidator {
    static func validateAmount(_ amount: Decimal) {
        precondition(amount > 0, "Payment amount must be positive")
        precondition(amount <= 10000, "Payment exceeds maximum limit")

        if amount < 0.01 {
            fatalError("Invalid payment amount: \(amount)")
        }
    }
}

// Test all the crash scenarios
@Test 
func testNegativePaymentAmount() async {
    await #expect(processExitsWith: .failure) {
        PaymentValidator.validateAmount(-100)
    }
}

@Test
func testExcessivePaymentAmount() async {
    await #expect(processExitsWith: .failure) {
        PaymentValidator.validateAmount(50000)
    }
}

@Test
func testTinyPaymentAmount() async {
    await #expect(processExitsWith: .failure) {
        PaymentValidator.validateAmount(0.001)
    }
}
Enter fullscreen mode Exit fullscreen mode

Different Types of Exit Conditions

You can be specific about how you expect the process to exit:

@Test
func testSpecificExitCode() async {
    await #expect(processExitsWith: .exitCode(1)) {
        exit(1) // Expects specific exit code
    }
}

@Test
func testSuccessfulExit() async {
    await #expect(processExitsWith: .success) {
        // Code that should exit cleanly
        exit(0)
    }
}

@Test
func testSignalTermination() async {
    await #expect(processExitsWith: .signal(SIGABRT)) {
        fatalError("Will raise SIGABRT")
    }
}

@Test
func testAnyFailure() async {
    await #expect(processExitsWith: .failure) {
        // Any abnormal exit is acceptable
        precondition(false, "This will fail")
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Capturing Process Output

Exit Tests can capture stdout and stderr from the crashed child process:

extension Customer {
    func eat(_ food: consuming some Food) {
        print("Let's see if I want to eat \(food)...")
        precondition(food.isDelicious, "Tasty food only!")
        precondition(food.isNutritious, "Healthy food only!")
    }
}

@Test 
func `Customer won't eat food unless it's delicious`() async {
    let result = await #expect(
        processExitsWith: .failure,
        observing: [\.standardOutputContent]
    ) {
        var food = SomeFood()
        food.isDelicious = false
        Customer.current.eat(food)
    }

    if let result {
        #expect(result.standardOutputContent.contains(UInt8(ascii: "L")))
    }
}
Enter fullscreen mode Exit fullscreen mode

Using #require for Stricter Testing

Use #require instead of #expect when you need the exit test result and want the test to stop if the exit condition isn't met:

@Test
func testCrashWithRequiredOutput() async throws {
    let result = try await #require(
        processExitsWith: .failure,
        observing: [\.standardErrorContent, \.standardOutputContent]
    ) {
        print("Debug information")
        fatalError("Something went wrong")
    }

    #expect(result.standardOutputContent.contains("Debug".utf8))
    #expect(!result.standardErrorContent.isEmpty)
}
Enter fullscreen mode Exit fullscreen mode

Important Limitations

State Capture Restriction: The exit test body cannot capture any state from the parent process:

@Test 
func testWithCaptureError() async {
    let isDelicious = false

    await #expect(processExitsWith: .failure) {
        var food = SomeFood()
        food.isDelicious = isDelicious // ❌ ERROR: Cannot capture parent state
        Customer.current.eat(food)
    }
}
Enter fullscreen mode Exit fullscreen mode

Nested Exit Tests: You cannot run an exit test within another exit test.

Best Practices

Do:

  • Keep exit tests simple and focused on the crash scenario
  • Use #expect(processExitsWith: .failure) for most precondition/fatalError tests
  • Test actual production crash scenarios
  • Group related crash tests together
  • Use descriptive test names that explain the crash condition

Don't:

  • Try to capture variables from the parent process in exit test bodies
  • Put complex setup logic inside exit test closures
  • Nest exit tests within other exit tests
  • Forget that exit tests are async functions

Platform Support

Exit Tests are available on:

  • macOS
  • Linux
  • FreeBSD
  • OpenBSD
  • Windows

Requirement

Requirements:

  • Swift 6.2+
  • Xcode 26.0+
  • Swift Testing framework (not XCTest)

The Bottom Line

Before Exit Tests:

  • Skip testing crash scenarios entirely
  • Use complex workarounds that don't test real behavior
  • Discover crash bugs in production
  • Accept incomplete test coverage

With Exit Tests:

  • Test every crash scenario confidently
  • Write simple tests that match your production code
  • Catch failure modes during development

Top comments (1)

Collapse
 
arshtechpro profile image
ArshTechPro

Every fatalError(), every precondition(), every edge case that should never happen - they're all testable now.