Skip to content

crleonard/SwiftFixtures

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

34 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SwiftFixtures

Type-safe, ergonomic test data factories for Swift. Think FactoryBot for the Swift 6 era.

SwiftFixtures gives you a single .fixture() call to build realistic test data for any value type. Inspired by Ruby's FactoryBot and JavaScript's Fishery, rebuilt around Swift's type system, strict concurrency, and Swift Testing.

  • Zero dependencies. One target. MIT-licensed.
  • Sendable-correct under Swift 6 strict concurrency.
  • Deterministic mode with a single seeded RNG. Same seed in, same fixture out.
  • Built-in sample (names, addresses, phones, companies, web/app fields, lorem text, files, network strings, UUIDs).
  • Native typed generators for money-like Decimal, URL, URLRequest, HTTPURLResponse, Data, Date, DateComponents, Measurement, IndexPath, Result, and UUID.
  • Per-type monotonically increasing sequences for unique fields.
  • Composable traits (.outOfStock, .onSale) and trailing-closure overrides.

Installation

Add SwiftFixtures to your Package.swift:

dependencies: [
    .package(url: "https://github.com/crleonard/SwiftFixtures.git", from: "0.1.0"),
],
targets: [
    .testTarget(
        name: "MyAppTests",
        dependencies: [
            .product(name: "SwiftFixtures", package: "SwiftFixtures"),
        ]
    ),
]

Requires Swift 6.2+ and one of: macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, or Linux.

30-second quick start

import Foundation
import SwiftFixtures

struct ProductInfo: Codable, Equatable, Sendable {
    var id: UUID
    var sku: String
    var name: String
    var priceCents: Int
    var inStock: Bool
    var categories: [String]
    var createdAt: Date
}

extension ProductInfo: Fixture {
    static let factory = Factory {
        ProductInfo(
            id: .sample(.uuid),
            sku: .sequence { "SKU-\(String(format: "%05d", $0))" },
            name: .sample(.productName),
            priceCents: .random(in: 100...99_999),
            inStock: true,
            categories: .sample(.words(count: 2...4)),
            createdAt: .recent(within: .seconds(60 * 60 * 24 * 90))
        )
    }
}

extension Trait where T == ProductInfo {
    static let outOfStock = Trait { $0.inStock = false }
    static let onSale = Trait { $0.priceCents = .random(in: 50...499) }
}

// In tests:
let p1 = ProductInfo.fixture()
let sale = ProductInfo.fixture(.onSale, .outOfStock)
let many = ProductInfo.fixture(count: 50)
let custom = ProductInfo.fixture { $0.name = "Specific" }

Factories

A Factory<T> wraps a @Sendable () -> T closure. Conform a type to Fixture by exposing a static factory and you get .fixture(), .fixture(count:), and trailing-closure overrides for free.

extension UserAccount: Fixture {
    static let factory = Factory {
        UserAccount(
            id: .sample(.uuid),
            name: .sample(.name),
            email: .sample(.email)
        )
    }
}

A factory can be reused without Fixture:

let admin = UserAccount.factory.build()
let many  = UserAccount.factory.build(count: 10)
let muted = UserAccount.factory.map { $0.email = "muted@example.com" }

Batch builds

fixture(count:) produces an array. Sequence generators inside the factory closure continue to increment across the batch, so any .sequence(...)-derived field stays unique:

let products = ProductInfo.fixture(count: 50)

For ordered or position-aware data, the trailing closure can take an index. Swift picks this overload from the closure's arity, so there's no label to remember:

let posts = Post.fixture(count: 10) { index, post in
    post.position = index
    post.slug = "post-\(index)"
}

The indexed closure runs after the base factory and any traits, so it can override their work.

Traits

A Trait<T> is a small (inout T) -> Void modifier. The convention is to add traits as static members of Trait<YourModel> so they're reachable via leading-dot syntax at the call site:

extension Trait where T == ProductInfo {
    static let outOfStock = Trait(\.inStock, false)                   // KeyPath form
    static let onSale = Trait { $0.priceCents = .random(in: 50...499) } // closure form
}

ProductInfo.fixture(.onSale, .outOfStock)

Traits apply in order; a trailing-closure override runs after all traits and always wins.

KeyPath shortcuts

For the common "set one field" case, traits can be built from a WritableKeyPath instead of a closure:

// Define a static trait via the KeyPath init:
extension Trait where T == ProductInfo {
    static let outOfStock = Trait(\.inStock, false)
}

// Set a field inline at the call site:
ProductInfo.fixture(.set(\.name, to: "Pinned"))
ProductInfo.fixture(.outOfStock, .set(\.priceCents, to: 99))

// Or use the one-liner override shortcut:
let alice  = UserAccount.fixture(set: \.name, to: "Alice")
let pinned = ProductInfo.fixture(.outOfStock, set: \.name, to: "Pinned")

The KeyPath form requires T: Sendable and the value to be Sendable, which covers nearly every fixture type in practice.

A heads-up on naming: Swift Testing also exports a protocol named Trait. In practice the two never clash. Call-site leading-dot syntax (ProductInfo.fixture(.outOfStock)) and expression-position Trait<...> { ... } both resolve to SwiftFixtures unambiguously. Only fully bare type annotations (let t: Trait<...>) are ambiguous; in that rare case write SwiftFixtures.Trait<...> to disambiguate (the module-qualified name), not the bare Trait.

Generators

All generators draw from the active FixtureContext so they're reproducible inside SwiftFixtures.withSeed:

Generator Returns Example
Int.sequence() Int 1, 2, 3, …
Int.sequence(named:) Int per-name counter
String.sequence(_:) String "SKU-1", "SKU-2"
Int.random(in:) Int 42
Double.random(in:) Double 0.71
Float.random(in:) Float -0.4
Decimal.random(in:scale:) Decimal 42.37
Data.random(bytes:) Data 32 deterministic bytes
DateComponents.random(...) DateComponents year/month/day/time components
IndexPath.random(section:row:) IndexPath [2, 14]
Measurement<UnitLength>.random(in:unit:) Measurement<UnitLength> 1.42 km
Measurement<UnitMass>.random(in:unit:) Measurement<UnitMass> 72.5 kg
Result.random(success:failure:) Result .success(value) or .failure(error)
oneOf("a", "b", "c") element "b"
Bool.randomFixture() Bool true
String.sample(.name) String "Aria Patel"
String.sample(.email) String "aria.patel@example.com"
String.sample(.word) String "willow"
String.sample(.sentence(words:)) String "Quill silk pine."
String.sample(.paragraph(sentences:)) String "Quill silk pine. River amber cedar."
String.sample(.lorem(paragraphs:)) String paragraphs separated by blank lines
String.sample(.productName) String "Acme Compact Notebook"
String.sample(.url) String "https://acme.example/willow"
String.sample(.streetAddress) String "742 Maple Street"
String.sample(.city) String "Springfield"
String.sample(.postalCode) String "02134"
String.sample(.phoneNumber) String "+1-555-0104"
String.sample(.companyName) String "Acme Labs"
String.sample(.hex(length:)) String "7fa3c19d"
String.sample(.base64(bytes:)) String "xY7T8Q=="
String.sample(.ipv4Address) String "192.0.2.42"
String.sample(.ipv6Address) String "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
String.sample(.macAddress) String "f2:3c:91:0a:be:44"
String.sample(.fileName) String "river-amber.pdf"
String.sample(.mimeType) String "application/json"
String.sample(.username) String "aria.patel42"
String.sample(.slug(words:)) String "river-amber-pine"
String.sample(.domainName) String "acme.dev"
String.sample(.imageURL) String "https://picsum.photos/seed/river-amber/640/480"
String.sample(.avatarURL) String "https://api.dicebear.com/8.x/initials/png?seed=aria-patel42"
String.sample(.jobTitle) String "Software Engineer"
String.sample(.department) String "Engineering"
String.sample(.country) String "United Kingdom"
String.sample(.countryCode) String "GB"
String.sample(.currencyCode) String "GBP"
String.sample(.localeIdentifier) String "en_GB"
String.sample(.timeZoneIdentifier) String "Europe/London"
String.sample(.colorHex) String "#7fa3c1"
String.sample(.semver) String "1.4.2"
String.sample(.userAgent) String "Mozilla/5.0 ..."
URL.sample(.url) URL https://acme.example/willow
URL.sample(.imageURL) URL https://picsum.photos/...
URL.sample(.avatarURL) URL https://api.dicebear.com/...
URLRequest.sample(.get) URLRequest GET request with JSON accept headers
URLRequest.sample(.postJSON) URLRequest POST request with JSON body
HTTPURLResponse.sample(.ok) HTTPURLResponse 200 JSON response
HTTPURLResponse.sample(.status(404)) HTTPURLResponse custom status response
UUID.sample(.uuid) UUID 0E91…
[String].sample(.words(count:)) [String] ["pine", "river"]
Date.recent(within:) Date up to N seconds in the past
Date.future(within:) Date up to N seconds in the future
Date.random(in:) Date within a closed range

All .random(in:) overloads on Int, UInt, Double, Float, and Date route through the fixture context. Inside withSeed they're deterministic; outside they use SystemRandomNumberGenerator.

Relationships

Factories compose. To embed a related fixture, just call its .fixture():

struct Post: Fixture {
    var id: Int
    var author: UserAccount
    var title: String
}

extension Post {
    static let factory = Factory {
        Post(
            id: .sequence(),
            author: UserAccount.fixture(),
            title: .sample(.sentence(words: 3...6))
        )
    }
}

// Override the relationship inline:
let alice = UserAccount.fixture { $0.name = "Alice" }
let post  = Post.fixture { $0.author = alice }

Determinism

Wrap any block in SwiftFixtures.withSeed(_:_:) to get reproducible fixtures, including sequence counters, RNG output, and date generators:

@Test("seeded runs are deterministic")
func deterministic() {
    let a = SwiftFixtures.withSeed(42) { ProductInfo.fixture() }
    let b = SwiftFixtures.withSeed(42) { ProductInfo.fixture() }
    #expect(a == b)
}

Inside withSeed, three things change:

  1. A fresh SeededRandomNumberGenerator (SplitMix64) replaces the system RNG for every generator call.
  2. Per-type sequence counters reset to 0, so the first Int.sequence() returns 1.
  3. Date.recent(within:) and Date.future(within:) use a frozen reference instant instead of Date().

An async variant is provided for async test bodies:

let user = await SwiftFixtures.withSeed(42) {
    try? await Task.sleep(for: .nanoseconds(1))
    return UserAccount.fixture()
}

Swift Testing integration

For Swift Testing users there's a .fixtureSeed(_:) trait in the opt-in SwiftFixturesTesting product that wraps each annotated test in SwiftFixtures.withSeed:

import SwiftFixtures
import SwiftFixturesTesting
import Testing

@Test(.fixtureSeed(42))
func deterministic() {
    let p = ProductInfo.fixture()
    // ... same `p` every run.
}

@Suite(.fixtureSeed(42))
struct SnapshotTests {
    @Test func first()  { /* seeded */ }
    @Test func second() { /* seeded */ }
}

Test-level traits override suite-level ones, so a single test can opt into a different seed without breaking the rest of the suite.

Macros (opt-in)

For the common case of "I just want a default factory and don't care about the values," there's a @Fixture macro in the SwiftFixturesMacros product:

import SwiftFixtures
import SwiftFixturesMacros

@Fixture
struct User {
    var id: UUID
    var name: String
    var age: Int
    var isActive: Bool
    var tags: [String]
    var nickname: String?
}

let u = User.fixture() // works

The macro inspects stored properties and picks a generator per type:

Field type Generated value
String .sample(.word)
Int, UInt, … .random(in: 0...1000)
Double, Float .random(in: 0.0...1.0)
Bool true
UUID .sample(.uuid)
Date .now
[T] []
T? nil
anything else T.fixture() (assumes Fixture conformance)

The macro also adds Fixture conformance for you. To customise any field, just write the factory by hand. Don't use the macro.

The macro target depends on swift-syntax. The core SwiftFixtures library has zero external dependencies; only the opt-in SwiftFixturesMacros product pulls in swift-syntax.

Why this is better than hand-rolled mocks

Hand-written test data tends to look like this:

let user = UserAccount(
    id: UUID(),
    name: "John Smith",
    email: "j@example.com",
    createdAt: Date(timeIntervalSince1970: 0)
)
let product = ProductInfo(
    id: UUID(),
    sku: "SKU-1",
    name: "Test Product",
    priceCents: 100,
    inStock: true,
    categories: [],
    createdAt: Date()
)

Problems pile up fast:

  • Every field is required at every call site. Add a property and you re-edit every test.
  • Identical values everywhere. Tests that depend on uniqueness break silently when copy-pasted.
  • Hand-rolled clones drift. "Mostly the same user" becomes nine slightly different inline initialisers.
  • Determinism is up to you. UUID() and Date() change every run.

SwiftFixtures fixes all four: one canonical factory per type, sample-generated values that read like real data, sequence counters that guarantee uniqueness, and a single withSeed switch when you need reproducibility.

License

MIT. See LICENSE.

About

Type-safe, ergonomic test data factories for Swift. Think FactoryBot for the Swift 6 era.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages