Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ jobs:
macos:
strategy:
matrix:
xcode:
- '16.4'
include:
- xcode: '16.4'
os: macos-15
- xcode: '26.0'
os: macos-26

name: macOS
runs-on: macos-15
name: macOS (Xcode ${{ matrix.xcode }})
runs-on: ${{ matrix.os || 'macos-15' }}
steps:
- uses: actions/checkout@v4
- name: Select Xcode ${{ matrix.xcode }}
Expand Down
76 changes: 76 additions & 0 deletions Documentation/SwiftTestingAttachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Swift Testing attachment support

Learn how snapshot test failures automatically attach reference images, actual results, and diffs to your test results when using Swift Testing.

## Overview

Starting with Swift 6.2 and Xcode 26, swift-snapshot-testing now supports attachments when running tests with Swift Testing. When a snapshot test fails, the library automatically attaches the reference image, actual result, and a visual diff directly to your test results in Xcode, making it easier to diagnose and fix test failures.

## Requirements

- **Swift 6.2** or later
- **Xcode 26** or later
- Tests must be run using Swift Testing (not XCTest)

## How it works

When a snapshot test fails under Swift Testing:

### Image snapshots

Three attachments are created:
- **reference**: The expected image
- **failure**: The actual image that was captured
- **difference**: A visual diff highlighting the differences

### Text snapshots

One attachment is created:
- **difference.patch**: A unified diff showing the textual changes

## Implementation details

The implementation recreates attachment data from the original source values:
- `XCTAttachment` doesn't expose its internal data (userInfo is always nil), so we recreate it from the snapshot's reference and diffable values
- For image attachments, we call `snapshotting.diffing.toData()` on the reference/failure values
- For difference images, we regenerate the visual diff using the internal `diff()` functions
- When running under XCTest, it uses the traditional `XCTAttachment` approach
- This ensures backward compatibility while adding Swift Testing support

## Viewing Attachments

### In Xcode
- Attachments appear in the test navigator next to failed tests
- Click on an attachment to preview it inline
- Right-click to export or open in an external viewer

### In CI/Command Line
- Attachments are saved to the `.xcresult` bundle
- Extract with: `xcrun xcresulttool get --path Test.xcresult --id <attachment-id>`
- Or open the `.xcresult` file directly in Xcode

## Example usage

```swift
import Testing
import SnapshotTesting

@Test func testUserProfile() {
let view = UserProfileView(name: "Alice")

// If this fails, three attachments will be automatically created
assertSnapshot(of: view, as: .image)
}
```

## Backward compatibility

- Code using XCTest continues to work unchanged
- Swift versions before 6.2 will use XCTAttachment (no Swift Testing attachments)
- The feature is conditionally compiled based on Swift version

## Notes

- Attachments are non-copyable and can only be attached once per test.
- The attachment system respects the test's source location for better debugging.
- Empty images and corrupted data are handled gracefully.
73 changes: 69 additions & 4 deletions Sources/SnapshotTesting/AssertSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,23 @@ public func verifySnapshot<Value, Format>(
}

#if !os(Android) && !os(Linux) && !os(Windows)
if !isSwiftTesting,
ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS")
if isSwiftTesting {
let attachmentData: Data
if writeToDisk {
attachmentData = (try? Data(contentsOf: snapshotFileUrl)) ?? snapshotData
} else {
attachmentData = snapshotData
}
STAttachments.record(
attachmentData,
named: snapshotFileUrl.lastPathComponent,
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
} else if ProcessInfo.processInfo.environment.keys.contains(
"__XCODE_BUILT_PRODUCTS_DIR_PATHS")
{
XCTContext.runActivity(named: "Attached Recorded Snapshot") { activity in
if writeToDisk {
Expand Down Expand Up @@ -457,8 +472,58 @@ public func verifySnapshot<Value, Format>(

if !attachments.isEmpty {
#if !os(Linux) && !os(Android) && !os(Windows)
if ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS"),
!isSwiftTesting
if isSwiftTesting {
#if canImport(Testing) && compiler(>=6.2)
if Test.current != nil {
// XCTAttachment doesn't expose its internal data, so we recreate it from the
// source values (reference/diffable) based on the attachment name.
for attachment in attachments {
var attachmentData: Data?
var attachmentName: String? = attachment.name

switch attachment.name {
case "reference":
attachmentData = snapshotting.diffing.toData(reference)
attachmentName = "reference.\(snapshotting.pathExtension ?? "data")"
case "failure":
attachmentData = snapshotting.diffing.toData(diffable)
attachmentName = "failure.\(snapshotting.pathExtension ?? "data")"
case "difference":
#if os(macOS)
if let oldImage = reference as? NSImage, let newImage = diffable as? NSImage {
attachmentData = SnapshotTesting.NSImagePNGRepresentation(
SnapshotTesting.diff(oldImage, newImage))
attachmentName = "difference.\(snapshotting.pathExtension ?? "png")"
}
#elseif os(iOS) || os(tvOS)
if let oldImage = reference as? UIImage, let newImage = diffable as? UIImage {
attachmentData = SnapshotTesting.diff(oldImage, newImage).pngData()
attachmentName = "difference.\(snapshotting.pathExtension ?? "png")"
}
#endif
default:
// String diffs and other non-image attachments.
attachmentData = Data(failure.utf8)
if attachmentName == nil {
attachmentName = "difference.patch"
}
}

if let attachmentData = attachmentData {
STAttachments.record(
attachmentData,
named: attachmentName,
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
}
}
}
#endif
} else if ProcessInfo.processInfo.environment.keys.contains(
"__XCODE_BUILT_PRODUCTS_DIR_PATHS")
{
XCTContext.runActivity(named: "Attached Failure Diff") { activity in
attachments.forEach {
Expand Down
52 changes: 52 additions & 0 deletions Sources/SnapshotTesting/Internal/STAttachments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

#if !os(Linux) && !os(Android) && !os(Windows) && canImport(XCTest)
import XCTest
#endif

#if canImport(Testing) && compiler(>=6.2)
import Testing
#endif

/// Helper for Swift Testing attachment recording.
///
/// Records attachments asynchronously for better performance with large test suites.
internal enum STAttachments {
#if canImport(Testing) && compiler(>=6.2)
static func record(
_ data: Data,
named name: String? = nil,
fileID: StaticString,
filePath: StaticString,
line: UInt,
column: UInt
) {
guard Test.current != nil else { return }

// Record asynchronously to avoid blocking test execution.
// Using Task (not .detached) ensures Test.current context is inherited.
Task {
Attachment.record(
data,
named: name,
sourceLocation: SourceLocation(
fileID: fileID.description,
filePath: filePath.description,
line: Int(line),
column: Int(column)
)
)
}
}
#else
static func record(
_ data: Data,
named name: String? = nil,
fileID: StaticString,
filePath: StaticString,
line: UInt,
column: UInt
) {
}
#endif
}
2 changes: 1 addition & 1 deletion Sources/SnapshotTesting/Snapshotting/Any.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ private func snap<T>(
return "\(indentation)- \(name.map { "\($0): " } ?? "")\(value.snapshotDescription)\n"
case (let value as CustomStringConvertible, _):
description = value.description
case let (value as AnyObject, .class?):
case (let value as AnyObject, .class?):
let objectID = ObjectIdentifier(value)
if visitedValues.contains(objectID) {
return "\(indentation)\(bullet) \(name ?? "value") (circular reference detected)\n"
Expand Down
4 changes: 2 additions & 2 deletions Sources/SnapshotTesting/Snapshotting/NSImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
}
}

private func NSImagePNGRepresentation(_ image: NSImage) -> Data? {
internal func NSImagePNGRepresentation(_ image: NSImage) -> Data? {
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return nil
}
Expand Down Expand Up @@ -155,7 +155,7 @@
return context
}

private func diff(_ old: NSImage, _ new: NSImage) -> NSImage {
internal func diff(_ old: NSImage, _ new: NSImage) -> NSImage {
let oldCiImage = CIImage(cgImage: old.cgImage(forProposedRect: nil, context: nil, hints: nil)!)
let newCiImage = CIImage(cgImage: new.cgImage(forProposedRect: nil, context: nil, hints: nil)!)
let differenceFilter = CIFilter(name: "CIDifferenceBlendMode")!
Expand Down
1 change: 1 addition & 0 deletions Sources/SnapshotTesting/Snapshotting/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extension Diffing where Value == String {
hunks
.flatMap { [$0.patchMark] + $0.lines }
.joined(separator: "\n")

let attachment = XCTAttachment(
data: Data(failure.utf8), uniformTypeIdentifier: "public.patch-file")
return (failure, [attachment])
Expand Down
4 changes: 2 additions & 2 deletions Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@

switch layout {
#if os(iOS) || os(tvOS)
case let .device(config: deviceConfig):
case .device(config: let deviceConfig):
config = deviceConfig
#endif
case .sizeThatFits:
config = .init(safeArea: .zero, size: nil, traits: traits)
case let .fixed(width: width, height: height):
case .fixed(let width, let height):
let size = CGSize(width: width, height: height)
config = .init(safeArea: .zero, size: size, traits: traits)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SnapshotTesting/Snapshotting/UIImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
return context
}

private func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
internal func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
let width = max(old.size.width, new.size.width)
let height = max(old.size.height, new.size.height)
let scale = max(old.scale, new.scale)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@
"""
}
} matching: { issue in
issue.description == """
Issue recorded: Snapshot did not match. Difference: …
var issuePrefix = "Issue recorded"
#if compiler(>=6.2)
// Swift 6.2+ changed the error message format
issuePrefix += " (error)"
#endif

return issue.description == """
\(issuePrefix): Snapshot did not match. Difference: …

@@ −1,3 +1,4 @@
 ▿ 2 elements
Expand Down
28 changes: 18 additions & 10 deletions Tests/SnapshotTestingTests/SnapshotTestingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,14 @@ final class SnapshotTestingTests: BaseTestCase {
button.bezelStyle = .rounded
button.title = "Push Me"
button.sizeToFit()
if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") {
assertSnapshot(of: button, as: .image)
assertSnapshot(of: button, as: .recursiveDescription)
}
// Skip snapshot tests in Xcode 26+ (Swift 6.2+) due to NSButton rendering changes
// See: https://github.com/pointfreeco/swift-snapshot-testing/issues/1020
#if compiler(<6.2)
if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") {
assertSnapshot(of: button, as: .image)
assertSnapshot(of: button, as: .recursiveDescription)
}
#endif
#endif
}

Expand Down Expand Up @@ -244,12 +248,16 @@ final class SnapshotTestingTests: BaseTestCase {
label.isBezeled = false
label.isEditable = false
#endif
if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") {
label.text = "Hello."
assertSnapshot(of: label, as: .image(precision: 0.9), named: platform)
label.text = "Hello"
assertSnapshot(of: label, as: .image(precision: 0.9), named: platform)
}
// Skip snapshot tests in Xcode 26+ (Swift 6.2+) due to rendering changes
// See: https://github.com/pointfreeco/swift-snapshot-testing/issues/1020
#if compiler(<6.2)
if !ProcessInfo.processInfo.environment.keys.contains("GITHUB_WORKFLOW") {
label.text = "Hello."
assertSnapshot(of: label, as: .image(precision: 0.9), named: platform)
label.text = "Hello"
assertSnapshot(of: label, as: .image(precision: 0.9), named: platform)
}
#endif
#endif
}

Expand Down
Loading