Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

XCTest Failure Screenshots as CI Evidence

Recommendation. For this observer — UI tests driving xcodebuild, GitHub Actions self-hosted runner, no GPU or Apple-silicon dependency — start on Figment's $29 virtual Intel macOS plan (2 vCPU, 8 GiB RAM, 80 GiB disk). It runs a supported Intel Xcode (Xcode 26 or earlier per Apple's porting guidance), xcodebuild, XCTest and the self-hosted runner directly in its desktop. Move to a physical M-series Mac from $95/month (exact chip, RAM and disk confirmed in the quote) when you need arm64 execution, more memory, or Xcode 27. The failure hook itself is pure XCTest — see XCTestObservation.

The XCTestObservation Hook for Failure Events

XCTestObservation is the XCTest protocol for cross-bundle lifecycle events; you do not have to subclass each test case. Modern XCTest routes failures through testCase(_:didRecord:) with an XCTIssue, and that is the preferred signature on current Xcode. The older testCase(_:didFailWithDescription:inFile:atLine:) method is still listed on the Apple protocol page and remains convenient for a screenshot hook. Confirm the signature you adopt against your installed Xcode by option-clicking the symbol — the protocol methods for the version you ship with are the source of truth.

Both methods fire synchronously on the test thread, so a screenshot captured inside either reflects the screen state at the moment of failure. The XCTestCase parameter exposes add(_:) so the attachment lands on the failing test with no extra plumbing.

The Observer Class

Add one Swift file to your UI test target. No changes to any existing test file are required. XCUIScreen.main.screenshot() is a UI-testing API, so this belongs in a UI test bundle; for unit-test bundles without a running host app, capture a view-based image instead.

// FailureScreenshotObserver.swift
import XCTest

final class FailureScreenshotObserver: NSObject, XCTestObservation {

    override init() {
        super.init()
        XCTestObservationCenter.shared.addTestObserver(self)
    }

    func testCase(_ testCase: XCTestCase, didRecord issue: XCTIssue) {
        let screenshot = XCUIScreen.main.screenshot()

        let timestamp = ISO8601DateFormatter().string(from: Date())
            .replacingOccurrences(of: ":", with: "-")
        let testName = testCase.name
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: " ", with: "_")

        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.name = "\(timestamp)-\(testName)-failure.png"
        attachment.lifetime = .keepAlways
        testCase.add(attachment)
    }
}

XCTAttachment(screenshot:) wraps the XCUIScreenshot directly. Setting lifetime = .keepAlways keeps the attachment in the result bundle even when a later retry passes, which matters when you are chasing a flake that only reproduces once per hour.

Registering the Observer Globally via NSPrincipalClass

Wiring the observer in a setUp() works but has to be repeated or inherited. The cleaner path is NSPrincipalClass: XCTest instantiates that class once when the bundle loads, so the observer registers before any test case runs.

  1. Open the UI Test target → Info tab (or edit the target's Info.plist).
  2. Add the key NSPrincipalClass with value $(PRODUCT_MODULE_NAME).FailureScreenshotObserver.

$(PRODUCT_MODULE_NAME) resolves at build time. When XCTest loads the bundle it calls init() on that class, which registers the observer with the shared XCTestObservationCenter.

If you prefer not to touch Info.plist, register once from a base class:

class BaseUITestCase: XCTestCase {
    private static let observer: FailureScreenshotObserver = {
        FailureScreenshotObserver()
    }()

    override class func setUp() {
        super.setUp()
        _ = observer   // force the lazy init exactly once
    }
}

Running Tests and Locating the .xcresult Bundle

Pass an explicit -resultBundlePath so the artifact lands where your CI expects it. Use your real scheme name — do not copy a placeholder into the pipeline:

xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme "$UI_TEST_SCHEME" \
  -destination 'platform=macOS' \
  -resultBundlePath build/results/UITests.xcresult

If this command exits non-zero, the test run failed; do not record its wall-clock time as a passing benchmark. The exit code is the signal CI should branch on.

Inspect the bundle. On Xcode 16, xcresulttool get now requires an explicit subcommand or --legacy for the pre-16 JSON shape; check xcrun xcresulttool get --help on the machine that actually runs the job before committing a command:

xcrun xcresulttool get --legacy \
  --path build/results/UITests.xcresult \
  --format json > build/results/summary.json

Export every attachment to a directory so reviewers do not need Xcode open:

xcrun xcresulttool export attachments \
  --path build/results/UITests.xcresult \
  --output-path build/results/attachments

Your timestamp-prefixed filenames sort chronologically and group per failing test.

Uploading the .xcresult Artifact from CI

Upload the whole bundle. The screenshots are inside it, the system logs sit alongside, and opening the .xcresult on any Mac with Xcode shows the failure thread, screenshot and attachments under the failing test.

# .github/workflows/uitests.yml
jobs:
  ui-tests:
    runs-on: [self-hosted, macOS]
    steps:
      - uses: actions/checkout@v4

      - name: Run UI tests
        run: |
          set -o pipefail
          xcodebuild test \
            -project MyApp.xcodeproj \
            -scheme "$UI_TEST_SCHEME" \
            -destination 'platform=macOS' \
            -resultBundlePath build/results/UITests.xcresult

      - name: Upload .xcresult bundle
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: xcresult-${{ github.run_id }}-${{ github.run_attempt }}
          path: build/results/UITests.xcresult
          retention-days: 14

The if: always() guard runs the upload step on failure. github.run_attempt keeps reruns from colliding on the same artifact name. Review the result bundle by downloading, double-clicking, and letting Xcode render the Test navigator — failing tests show the screenshot inline.

For GitLab CI the shape is the same:

ui-tests:
  stage: test
  script:
    - set -o pipefail
    - xcodebuild test -project MyApp.xcodeproj -scheme "$UI_TEST_SCHEME" -destination 'platform=macOS' -resultBundlePath build/UITests.xcresult
  artifacts:
    when: always
    paths:
      - build/UITests.xcresult
    expire_in: 14 days

Do not insert || true to mask the failure; let the job fail and let when: always ship the bundle.

Choosing a Mac Tier for This Workflow

The observer itself has no exotic hardware requirement. The $29 Figment virtual Intel macOS plan is the lowest-cost starting point: Xcode 26 or earlier, the self-hosted runner, and xcodebuild all run inside its desktop through the browser console. Outbound network to GitHub or GitLab is fine; inbound public SSH is not part of the $29 plan, so wire the runner as an outbound agent to your CI service.

Choose a physical M-series Mac from $95/month when you need arm64 execution (Apple silicon only), Xcode 27, or more than 8 GiB RAM. Specific chip, RAM and disk are confirmed in the quote rather than assumed from the entry price. If you want a managed setup — CI runner installed, backups and monitoring in place — the Managed Build Mac is $249/month and includes one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support.

Request a configuration at /mac-hosting or start at /mac-hosting/start.

Related guides:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms