Add Swift Testing Attachments For Better Failure Reports

A failing test should leave evidence behind. The assertion tells you what was wrong, but the attachment tells you what the test saw when it failed.

That matters most for tests that compare generated output, decode server payloads, render images, or make a decision from several intermediate values. Without attachments, the next step is usually “re-run it locally and add temporary prints.” That is fine once. It gets old quickly in CI.

Swift Testing in Xcode 26 includes Attachment, a small API for recording extra data from a test. In the local Xcode 26.5 interfaces, the base Testing module exposes strings and byte collections like [UInt8]; the Foundation and platform image overlays add Data, URL-backed file attachments, and images such as UIImage, CGImage, and CIImage. That covers most useful diagnostics: plain-text logs, JSON payloads, generated files, screenshots, and rendered images.

Record text first

Start with the boring attachment. It is the one you will use most.

import Foundation
import Testing
@testable import Recipes

@Test("Search response keeps the highlighted title")
func searchResponseKeepsHighlightedTitle() throws {
    let json = """
    {
      "results": [
        { "id": "ramen", "title": "Miso Ramen", "highlight": "Miso <em>Ramen</em>" }
      ]
    }
    """

    let data = try #require(json.data(using: .utf8))
    let response = try JSONDecoder().decode(SearchResponse.self, from: data)

    Attachment.record(json, named: "search-response.json")

    #expect(response.results.first?.highlight == "Miso <em>Ramen</em>")
}

String conforms to Attachable, so you can pass it directly to Attachment.record(_:named:sourceLocation:). The named: value is optional, but use it. A good attachment name makes a test report much easier to skim.

I like naming text attachments with real extensions:

  • response.json
  • rendered-markdown.txt
  • validation-errors.txt
  • feature-flags.json

The name is not a replacement for an assertion. Keep the #expect focused on the behavior, and use the attachment to give the failure context.

Attach only when it helps

You do not have to record every payload for every passing test. For small unit tests, unconditional text attachments are usually fine while developing and noisy later. For larger payloads, record only when something is suspicious.

@Test("Imported recipe uses the canonical ingredient names")
func importedRecipeUsesCanonicalIngredientNames() throws {
    let importer = RecipeImporter()
    let result = try importer.importRecipe(named: "lentil-soup")

    let unknownIngredients = result.ingredients.filter { !$0.isCanonical }

    if !unknownIngredients.isEmpty {
        let report = unknownIngredients
            .map { "- \($0.rawName) -> \($0.normalizedName)" }
            .joined(separator: "\n")

        Attachment.record(report, named: "unknown-ingredients.txt")
    }

    #expect(unknownIngredients.isEmpty)
}

That pattern keeps passing results tidy, but still gives you the important information on failure. The attachment is recorded before the expectation, so a failure report can include the names that did not normalize correctly.

Record JSON intentionally

For JSON, prefer pretty, stable output over dumping whatever String(data:) gives you. Stable formatting makes diffs and Quick Look previews more useful.

func prettyJSON<DataValue: Encodable>(
    _ value: DataValue,
    encoder: JSONEncoder = JSONEncoder()
) throws -> String {
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]

    let data = try encoder.encode(value)
    return String(decoding: data, as: UTF8.self)
}

@Test("Checkout request includes the selected coupon")
func checkoutRequestIncludesSelectedCoupon() throws {
    let cart = Cart.fixture(coupon: "SPRING20")
    let request = CheckoutRequest(cart: cart)

    let json = try prettyJSON(request)
    Attachment.record(json, named: "checkout-request.json")

    #expect(request.couponCode == "SPRING20")
    #expect(request.lineItems.count == 3)
}

This is especially useful when a test checks one part of a larger request. The expectations stay precise, but the attachment lets you inspect the whole payload when a server contract changes.

Be careful with secrets. Attachments can end up in local result bundles or CI artifacts. Do not record bearer tokens, session cookies, real customer data, or anything your team would be unhappy to download from a build log.

Use Data for generated files

The Xcode 26.5 base Testing.swiftinterface shows Array, ContiguousArray, and ArraySlice conforming to Attachable when their element is UInt8. The Foundation testing overlay also adds Foundation.Data conformance, which is the most natural shape for generated files in app tests.

@Test("Invoice renderer creates a nonempty PDF")
func invoiceRendererCreatesNonemptyPDF() throws {
    let invoice = Invoice.fixture(number: "INV-2048")
    let pdfData = try InvoiceRenderer().renderPDF(for: invoice)

    Attachment.record(pdfData, named: "invoice.pdf")

    #expect(pdfData.count > 10_000)
}

Use [UInt8] when your artifact is already a byte collection or when you are working in a very small target without Foundation. If the artifact already exists on disk, the Foundation overlay also exposes an async Attachment(contentsOf:named:sourceLocation:) initializer, which can be a better fit than loading a large file into memory just to record it.

For a large generated file, use judgment. A tiny fixture PDF or snapshot image is usually fine. A 300 MB export attached to every CI run is a good way to make your future self mutter at a progress bar.

Add screenshots or rendered images

Image attachments use a separate overload:

Attachment.record(
    image,
    named: "empty-state.png",
    as: .png
)

In Xcode 26.5, the Testing framework exposes AttachableAsImage, AttachableImageFormat, and image-specific Attachment.record(_:named:as:sourceLocation:) overloads. The local simulator interfaces also include Testing overlays for CGImage, CIImage, and UIImage.

Here is the shape I would use for a view-rendering helper that returns a UIImage:

#if canImport(UIKit)
import SwiftUI
import Testing
import UIKit

@MainActor
@Test("Empty state has a visible title")
func emptyStateHasVisibleTitle() throws {
    let image = try renderImage(
        EmptyRecipeStateView(title: "No Recipes Yet")
            .frame(width: 390, height: 320)
    )

    Attachment.record(
        image,
        named: "empty-recipe-state.png",
        as: .png
    )

    #expect(image.size.width == 390)
    #expect(image.size.height == 320)
}
#endif

The rendering helper is intentionally omitted because apps tend to have their own approach: ImageRenderer, a hosted UIKit view, a snapshot-testing package, or a custom drawing pipeline. The attachment API does not care how you produced the image. It only needs an image type that conforms to AttachableAsImage.

AttachableImageFormat includes .png, .jpeg, and .jpeg(withEncodingQuality:). Use PNG for UI screenshots and crisp rendered output. Use JPEG when the artifact is photographic or when size matters more than exact pixels. If you omit as:, the API can infer a format from the attachment name’s path extension when it recognizes one.

Wrap common diagnostics

Once a test file has three ad hoc attachment snippets, I usually add tiny helpers. Keep them boring and test-target-only.

import Foundation
import Testing

func recordJSON<T: Encodable>(
    _ value: T,
    named name: String,
    sourceLocation: SourceLocation = #_sourceLocation
) throws {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]

    let data = try encoder.encode(value)
    let json = String(decoding: data, as: UTF8.self)

    Attachment.record(json, named: name, sourceLocation: sourceLocation)
}

func recordTextReport(
    _ lines: [String],
    named name: String,
    sourceLocation: SourceLocation = #_sourceLocation
) {
    Attachment.record(
        lines.joined(separator: "\n"),
        named: name,
        sourceLocation: sourceLocation
    )
}

Passing sourceLocation through is a small nicety. It keeps the attachment associated with the call site in the test instead of making every attachment point at the helper.

Use helpers for formatting, redaction, and naming. Do not hide the fact that the test records an attachment. A future reader should still be able to scan the test and see which artifacts will appear in the report.

Use attachments with expectations

Attachments are supporting evidence. They should not replace checks.

This is weak:

@Test("Exports settings")
func exportsSettings() throws {
    let data = try SettingsExporter().export()

    Attachment.record(data, named: "settings.json")
}

The test records a file, but it does not assert anything. A broken exporter could produce an empty document and the test would still pass.

Prefer this:

@Test("Exports settings")
func exportsSettings() throws {
    let data = try SettingsExporter().export()
    let json = String(decoding: data, as: UTF8.self)

    Attachment.record(json, named: "settings.json")

    #expect(json.contains("\"syncEnabled\""))
    #expect(json.contains("\"theme\""))
}

The expectations define correctness. The attachment makes the failure faster to understand.

A practical checklist

Add attachments when a failure would otherwise send you hunting for local reproduction steps:

  • Record the server payload that produced a decoding failure.
  • Record the generated JSON, Markdown, PDF, or CSV that a test validates.
  • Record a screenshot or rendered image for visual output.
  • Record a short text report for validation errors, diff summaries, or selected feature flags.
  • Redact secrets before recording anything that may leave your machine.
  • Prefer stable names and extensions.
  • Keep assertions specific, and let attachments provide context.

The payoff is small but steady. A failed test with the right attachment feels less like a mystery and more like a bug report from someone who cared enough to include the receipts.

Further reading: