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.