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! } 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 } } 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) } } 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") } } 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"))) } } 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) } 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) } } 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)
Every fatalError(), every precondition(), every edge case that should never happen - they're all testable now.