Skip to content

0.4.0

Latest

Choose a tag to compare

@mackoj mackoj released this 19 Aug 16:46
· 2 commits to main since this release
Immutable release. Only release title and notes can be modified.
4acf19d

Capture a value your app actually produced. Commit it as Swift.

let order = try JSONDecoder().decode(Order.self, from: response)

try Literal.write(order, named: "shippedOrder")

That writes Order+shippedOrder.swift next to your test:

import Foundation

extension Order {
    static let shippedOrder: Order = Order(
        id: UUID(uuidString: "8C6D4E2A-0000-4000-8000-1B2C3D4E5F60")!,
        placedAt: Date(timeIntervalSince1970: 1710000000.0),
        status: .shipped,
        lines: [Order.Line(sku: "WDG-001", quantity: 2, unitPrice: Decimal(string: "29.99")!)]
    )
}

Order.shippedOrder is ordinary Swift from then on. It has autocomplete. It needs no
decoding. Rename a property and it stops compiling, and the compiler tells you where.

Treat this as the first release. Earlier tags exist and are not worth your time: the suite
crashed outright before 0.3.0, and 0.3.x renders nested types under a name that does not
resolve.

Why

You can type a fixture by hand. It is a guess about what the decoder produces, and it
drifts from the real thing one field at a time, with nothing failing until something fails
in production.

You can keep fixtures as JSON. The compiler cannot see JSON, so a renamed property leaves a
stale file that nothing complains about until a test runs.

A fixture written in Swift is checked by the same compiler that checks everything else.

What is in it

The renderer. Primitives, every sized integer, collections, tuples, ranges, optionals
including nested ones, Date, UUID, URL, Data, Decimal, Locale, TimeZone, and
your own structs, classes, enums and generics.

The details that are easy to get wrong are covered by tests. Dictionary keys keep their
type. Enum case names come from the runtime, so an enum that overrides description still
renders its real case. Int??.some(nil) is not nil. Inherited properties are included.
Nested types keep their path. Double round-trips exactly. Two objects pointing at each
other throw instead of exhausting the stack.

A macro for the things reflection cannot see.

@SwiftLiteral
struct User {
    let id: String
    @LiteralRename("displayName") let name: String
    @LiteralRedact(.mask("***")) let apiKey: String
    @LiteralIgnore let cache: [String: Any]
}

Redaction applies at every depth.

Custom renderers, for types whose initializer does not take their stored properties.

Formatting through swift-format, driven by your .editorconfig or .swift-format, so
fixtures look like the rest of your code.

Deterministic output. Dictionary keys sort, set elements sort. Two runs over equal
values produce the same bytes, or the fixture churns in every diff and the whole thing is
worse than JSON.

The test that holds all of this up renders every sample in the matrix, writes it next to
the real type declarations, and runs swiftc -typecheck over both. Parsing is not enough:
User(name: nil) parses.

What it costs

A Swift fixture is worse than JSON at four things, and they are worth knowing before you
adopt it.

It breaks the build. That is the feature and the cost: rename a property mid-refactor and
every fixture stops compiling before you are ready. JSON lets you defer.

It compiles, so it adds to build time. It has to be regenerated rather than edited. And it
cannot leave the project, where a JSON fixture can go to a backend team or a bug report.

If your fixtures are shared across languages, or change more often than the types do, JSON
is the better trade.

Known limits

Foundation types outside the list above are best-effort. The renderer builds an
initializer call from a type's stored properties. That holds for your own types. It does
not hold for Foundation value types that wrap reference or C storage, and when it does not,
the generated file will not compile.

Confirmed not working: Measurement, DateInterval, URLComponents. Confirmed throwing
rather than producing bad output: Calendar, CharacterSet, AttributedString,
NSRange. Register a custom renderer for any of them, or project the value into a type you
own.

This is the main thing between here and a 1.0.

There is no assert-against-the-committed-file helper yet. Literal.source gives you the
text, and comparing it is about forty lines of your own. It belongs in the library and it
is not there.

Types must be visible to the generated file. A private type produces an
inaccessibility error and a misleading secondary error about static stored properties in
generic types. The second one points away from the cause.

Generate on macOS or in the simulator. The default output path comes from #filePath,
which is the compiling machine's path.

Install

.package(url: "https://github.com/mackoj/swift-snapshot.git", from: "0.4.0")
.product(name: "SwiftLiteral", package: "swift-snapshot")

Swift 6.0. macOS 13, iOS 16, watchOS 9, tvOS 16, all built on CI.

If you used 0.2 or 0.3

The names changed. SwiftSnapshot is SwiftLiteral, @SwiftSnapshot is @SwiftLiteral,
and SwiftSnapshotRuntime.export(instance:variableName:) is Literal.write(_:named:).
"Snapshot" read as swift-snapshot-testing and set the wrong expectation before anyone
reached the first code sample.

Literal.source(of:named:) is public now, which it should have been from the start.