FactoryBot-inspired test data factory for Rust + sqlx
fabricate brings the ergonomics of Ruby's FactoryBot to Rust. Define factories once, then build in-memory structs, persist directly to Postgres via sqlx, or seed through your HTTP API — all with composable traits, auto-incrementing sequences, and field-level overrides.
- Why fabricate?
- Quick Start
- Core Concepts
- Persistence Modes
- Personas (Scenario Bundles)
- Defining Your Own Factory
- Built-in Factories Reference
- CLI Tool
- Architecture Overview
- Cargo Features
- Error Handling
- Contributing
- License
- Acknowledgments
Rust's test ecosystem has excellent assertion libraries, mocking tools, and property-based testing — but no standard answer for test data factories. Setting up realistic, interconnected test entities means writing dozens of builder functions by hand, managing foreign key relationships manually, and duplicating setup logic across test files.
fabricate solves this with:
- Declarative factories — define entity defaults once, reuse everywhere
- Composable traits — named modifications that stack (
"verified","premium","suspended") - Auto-incrementing sequences — unique emails, phones, names, and license plates out of the box
- Field-level overrides — override any field inline with
.set("email", json!("custom@test.com")) - Three persistence modes — build in-memory, persist to Postgres via sqlx, or seed via HTTP API
- Persona bundles — pre-composed multi-entity scenarios (rider + wallet + payment, booking flow, etc.)
- CLI tool — seed and reset test data from the command line
| Feature | Hand-written builders | fake / faker |
fabricate |
|---|---|---|---|
| Reusable entity definitions | Manual per-test | No structure | Factories with defaults |
| Named variants (verified, suspended) | If-else chains | N/A | Composable traits |
| Unique values (emails, phones) | Manual counters | Random (collisions) | Deterministic sequences |
| Database persistence | Custom per-entity | N/A | Built-in (sqlx + HTTP) |
| Multi-entity scenarios | Large setup blocks | N/A | Personas |
| Relationships / foreign keys | Manual wiring | N/A | Associations |
Add fabricate to your Cargo.toml:
[dev-dependencies]
fabricate = "0.1"
serde_json = "1"To use direct Postgres persistence (enabled by default):
[dev-dependencies]
fabricate = { version = "0.1", features = ["postgres"] }Build your first entity:
use fabricate::{FactoryBuilder, FactoryContext, Sequence};
use fabricate::ridemate::user::UserFactory;
use serde_json::json;
// Create a build-only context (no database, no HTTP)
let mut ctx = FactoryContext {
sequences: Sequence::new(),
pool: None,
http_client: None,
base_url: None,
test_key: "test-key".to_string(),
overrides: std::collections::HashMap::new(),
};
// Build a default user (in-memory only)
let user = FactoryBuilder::new(UserFactory::new())
.build(&mut ctx)
.unwrap();
assert!(user.email.starts_with("test_user_"));
assert_eq!(user.user_type, "passenger");
// Build a verified driver with a custom email
let driver = FactoryBuilder::new(UserFactory::new())
.with_trait("verified")
.with_trait("driver")
.set("email", json!("driver@example.com"))
.build(&mut ctx)
.unwrap();
assert_eq!(driver.email, "driver@example.com");
assert_eq!(driver.user_type, "driver");
assert!(driver.is_email_verified);
assert!(driver.is_phone_verified);fabricate has two core traits for defining factories:
Factory — the high-level async trait with build and create methods:
#[async_trait]
pub trait Factory: Send + Sync {
type Output: Send;
/// Build an in-memory instance (no persistence).
fn build(&self, ctx: &mut FactoryContext) -> Self::Output;
/// Persist to database and return the created record.
async fn create(&self, ctx: &mut FactoryContext) -> Result<Self::Output>;
/// Create multiple entities at once.
async fn create_list(&self, ctx: &mut FactoryContext, count: usize) -> Result<Vec<Self::Output>>;
}BuildableFactory<T> — the lower-level trait used by FactoryBuilder, giving you control over base construction, trait registries, field overrides, and persistence:
pub trait BuildableFactory<T>: Send + Sync {
fn build_base(&self, ctx: &mut FactoryContext) -> T;
fn trait_registry(&self) -> &TraitRegistry<T>;
fn apply_overrides(&self, entity: &mut T, overrides: &[(String, serde_json::Value)]);
async fn persist(&self, entity: T, ctx: &mut FactoryContext) -> Result<T>;
}Most factories implement BuildableFactory<T> and are used through FactoryBuilder.
FactoryBuilder provides the chainable API for constructing entities:
let user = FactoryBuilder::new(UserFactory::new())
.with_trait("verified") // Apply a named trait
.with_trait("premium") // Stack multiple traits
.set("email", json!("vip@example.com")) // Override a field
.build(&mut ctx) // Build in-memory
.unwrap();Methods:
| Method | Description |
|---|---|
FactoryBuilder::new(factory) |
Wrap a factory in the fluent builder |
.with_trait("name") |
Apply a named trait (composable, order matters) |
.set("field", json!(value)) |
Override a specific field value |
.build(&mut ctx) |
Build in-memory (synchronous, returns Result<T>) |
.create(&mut ctx).await |
Build + persist to database or HTTP API (async) |
Traits are named modifications that alter specific fields on an entity. They are composable — apply multiple traits and they stack in order, with later traits overriding earlier ones for the same fields.
// Single trait
let verified_user = FactoryBuilder::new(UserFactory::new())
.with_trait("verified")
.build(&mut ctx)
.unwrap();
// Stacked traits — "driver" overrides user_type set by defaults
let verified_driver = FactoryBuilder::new(UserFactory::new())
.with_trait("verified")
.with_trait("driver")
.build(&mut ctx)
.unwrap();
assert!(verified_driver.is_email_verified);
assert_eq!(verified_driver.user_type, "driver");To define a trait for your own factory, implement FactoryTrait<T>:
pub trait FactoryTrait<T>: Send + Sync {
fn name(&self) -> &str;
fn apply(&self, entity: &mut T);
}Every FactoryContext includes a Sequence generator that produces unique, deterministic values. Named sequences auto-increment independently starting from 1.
// Raw sequence counter
let n = ctx.sequence("invoice"); // 1, 2, 3, ...
// Built-in helpers
let email = ctx.email("rider"); // "test_rider_1@test.ridemate.app"
let phone = ctx.phone(); // "+15550000001"
let name = ctx.full_name(); // "Alice Smith"Built-in sequence helpers:
| Helper | Example Output | Pattern |
|---|---|---|
ctx.email("prefix") |
test_prefix_1@test.ridemate.app |
Unique per call |
ctx.phone() |
+15550000001 |
555 area code, zero-padded |
ctx.full_name() |
Alice Smith |
Cycles through 10 first/last names |
sequences.plate() |
AA0001 |
Letter pairs + zero-padded number |
ctx.sequence("name") |
1, 2, 3... |
Raw counter for any named sequence |
Override any field on any factory using .set() with a JSON value:
let user = FactoryBuilder::new(UserFactory::new())
.set("email", json!("custom@test.com"))
.set("full_name", json!("Custom Name"))
.build(&mut ctx)
.unwrap();
assert_eq!(user.email, "custom@test.com");
assert_eq!(user.full_name, "Custom Name");Overrides are applied after traits, so they always win. Each factory defines which fields are overridable in its apply_overrides implementation.
The simplest mode — build entities as plain Rust structs with no side effects:
let mut ctx = FactoryContext {
sequences: Sequence::new(),
pool: None,
http_client: None,
base_url: None,
test_key: "test-key".to_string(),
overrides: std::collections::HashMap::new(),
};
let user = FactoryBuilder::new(UserFactory::new())
.build(&mut ctx)
.unwrap();
// user is a TestUser struct — no DB, no networkPersist entities directly to your Postgres database using sqlx. Requires the postgres feature (enabled by default).
let pool = sqlx::PgPool::connect("postgres://localhost/myapp_test").await?;
let mut ctx = FactoryContext::database(pool);
let user = FactoryBuilder::new(UserFactory::new())
.with_trait("verified")
.create(&mut ctx) // INSERT INTO users ...
.await?;
// user.id is now a real database UUIDEach factory's persist method contains the actual SQL. For example, UserFactory executes:
INSERT INTO users (id, email, phone_number, password_hash, full_name, ...)
VALUES ($1, $2, $3, $4, $5, ...)
ON CONFLICT (email) DO UPDATE SET updated_at = $12
RETURNING idSeed data through your running backend's test API endpoints. Factories POST to /__test__/ routes with X-Test-Key authentication:
let mut ctx = FactoryContext::http("http://localhost:8080")
.with_test_key("my-secret-test-key");
let user = FactoryBuilder::new(UserFactory::new())
.with_trait("verified")
.create(&mut ctx) // POST http://localhost:8080/__test__/users
.await?;HTTP mode sends JSON payloads and expects JSON responses. The X-Test-Key header authenticates requests so your test endpoints can reject unauthorized access in production.
Personas compose multiple factories into realistic multi-entity scenarios. Instead of manually creating a user, wallet, payment method, driver profile, and ride, call a single persona method.
use fabricate::ridemate::personas::Personas;
let mut ctx = FactoryContext::http("http://localhost:8080");
// Create a rider with wallet and payment method (3 entities)
let rider = Personas::rider(&mut ctx).await?;
println!("{} <{}>", rider.full_name, rider.email);
// Create a complete booking scenario (7 entities)
let booking = Personas::rider_books_ride(&mut ctx).await?;
println!("Ride {} from {} to {}",
booking.ride_id,
booking.pickup_address,
booking.destination_address);| Persona | Entities Created | Description |
|---|---|---|
rider |
3 | Verified user + funded wallet + payment method |
driver |
3 | Verified driver user + verified driver profile + wallet |
driver_onboard |
2 | Unverified driver + unverified profile (needs setup) |
rider_books_ride |
7 | Rider (3) + driver (3) + accepted ride |
complete_ride |
7 | Rider + driver + completed ride with payment and ratings |
driver_posts_trip |
4 | Driver (3) + trip post (instant booking) |
seed_for_exploration |
55+ | 3 riders + 4 drivers + 2 bookings + 3 completed rides + 2 trip posts |
Use Personas::seed_via_api to seed specific scenarios by name:
let summary = Personas::seed_via_api(
&mut ctx,
&["rider", "rider", "driver", "rider-books-ride"],
).await?;
println!("Created {} entities", summary.total_entities_created);Available scenario names: rider, driver, driver-onboard, rider-books-ride, complete-ride, driver-posts-trip, full.
Here is a complete example defining a factory for an Article domain (outside the built-in Ridemate factories):
use chrono::{DateTime, Utc};
use fabricate::builder::BuildableFactory;
use fabricate::context::FactoryContext;
use fabricate::traits::{FactoryTrait, TraitRegistry};
use fabricate::Result;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// 1. Define your entity struct
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Article {
pub id: Uuid,
pub title: String,
pub body: String,
pub author_email: String,
pub status: String, // "draft", "published", "archived"
pub view_count: i64,
pub is_featured: bool,
pub published_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
// 2. Define the factory
pub struct ArticleFactory {
traits: TraitRegistry<Article>,
}
impl ArticleFactory {
pub fn new() -> Self {
let mut traits = TraitRegistry::new();
traits.register(Box::new(PublishedTrait));
traits.register(Box::new(FeaturedTrait));
traits.register(Box::new(ArchivedTrait));
Self { traits }
}
}
// 3. Implement BuildableFactory
impl BuildableFactory<Article> for ArticleFactory {
fn build_base(&self, ctx: &mut FactoryContext) -> Article {
let n = ctx.sequence("article");
let now = Utc::now();
Article {
id: Uuid::new_v4(),
title: format!("Test Article {n}"),
body: format!("This is the body of test article {n}."),
author_email: ctx.email("author"),
status: "draft".to_string(),
view_count: 0,
is_featured: false,
published_at: None,
created_at: now,
}
}
fn trait_registry(&self) -> &TraitRegistry<Article> {
&self.traits
}
fn apply_overrides(&self, entity: &mut Article, overrides: &[(String, serde_json::Value)]) {
for (field, value) in overrides {
match field.as_str() {
"title" => if let Some(v) = value.as_str() { entity.title = v.to_string(); },
"body" => if let Some(v) = value.as_str() { entity.body = v.to_string(); },
"status" => if let Some(v) = value.as_str() { entity.status = v.to_string(); },
_ => {}
}
}
}
async fn persist(&self, entity: Article, _ctx: &mut FactoryContext) -> Result<Article> {
// Add your sqlx INSERT or HTTP POST here
Ok(entity)
}
}
// 4. Define traits
struct PublishedTrait;
impl FactoryTrait<Article> for PublishedTrait {
fn name(&self) -> &str { "published" }
fn apply(&self, article: &mut Article) {
article.status = "published".to_string();
article.published_at = Some(Utc::now());
}
}
struct FeaturedTrait;
impl FactoryTrait<Article> for FeaturedTrait {
fn name(&self) -> &str { "featured" }
fn apply(&self, article: &mut Article) {
article.status = "published".to_string();
article.published_at = Some(Utc::now());
article.is_featured = true;
article.view_count = 10_000;
}
}
struct ArchivedTrait;
impl FactoryTrait<Article> for ArchivedTrait {
fn name(&self) -> &str { "archived" }
fn apply(&self, article: &mut Article) {
article.status = "archived".to_string();
}
}
// 5. Use it
use fabricate::FactoryBuilder;
use serde_json::json;
let mut ctx = /* your FactoryContext */;
let draft = FactoryBuilder::new(ArticleFactory::new())
.build(&mut ctx)
.unwrap();
assert_eq!(draft.status, "draft");
let featured = FactoryBuilder::new(ArticleFactory::new())
.with_trait("featured")
.set("title", json!("Breaking News"))
.build(&mut ctx)
.unwrap();
assert_eq!(featured.status, "published");
assert!(featured.is_featured);
assert_eq!(featured.title, "Breaking News");fabricate ships with 11 factories for the Ridemate ride-sharing domain as a reference implementation and for immediate use in Ridemate projects.
| Factory | Output Type | Available Traits |
|---|---|---|
UserFactory |
TestUser |
verified, unverified, suspended, premium, driver, admin |
DriverProfileFactory |
TestDriverProfile |
verified, unverified, high_rated, new_driver, available, offline |
RideFactory |
TestRide |
requested, accepted, in_progress, completed, cancelled, with_payment, with_ratings |
TripPostFactory |
TestTripPost |
instant_book, request_to_book, recurring, with_stops, full |
TripBookingFactory |
TestTripBooking |
pending, confirmed, completed, cancelled |
PaymentMethodFactory |
TestPaymentMethod |
visa, mastercard |
WalletFactory |
TestWallet |
funded, empty |
PaymentFactory |
TestPayment |
successful, failed, refunded, pending |
RatingFactory |
TestRating |
five_star, low_rating, with_review, driver_rating, passenger_rating |
SafetyIncidentFactory |
TestSafetyIncident |
panic_button, crash_detected, resolved |
SafetyContactFactory |
TestSafetyContact |
(none) |
All built-in factories are under fabricate::ridemate::* and support all three persistence modes.
fabricate includes a CLI binary for seeding and managing test data from the command line.
cargo install fabricateOr run directly from the repository:
cargo run --# Seed everything (full exploration dataset)
fabricate seed --target http://localhost:8080
# Seed specific scenarios
fabricate seed --target http://localhost:8080 --scenarios rider,driver,rider-books-ride
# Seed specific personas
fabricate seed --target http://localhost:8080 --personas rider,driver
# Use a custom test API key
fabricate seed --target http://localhost:8080 --test-key my-secret-key# Reset everything
fabricate reset --target http://localhost:8080
# Reset specific scope
fabricate reset --target http://localhost:8080 --scope usersfabricate listOutput:
fabricate: Available Factories & Personas
FACTORIES:
UserFactory
Traits: verified, unverified, suspended, premium, driver, admin
DriverProfileFactory
Traits: verified, unverified, high_rated, new_driver, available, offline
RideFactory
Traits: requested, accepted, in_progress, completed, cancelled, with_payment, with_ratings
...
PERSONAS (scenario bundles):
rider - User + wallet + payment method
driver - User (driver) + driver profile + wallet
driver-onboard - Driver (unverified, needs setup)
rider-books-ride - Rider + driver + accepted ride
complete-ride - Rider + driver + completed ride + payment + ratings
driver-posts-trip - Driver + trip post (carpooling)
full - All of the above (comprehensive exploration data)
┌─────────────────────┐
│ FactoryBuilder │ Fluent API: .with_trait() .set() .build() .create()
└──────────┬──────────┘
│ uses
┌──────────▼──────────┐
│ BuildableFactory<T> │ build_base() + trait_registry() + apply_overrides() + persist()
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────────▼───────┐ ┌─────▼──────┐ ┌───────▼────────┐
│ TraitRegistry │ │ Sequence │ │ FactoryContext │
│ FactoryTrait<T> │ │ (counters) │ │ (pool/http/ │
│ (named mods) │ │ │ │ overrides) │
└─────────────────┘ └────────────┘ └───────┬────────┘
│ persistence via
┌────────────┼────────────┐
│ │ │
┌────▼───┐ ┌─────▼────┐ ┌───▼──┐
│ sqlx │ │ reqwest │ │ None │
│ PgPool │ │ HTTP API │ │ (mem)│
└────────┘ └──────────┘ └──────┘
| Type | Role |
|---|---|
FactoryBuilder<F, T> |
Fluent builder that chains traits, overrides, and persistence |
BuildableFactory<T> |
Trait defining how to build, customize, and persist an entity |
Factory |
Higher-level trait with build, create, create_list |
FactoryContext |
Session context holding DB pool, HTTP client, sequences, and overrides |
Sequence |
Named auto-incrementing counters with helpers for emails, phones, names, plates |
TraitRegistry<T> |
Registry of named FactoryTrait<T> implementations for an entity |
FactoryTrait<T> |
A named modification (e.g., "verified") that mutates an entity |
Association |
Describes a foreign key dependency between factories |
Personas |
Pre-composed multi-entity scenario bundles |
| Feature | Default | Description |
|---|---|---|
postgres |
Yes | Enables direct Postgres persistence via sqlx (FactoryContext::database()) |
To disable the default postgres feature (HTTP-only or build-only usage):
[dev-dependencies]
fabricate = { version = "0.1", default-features = false }All fallible operations return fabricate::Result<T>, which is std::result::Result<T, fabricate::Error>.
| Variant | Description |
|---|---|
Error::Build(String) |
Factory construction failed (missing config, invalid state) |
Error::Association(String) |
Associated entity could not be resolved |
Error::TraitNotFound(String) |
Requested trait is not registered on the factory |
Error::Sequence(String) |
Sequence generation error |
Error::Database(sqlx::Error) |
Database operation failed (postgres feature only) |
Error::Http(reqwest::Error) |
HTTP request to test API failed |
Error::Json(serde_json::Error) |
JSON serialization/deserialization error |
Error::Persona(String) |
Unknown persona or scenario name |
Errors include helpful context. For example, requesting a non-existent trait:
Trait 'nonexistent_trait' not registered. Available: ["verified", "unverified", "suspended", "premium", "driver", "admin"]
- Fork the repository
- Create a feature branch (
git checkout -b my-feature) - Make your changes
- Run tests and checks:
cargo test cargo fmt -- --check cargo clippy -- -D warnings - Commit with a descriptive message
- Open a Pull Request
MIT License. See LICENSE for details.
fabricate is inspired by FactoryBot by thoughtbot, the gold standard for test data factories in the Ruby ecosystem. This project aims to bring the same developer experience to Rust.