Contents

0028-REVISE-ATTACHMENT-ENCODABLE-INTERFACES: Revise Swift Testing's `Attachment`/`Encodable` interop

Introduction

We introduced attachments to Swift Testing in Swift 6.2 with [ST-0009]. As part of that initial introduction, we included default implementations of our [Attachable] protocol's requirements for types that already conform to [Encodable] or [NSSecureCoding].

This proposal revises the interfaces for attaching a value that conforms to one of those protocols to resolve some constraints that come with the current interface and implementation.

Motivation

We have identified several deficiencies in the current interface:

  • Test authors must explicitly add conformance to [Attachable] to their

types that already conform to [Encodable] or [NSSecureCoding], which is boilerplate that doesn't really do anything other than satisfy Swift's type system.

  • The only way to select between property list and JSON encodings is to specify

a path extension in the attachment's preferred name.

  • There is no way to customize the encoder's settings. For example, there is no

way to configure JSONEncoder.outputFormatting.

  • Types that conform to both [Encodable] and [NSSecureCoding] are

ambiguously encoded. We document these types as using the [Encodable] encoding by default, but there is no way to customize this behavior.

Proposed solution

New [Attachment] initializers are introduced that behave similarly to the initializers introduced for file URLs in [ST-0009] and for [Transferable]-conforming types in [ST-0023].

The existing default implementations of [Attachable]'s requirements are marked to-be-deprecated. Their documentation will automatically include migration information, but new diagnostics will not be emitted at build time until a future Swift release.

Detailed design

The following extension to Attachment is added and is available when test authors import both Testing and Foundation in a Swift file:

extension Attachment {
  /// Initialize an instance of this type representing a value that conforms to
  /// the `Encodable` protocol.
  ///
  /// - Parameters:
  ///   - encodableValue: The value to encode and attach.
  ///   - encodingFormat: The encoding format to use to encode `encodableValue`.
  ///   - preferredName: The preferred name of the attachment when writing it to
  ///     a test report or to disk.
  ///   - sourceLocation: The source location of the call to this initializer.
  ///     This value is used when recording issues associated with the
  ///     attachment.
  ///
  /// - Throws: If an appropriate encoder could not be found given the
  ///   `encodingFormat` and `preferredName` arguments.
  ///
  /// Use this initializer to create an instance of ``Attachment`` from a value
  /// that conforms to the `Encodable` protocol:
  ///
  /// let menu = FoodTruck.currentMenu
  /// let attachment = try Attachment(encoding: menu, as: .json)
  /// Attachment.record(attachment)
  ///
  /// The encoding that the testing library uses depends on the `encodingFormat`
  /// argument. If the value of that argument is `nil`, the testing library
  /// derives the format from the path extension you specify in `preferredName`.
  ///
  /// | Extension | Encoding Used | Encoder Used |
  /// |-|-|-|
  /// | `".xml"` | XML property list | `PropertyListEncoder` |
  /// | `".plist"` | Binary property list | `PropertyListEncoder` |
  /// | None, `".json"` | JSON | `JSONEncoder` |
  ///
  /// - Important: OpenStep-style property lists are not supported.
  ///
  /// If the values of both the `encodingFormat` and `preferredName` arguments
  /// are `nil`, the testing library encodes `encodableValue` as JSON.
  public init<T>(
    encoding encodableValue: T,
    as encodingFormat: AttachableEncodingFormat? = nil,
    named preferredName: String? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
  ) throws where AttachableValue == _AttachableEncodableWrapper<T, Void>, T: Encodable

#if canImport(Combine)
  /// Initialize an instance of this type representing a value that conforms to
  /// the `Encodable` protocol.
  ///
  /// - Parameters:
  ///   - encodableValue: The value to encode and attach.
  ///   - encoder: The encoder to use to encode `encodableValue`.
  ///   - preferredName: The preferred name of the attachment when writing it to
  ///     a test report or to disk.
  ///   - sourceLocation: The source location of the call to this initializer.
  ///     This value is used when recording issues associated with the
  ///     attachment.
  ///
  /// - Throws: If `encoder` cannot be used to encode `encodableValue`.
  ///
  /// Use this initializer to create an instance of ``Attachment`` from a value
  /// that conforms to the `Encodable` protocol:
  ///
  /// let menu = FoodTruck.currentMenu
  /// let encoder = JSONEncoder()
  /// let attachment = try Attachment(encoding: menu, using: encoder)
  /// Attachment.record(attachment)
  public init<T, E>(
    encoding encodableValue: T,
    using encoder: E,
    named preferredName: String? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
  ) throws where AttachableValue == _AttachableEncodableWrapper<T, E>, T: Encodable, E: TopLevelEncoder & Sendable, E.Output: ContiguousBytes
#endif

  /// Initialize an instance of this type representing a value that conforms to
  /// the `NSSecureCoding` protocol.
  ///
  /// - Parameters:
  ///   - encodableValue: The value to encode and attach.
  ///   - propertyListFormat: The property list format to use to encode
  ///     `encodableValue`.
  ///   - preferredName: The preferred name of the attachment when writing it to
  ///     a test report or to disk.
  ///   - sourceLocation: The source location of the call to this initializer.
  ///     This value is used when recording issues associated with the
  ///     attachment.
  ///
  /// - Throws: If an appropriate encoder could not be found given the
  ///   `propertyListFormat` and `preferredName` arguments.
  ///
  /// Use this initializer to create an instance of ``Attachment`` from a value
  /// that conforms to the `NSSecureCoding` protocol:
  ///
  /// let menu = FoodTruck.currentMenu
  /// let attachment = try Attachment(encoding: menu, as: .xml)
  /// Attachment.record(attachment)
  ///
  /// The encoding that the testing library uses depends on the
  /// `propertyListFormat` argument. If the value of that argument is `nil`, the
  /// testing library derives the format from the path extension you specify in
  /// `preferredName`.
  ///
  /// | Extension | Encoding Used | Encoder Used |
  /// |-|-|-|
  /// | `".xml"` | XML property list | `NSKeyedArchiver` |
  /// | None, `".plist"` | Binary property list | `NSKeyedArchiver` |
  ///
  /// - Important: OpenStep-style property lists are not supported.
  ///
  /// If the values of both the `propertyListFormat` and `preferredName`
  /// arguments are `nil`, the testing library encodes `encodableValue` as a
  /// binary property list.
  public init<T>(
    encoding encodableValue: T,
    as propertyListFormat: PropertyListSerialization.PropertyListFormat? = nil,
    named preferredName: String? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
  ) throws where AttachableValue == _AttachableEncodableWrapper<T, NSKeyedArchiver>, T: NSSecureCoding
}

[!NOTE] The _AttachableEncodableWrapper is a type that conforms to the [AttachableWrapper] protocol and is an implementation detail. Test authors do not need to use this type directly.

The AttachableEncodingFormat type is declared as follows:

/// An enumeration describing the encoding formats that you can use when
/// attaching a value that conforms to `Encodable`.
///
/// Pass an instance of this type to ``Testing/Attachment/init(encoding:as:named:sourceLocation:)``
/// to specify what encoder and format to use when the testing library saves the
/// resulting attachment.
///
/// If you want to attach a value that conforms to `NSSecureCoding`, use
/// `PropertyListFormat` instead.
public struct AttachableEncodingFormat: Sendable, Equatable {
  /// Create an instance of this type representing a property list format.
  ///
  /// - Parameters:
  ///   - format: The corresponding property list format.
  ///
  /// - Returns: An instance of this type representing `format`.
  public static func propertyListFormat(_ format: PropertyListSerialization.PropertyListFormat) -> Self

  /// An instance of this type representing the JSON format.
  public static var json: Self { get }
}

Test authors can use the new initializers to create attachments from any value that conforms to [Encodable] or [NSSecureCoding]. These values do not need a pro forma conformance to [Attachable]:

@Test func `Bake an apple pie`() throws {
  let oven = Oven()
  oven.warm(to: .fahrenheit(400))
  ...
  let attachment = try Attachment(encoding: recipe, as: .propertyListFormat(.binary))
  Attachment.record(attachment)
  ...
  #expect(pie.isDelicious)
}

// OR:

@Test func `Bake an apple pie`() throws {
  let oven = Oven()
  oven.warm(to: .fahrenheit(400))
  ...
  let encoder = JSONEncoder()
  encoder.outputFormatting = [.prettyPrinted, ...]
  let attachment = try Attachment(encoding: recipe, using: encoder)
  Attachment.record(attachment)
  ...
  #expect(pie.isScrumptious)
}

If the test author does not specify an encoding format or encoder, Swift Testing makes a best effort to derive the encoding format from the attachment's preferred name (as with the existing interface). If the test author doesn't specify a preferred name either, Swift Testing defaults to JSON (for [Encodable] types) or the binary property list format (for [NSSecureCoding] types).

Deprecation of existing interfaces

The existing default implementations are marked to-be-deprecated:

extension Attachable where Self: Encodable {
  /// @DeprecationSummary {
  ///   Use ``Attachment/init(encoding:as:named:sourceLocation:)`` instead:
  ///
  ///   let attachment = try Attachment(encoding: someValue, as: .json)
  ///   Attachment.record(attachment)
  /// }
  @available(swift, introduced: 6.2, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead")
  public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R
}

extension Attachable where Self: NSSecureCoding {
  /// @DeprecationSummary {
  ///   Use ``Attachment/init(encoding:as:named:sourceLocation:)`` instead:
  ///
  ///   let attachment = try Attachment(encoding: someValue, as: .binary)
  ///   Attachment.record(attachment)
  /// }
  @available(swift, introduced: 6.2, deprecated: 100000.0, message: "Use 'Attachment.init(encoding:as:named:sourceLocation:)' instead")
  public func withUnsafeBytes<R>(for attachment: borrowing Attachment<Self>, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R
}

TopLevelEncoder on non-Apple platforms

[TopLevelEncoder] is declared in the Combine framework which is part of Apple's SDKs but not part of the open source Swift project. As such, it is not available on non-Apple platforms. See future directions for more details; in the mean time, concrete overloads of init(encoding:using:named:sourceLocation:) are added on non-Apple platforms to cover the common use cases:

#if !canImport(Combine)
extension Attachment {
  public init<T, E>(
    encoding encodableValue: T,
    using encoder: E,
    named preferredName: String? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
  ) throws where AttachableValue == _AttachableEncodableWrapper<T, E>, T: Encodable, E: PropertyListEncoder & Sendable

  public init<T, E>(
    encoding encodableValue: T,
    using encoder: E,
    named preferredName: String? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
  ) throws where AttachableValue == _AttachableEncodableWrapper<T, E>, T: Encodable, E: JSONEncoder & Sendable
}
#endif

Source compatibility

These changes are additive and should not impact existing code.

Integration with supporting tools

No changes. Supporting tools that consume attachments should "just work".

Future directions

  • Lowering [TopLevelEncoder] to the standard library. If we do this, then

the protocol becomes available on non-Apple platforms and our hard-coded workarounds can be removed. (The author intends to propose a change here, but it is beyond the scope of this proposal).

  • Formally deprecating the existing interface. As discussed above, a future

Swift toolchain release will emit deprecation warnings at build time when a type conforms to both [Encodable]/[NSSecureCoding] and [Attachable] and relies on the default implementations of [Attachable]'s requirements.

Alternatives considered

  • Exposing new overloads of Attachment.record() instead of Attachment.init().

See [ST-0023] for more information why we prefer to overload init() here instead of record().

  • Dropping support for [NSSecureCoding]. While [Encodable] is the

preferred serialization protocol in Swift, [NSSecureCoding] remains supported at the Foundation layer.

  • Leaving the existing interfaces undeprecated while adding the new ones.

The existing interfaces have the aforementioned ergonomic deficiencies. We don't want test authors to add conformances to [Attachable] by rote, and we don't want to support two entirely distinct mechanisms for attaching encodable values when only one is needed (let alone where only one is recommended).

[Attachable]: https://developer.apple.com/documentation/testing/attachable [AttachableWrapper]: https://developer.apple.com/documentation/testing/attachablewrapper [Attachment]: https://developer.apple.com/documentation/testing/attachment [Encodable]: https://developer.apple.com/documentation/swift/encodable [NSSecureCoding]: https://developer.apple.com/documentation/foundation/nssecurecoding [TopLevelEncoder]: https://developer.apple.com/documentation/combine/toplevelencoder [Transferable]: https://developer.apple.com/documentation/coretransferable/transferable

[ST-0009]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0009-attachments.md [ST-0023]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0023-attachments-transferable.md