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, andUUID. - Per-type monotonically increasing sequences for unique fields.
- Composable traits (
.outOfStock,.onSale) and trailing-closure overrides.
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.
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" }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" }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.
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.
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.
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.
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 }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:
- A fresh
SeededRandomNumberGenerator(SplitMix64) replaces the system RNG for every generator call. - Per-type sequence counters reset to
0, so the firstInt.sequence()returns1. Date.recent(within:)andDate.future(within:)use a frozen reference instant instead ofDate().
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()
}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.
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() // worksThe 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.
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()andDate()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.
MIT. See LICENSE.