Test fatalError and preconditionFailure with Swift Testing Exit Tests

Most Swift tests are about values: call a function, get a value back, compare it with what you expected.

Some code is more dramatic than that. A failed precondition, a deliberate fatalError, or a command-line tool that exits with a nonzero status does not return a value. It ends the process.

Before exit tests, that kind of behavior was awkward to test. If you called the code directly from your test, the whole test process crashed. If you avoided the test entirely, the most dangerous branch in the codebase stayed undocumented.

Swift Testing’s exit tests give us a better option. The testing library runs the exit-test body in a separate child process, waits for that child process to finish, and lets the parent test assert how it exited.

One important caveat before the examples: Apple’s documentation says exit tests are available on macOS, Linux, FreeBSD, OpenBSD, and Windows. In the Xcode 26.5 SDK interfaces I checked, the iOS and iOS Simulator Testing module marks these APIs unavailable. That makes them a great fit for Swift packages, macOS tools, and shared logic tested on macOS, but not something to drop into an iOS-only simulator test target and expect to run there.

A small API with a fatal path

Here is a deliberately tiny example. Imagine an internal parser where an empty template name is a programmer error. You do not want to silently recover from it, because continuing would corrupt a generated file.

enum TemplateName {
    static func normalized(_ rawValue: String) -> String {
        let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)

        guard !trimmed.isEmpty else {
            fatalError("Template names must not be empty.")
        }

        return trimmed.lowercased()
    }
}

The happy path is ordinary Swift Testing:

import Testing

@Test("Normalizes template names")
func normalizesTemplateNames() {
    #expect(TemplateName.normalized("  Receipt ") == "receipt")
}

The empty-string path needs an exit test:

import Testing

@Test("Rejects an empty template name")
func rejectsEmptyTemplateName() async {
    await #expect(processExitsWith: .failure) {
        _ = TemplateName.normalized("   ")
    }
}

That closure is not run by the parent test process. Swift Testing starts another copy of the test executable, treats the closure as the work for that child process, and checks whether the child exits the way you expected.

Use .failure when the exact exit code or signal is not the point. If you are testing a command-line tool that owns its exit codes, you can be more precise:

@Test("Returns the documented usage exit code")
func returnsUsageExitCode() async {
    await #expect(processExitsWith: .exitCode(64)) {
        App.main(["export"])
    }
}

The other built-in conditions are .success and .signal(_:).

Use require when the result matters

#expect(processExitsWith:) returns an optional ExitTest.Result. That is useful when you want to keep checking other things even if the exit expectation fails.

When the rest of the test depends on the child process result, use #require instead:

import Foundation
import Testing

@Test("Prints a helpful configuration error")
func printsConfigurationError() async throws {
    let result = try await #require(
        processExitsWith: .failure,
        observing: [\.standardErrorContent]
    ) {
        FileHandle.standardError.write(Data("Missing API key\n".utf8))
        fatalError("Missing API key")
    }

    let stderr = String(decoding: result.standardErrorContent, as: UTF8.self)
    #expect(stderr.contains("Missing API key"))
}

The observing argument tells Swift Testing which child-process streams you want to capture. In Xcode 26.5, ExitTest.Result includes:

  • exitStatus
  • standardOutputContent
  • standardErrorContent

The testing library always reports the child’s exitStatus. Standard output and standard error are opt-in: observe them when your assertion needs those bytes, and decode them carefully because process output is not guaranteed to be valid UTF-8.

Capture values intentionally

Exit-test bodies run in another process, so captures are not the same as normal closure captures. With Swift 6.3 and newer, the exit-test macros can capture state for you, but every captured value needs to be Sendable and Codable.

This is fine:

@Test(arguments: ["", "   ", "\n"])
func rejectsBlankTemplateNames(_ rawName: String) async {
    await #expect(processExitsWith: .failure) { [rawName] in
        _ = TemplateName.normalized(rawName)
    }
}

If a captured local value is not a function argument or self, Apple’s docs recommend making the type explicit:

@Test("Rejects a generated invalid fixture")
func rejectsGeneratedInvalidFixture() async {
    let fixture = TemplateFixture(name: "", body: "Hello")

    await #expect(processExitsWith: .failure) { [fixture = fixture as TemplateFixture] in
        _ = TemplateCompiler.compile(fixture)
    }
}

That little bit of explicitness makes the test less surprising when a fixture grows from a string into a real value.

Prefer exit tests for invariants, not normal validation

Exit tests are powerful, but they are not a replacement for throwing errors or returning validation results.

Use an exit test when exiting is truly the contract:

  • A precondition protects an impossible state in an internal API.
  • A fatalError marks a programmer error in generated code.
  • A command-line tool exits with documented status codes.
  • A low-level bootstrap path cannot continue safely after a missing dependency.

Do not use an exit test for ordinary user input:

func validateDisplayName(_ name: String) throws -> DisplayName

That kind of API should throw, return nil, or return a domain-specific validation result. It is easier to test, easier to recover from, and kinder to the person using your app.

The line I use is this: if a real user can cause the condition during normal use, do not fatalError. If only a programmer or corrupted invariant can cause it, an exit test may be a good way to make that invariant visible.

A practical checklist

When you add an exit test, check these details:

  • Put it in a target and platform where exit tests are available.
  • Mark the test async.
  • Use .failure for fatalError, failed precondition, and thrown errors from the child body.
  • Use .exitCode(_:) when you own the process exit code.
  • Capture only small Sendable and Codable values.
  • Use observing when you need stdout or stderr from the child process.
  • Do not nest an exit test inside another exit test.
  • Keep ordinary validation as normal errors, not process exits.

That gives you coverage for code paths that used to be treated as untestable. Better still, the test documents the intent: this is not just a crash we forgot about. This is a boundary the program is designed to enforce.

Further reading: