marten-fixtures
marten_fixtures
Rails-style YAML fixtures for Marten.
marten_fixtures ports ActiveRecord::FixtureSet to Crystal/Marten: per-file YAML fixtures, label references that resolve to deterministic primary keys (CRC32 of the label — byte-compatible with Rails), foreign-key auto-resolution, polymorphic shorthand, and ERB-equivalent templating via Crinja.
Installation
Add to shard.yml:
dependencies:
marten_fixtures:
github: stevegeek/marten-fixtures
Then shards install.
Quick start
# spec/spec_helper.cr
require "spec"
require "marten/spec"
require "marten_fixtures"
Spec.before_each do
Marten::Spec.flush_databases
MartenFixtures.load_all("spec/fixtures")
end
# spec/access_spec.cr
describe "Access" do
include MartenFixtures::Accessors
it "links david to handbook" do
access = Access.get!(user: fixture(User, :david), book: fixture(Book, :handbook))
access.level.should eq("editor")
end
end
The fixture files themselves are plain YAML — label-keyed top-level mappings, one file per table:
# spec/fixtures/users.yml
david:
name: David
email: david@example.com
account: engineering # FK label → resolved to account_id
alice:
name: Alice
email: alice@example.com
account: marketing
The file's stem (users) is matched against Marten::DB::Model.db_table; subdirectories under fixtures/ are flattened into the table name (fixtures/admin/users.yml → admin_users).
Public API
| Method | Purpose |
|---|---|
MartenFixtures.load_all(dir) |
Glob **/*.yml under dir (defaults to configuration.fixtures_path) and load every file. |
MartenFixtures.load(path, …) |
Load specific files in the caller-supplied order. |
MartenFixtures.identify(label) |
Return the Int64 primary key the loader will assign to label. |
| `MartenFixtures.configure { | c |
| `MartenFixtures.register_helper(name) { | args, ctx |
include MartenFixtures::Accessors → fixture(Model, label) |
Spec-side sugar: returns the loaded row by label. Accepts String or Symbol. |
Fixture file syntax
Plain YAML
The default case — no template syntax, no helpers — is just a YAML mapping of labels to attribute hashes:
engineering:
name: Engineering
plan: enterprise
Foreign keys by label
A ManyToOne / OneToOne field accepts the label of a row in the related table (the loader looks up the pk in the global registry):
david:
name: David
account: engineering # accounts.yml's "engineering" row
The account: key is rewritten to account_id and populated with MartenFixtures.identify("engineering").
Polymorphic shorthand
For polymorphic fields, the value is "label (Type)". The short type name is matched against the field's compile-time to: list (exact match or ::ShortName suffix), and the loader emits both the _type and _id columns:
# leaves.yml
lead_welcome:
position: 1
leafable: welcome (Post) # → leafable_type="Post", leafable_id=identify("welcome")
Crinja templating
Any {{ … }} expression or {% … %} statement triggers a Crinja render pass before YAML parsing. Bracketed syntax differs from ERB but the structure maps 1:1 — see MIGRATION.md.
david:
name: David
email: david@example.com
password_digest: {{ bcrypt("password123") }}
Helpers registered via MartenFixtures.register_helper are exposed as Crinja functions inside templates.
CRC32 label IDs
MartenFixtures.identify(label) returns Digest::CRC32.checksum(label).to_i64 % (2**30 - 1) — the same value Ruby's Zlib.crc32(label) % (2**30 - 1) produces. Rails' ActiveRecord::FixtureSet uses the identical formula, so a fixture labelled david resolves to the same id in both Rails and Marten test suites. This makes it safe to port a Rails fixture file straight across: cross-fixture references and any hand-written foreign-key assertions still line up.
UUID primary keys are derived via UUID.v5 using the RFC 4122 OID namespace (6ba7b812-9dad-11d1-80b4-00c04fd430c8) — matching Rails 7.x's ActiveRecord::FixtureSet, which calls Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label). So a fixture labelled david on a UUID-pk model resolves to the same UUID string in both Rails and Marten test suites.
Postgres setup
Postgres is a first-class target. The Inserter toggles session_replication_role to disable FK checks during the bulk load and runs SELECT setval(pg_get_serial_sequence(…), MAX(id)) for every table touched, so app-allocated ids do not collide with sequence-allocated ones after the fixtures land.
To run the test suite against Postgres, add pg to your dev-dependencies and start a local Postgres instance, then:
PG_DB=marten_fixtures_test MARTEN_FIXTURES_BACKEND=postgres script/cr spec
The spec suite reads the backend choice from MARTEN_FIXTURES_BACKEND (default sqlite); PG_HOST, PG_PORT, PG_DB, PG_USER, PG_PASSWORD override the connection settings. The sequence-bump example in inserter_spec.cr is marked pending on SQLite and runs only when the Postgres backend is active.
For your own app, just declare the database as usual in config/settings/test.cr:
config.database do |db|
db.backend = :postgresql
db.host = "localhost"
db.name = "myapp_test"
db.user = ENV["USER"]
end
The loader picks up the active connection via Marten::DB::Connection.default.
Helpers (companion shards)
Companion shards can expose richer per-row computation by registering named helpers at require time. A helper receives the raw kwargs (as Hash(String, YAML::Any)) and a MartenFixtures::EvalContext carrying the fixtures-path root and the live label registry:
# marten-storages
MartenFixtures.register_helper("blob") do |args, ctx|
filename = args["filename"].as_s
{
"key" => Digest::SHA256.hexdigest(filename),
"byte_size" => File.size(ctx.fixtures_path / "files" / filename).to_s,
"content_type" => args["content_type"]?.try(&.as_s) || "application/octet-stream",
}
end
The returned Hash(String, String) can be merged into a fixture row by the companion shard's own machinery (e.g. a dedicated YAML pre-processor). See "Known limitations" below for the v0.2 return-type plan.
Known limitations / v0.2 candidates
- Helper return type. Helpers currently return
Hash(String, String)for row-expansion use cases. Calling a helper inside a Crinja{{ … }}expression needs the return value to be stringifiable — Crinja will not happily render aHash. The v0.2 plan is to split the API intoregister_row_helper(Hash return, expanded into the row) andregister_template_helper(String return, callable inline), or to widen the proc signature to allow either. - No HABTM /
many_to_manyjoin-table fixtures. Marten'smany_to_manydoesn't surface its join table as a first-class model; shipping a_join.ymlconvention would mean a non-trivial extension that the Writebook port (the driving use case) doesn't need yet. - No STI
type:column translation. Marten's polymorphic field is the delegated-type equivalent; STI is not a Marten pattern, so no fixture syntax exists for it. - No bundled transactional-fixtures lifecycle.
Marten::Spec.flush_databasesin abefore_eachworks; a properly bundled "load once, rollback per test" helper is deferred until the Writebook port exercises it. - MySQL is not supported. SQLite and Postgres only. Adding MySQL would mainly mean a third FK-disable branch in
Inserterplus a sequence-bump equivalent (AUTO_INCREMENTreset).
Development
shards install
script/cr spec/ # or `crystal spec`
To lint with ameba (the shard does not declare ameba as a dev-dep — borrow it from a sibling shard that does):
cd ../marten-turbo && \
crystal lib/ameba/bin/ameba.cr \
--config ../marten-fixtures/.ameba.yml \
../marten-fixtures/src/ ../marten-fixtures/spec/
License
MIT — see LICENSE.
marten-fixtures
- 0
- 0
- 0
- 0
- 3
- about 4 hours ago
- May 20, 2026
MIT License
Mon, 25 May 2026 15:10:38 GMT