Scenario-Based Test Data Fixtures

FakeDataDSL Scenarios let you define complex, interconnected test data with named references, lazy evaluation, and inheritance. Think of it as "Fixtures 2.0" that combines the best of Rails fixtures and Factory Bot.

Quick Start

# Define a scenario
FakeDataDSL::Scenarios.define(:e_commerce_checkout) do
  # Define entities with 'let'
  let(:buyer) { User.generate(role: "customer", verified: true) }
  let(:seller) { User.generate(role: "merchant") }
  let(:product) { Product.generate(seller: seller, price: 99.99) }
  let(:cart) { ShoppingCart.generate(user: buyer, items: [product]) }
  let(:order) { Order.generate(user: buyer, cart: cart, status: "pending") }
end

# Use in tests
RSpec.describe "Checkout" do
  include_scenario :e_commerce_checkout

  it "completes the order" do
    expect(order.status).to eq("pending")
    order.complete!
    expect(order.status).to eq("completed")
  end
end

Defining Scenarios

Basic Scenario

FakeDataDSL::Scenarios.define(:basic_user) do
  let(:user) { User.generate }
  let(:profile) { Profile.generate(user: user) }
end

With Schema References

FakeDataDSL::Scenarios.define(:blog_post) do
  # Reference other scenarios
  include_scenario :basic_user

  let(:category) { Category.generate(name: "Technology") }
  let(:post) { 
    Post.generate(
      author: user,    # From included scenario
      category: category,
      published: true
    )
  }
  let(:comments) {
    3.times.map { Comment.generate(post: post, author: User.generate) }
  }
end

DSL File Syntax

You can also define scenarios in .dsl files:

# db/schemas/scenarios/e_commerce.dsl
@scenario "e_commerce_checkout"
  Users:
    buyer: User(role: "customer", verified: true)
    seller: User(role: "merchant")

  Products:
    laptop: Product(seller: @seller, price: 999.99, stock: 10)
    phone: Product(seller: @seller, price: 499.99, stock: 5)

  Cart:
    cart: ShoppingCart(user: @buyer)
      items:
        - CartItem(product: @laptop, quantity: 1)
        - CartItem(product: @phone, quantity: 2)

  Order:
    pending_order: Order(user: @buyer, status: "pending")
      from_cart: @cart

Using Scenarios

RSpec Integration

# spec/rails_helper.rb
require 'fake_data_dsl/scenarios'

RSpec.configure do |config|
  config.include FakeDataDSL::Scenarios::RSpecHelpers
end
# spec/features/checkout_spec.rb
RSpec.describe "Checkout", type: :feature do
  include_scenario :e_commerce_checkout

  it "shows cart total" do
    visit cart_path(cart)
    expect(page).to have_content("$1,499.97")  # laptop + 2 phones
  end

  it "processes payment" do
     buyer
    visit checkout_path(order)
    click_button "Pay Now"
    expect(order.reload.status).to eq("paid")
  end
end

Minitest Integration

# test/test_helper.rb
require 'fake_data_dsl/scenarios'

class ActiveSupport::TestCase
  include FakeDataDSL::Scenarios::MinitestHelpers
end
# test/integration/checkout_test.rb
class CheckoutTest < ActionDispatch::IntegrationTest
  include_scenario :e_commerce_checkout

  test "completes checkout" do
    post checkout_path(order), params: { payment_method: "credit_card" }
    assert_equal "completed", order.reload.status
  end
end

Lazy Evaluation

Scenario entities are lazily evaluated - they're only created when accessed:

FakeDataDSL::Scenarios.define(:expensive_setup) do
  let(:user) { User.generate }  # Not created yet
  let(:large_dataset) {
    1000.times.map { Record.generate(user: user) }
  }  # Not created yet
end

RSpec.describe "Performance" do
  include_scenario :expensive_setup

  it "accesses user" do
    expect(user).to be_present  # NOW user is created
    # large_dataset is never created in this test
  end

  it "accesses dataset" do
    expect(large_dataset.count).to eq(1000)  # NOW both are created
  end
end

Entity References

Forward References

FakeDataDSL::Scenarios.define(:circular) do
  let(:team) { Team.generate(lead: lead) }  # References 'lead' defined below
  let(:lead) { User.generate(team: team) }  # References 'team' defined above
end

Cross-Scenario References

FakeDataDSL::Scenarios.define(:users) do
  let(:admin) { User.generate(role: "admin") }
  let(:regular) { User.generate(role: "user") }
end

FakeDataDSL::Scenarios.define(:permissions) do
  include_scenario :users

  let(:admin_permissions) { Permission.generate(user: admin, level: "full") }
  let(:user_permissions) { Permission.generate(user: regular, level: "read") }
end

Scenario Inheritance

Extending Scenarios

FakeDataDSL::Scenarios.define(:base_user) do
  let(:user) { User.generate }
  let(:profile) { Profile.generate(user: user) }
end

FakeDataDSL::Scenarios.define(:admin_user, extends: :base_user) do
  # Override user definition
  let(:user) { User.generate(role: "admin", permissions: ["all"]) }

  # Add new entities
  let(:audit_log) { AuditLog.generate(admin: user) }
end

Multiple Inheritance

FakeDataDSL::Scenarios.define(:full_checkout, 
  extends: [:e_commerce_checkout, :payment_setup, :shipping_setup]
) do
  let(:complete_order) {
    Order.generate(
      user: buyer,
      cart: cart,
      payment: payment_method,
      shipping: shipping_address,
      status: "complete"
    )
  }
end

Context and State

Shared Context

FakeDataDSL::Scenarios.define(:multi_tenant) do
  let(:tenant) { Tenant.generate }

  # All entities share the tenant context
  with_context(tenant_id: -> { tenant.id }) do
    let(:user) { User.generate }  # Automatically gets tenant_id
    let(:product) { Product.generate }  # Automatically gets tenant_id
    let(:order) { Order.generate(user: user) }  # Automatically gets tenant_id
  end
end

Conditional Entities

FakeDataDSL::Scenarios.define(:feature_flags) do
  let(:user) { User.generate }

  let(:premium_feature) {
    if user.subscription == "premium"
      PremiumFeature.generate(user: user)
    end
  }

  let(:beta_feature) {
    BetaFeature.generate(user: user) if ENV["ENABLE_BETA"]
  }
end

Parameterized Scenarios

With Arguments

FakeDataDSL::Scenarios.define(:order_with_items) do |count: 3, status: "pending"|
  let(:user) { User.generate }
  let(:items) { count.times.map { OrderItem.generate } }
  let(:order) { Order.generate(user: user, items: items, status: status) }
end

# Usage
RSpec.describe "Orders" do
  include_scenario :order_with_items, count: 5, status: "shipped"

  it "has correct items" do
    expect(order.items.count).to eq(5)
    expect(order.status).to eq("shipped")
  end
end

Dynamic Scenarios

FakeDataDSL::Scenarios.define(:scaled_data) do |scale: 1|
  let(:users) { (10 * scale).times.map { User.generate } }
  let(:products) { (100 * scale).times.map { Product.generate } }
  let(:orders) { (50 * scale).times.map { Order.generate } }
end

# Small dataset for unit tests
include_scenario :scaled_data, scale: 1

# Large dataset for load tests
include_scenario :scaled_data, scale: 100

Scenario Variants

Named Variants

FakeDataDSL::Scenarios.define(:user_states) do
  variant(:active) do
    let(:user) { User.generate(active: true, verified: true) }
  end

  variant(:pending) do
    let(:user) { User.generate(active: false, verified: false) }
  end

  variant(:suspended) do
    let(:user) { User.generate(active: false, suspended_at: Time.current) }
  end
end

# Usage
include_scenario :user_states, variant: :suspended

Mode-Based Variants

FakeDataDSL::Scenarios.define(:edge_cases) do
  variant(:happy_path, mode: :random) do
    let(:user) { User.generate }
  end

  variant(:edge_cases, mode: :edge) do
    let(:user) { User.generate }  # Will use edge case values
  end

  variant(:hostile, mode: :hostile) do
    let(:user) { User.generate }  # Will use hostile values
  end
end

Hooks

Setup and Teardown

FakeDataDSL::Scenarios.define(:with_hooks) do
  before do
    Rails.cache.clear
    Sidekiq::Testing.inline!
  end

  after do
    Sidekiq::Testing.fake!
  end

  let(:user) { User.generate }
end

Entity Callbacks

FakeDataDSL::Scenarios.define(:with_callbacks) do
  let(:user) {
    User.generate.tap do |u|
      u.confirm!  # Run after creation
      u.update!(last_login: Time.current)
    end
  }
end

Database Integration

Creating Records

FakeDataDSL::Scenarios.define(:persisted) do
  # create() persists to database
  let(:user) { create(:user) }
  let(:order) { create(:order, user: user) }
end

FakeDataDSL::Scenarios.define(:in_memory) do
  # build() stays in memory
  let(:user) { build(:user) }
  let(:order) { build(:order, user: user) }
end

Transactional Scenarios

FakeDataDSL::Scenarios.define(:transactional) do
  transactional!  # Wrap in transaction, rollback after test

  let(:user) { create(:user) }
  let(:order) { create(:order) }
end

Debugging Scenarios

Inspect Scenario

scenario = FakeDataDSL::Scenarios.find(:e_commerce_checkout)
scenario.entities  # => [:buyer, :seller, :product, :cart, :order]
scenario.dependencies  # => { order: [:buyer, :cart], cart: [:buyer, :product], ... }

Visualization

# Generate dependency graph
FakeDataDSL::Scenarios.find(:e_commerce_checkout).to_dot
# => "digraph { buyer -> cart; cart -> order; ... }"

Logging

FakeDataDSL::Scenarios.configure do |config|
  config.logger = Rails.logger
  config.log_level = :debug
end

# Now see:
# [FakeDataDSL::Scenarios] Creating :user
# [FakeDataDSL::Scenarios] Creating :order (depends on :user, :cart)

API Reference

Scenarios.define

FakeDataDSL::Scenarios.define(name, options = {}, &block)

Parameters:

  • name - Symbol name for the scenario
  • options[:extends] - Parent scenario(s) to inherit from
  • block - Scenario definition block

Scenario#let

let(name, &block)

Defines a lazy-evaluated entity.

Scenario#create

create(schema_name, overrides = {})

Creates and persists a record using the schema.

Scenario#build

build(schema_name, overrides = {})

Builds a record in memory (not persisted).

include_scenario

include_scenario(name, **options)

Includes a scenario in a test, making all entities available.

Best Practices

1. Keep Scenarios Focused

# Good: Single purpose
FakeDataDSL::Scenarios.define(:checkout_ready) do
  let(:buyer) { create(:user, :verified) }
  let(:cart) { create(:cart, user: buyer, items: [product]) }
end

# Bad: Too much unrelated data
FakeDataDSL::Scenarios.define(:everything) do
  let(:user) { ... }
  let(:admin) { ... }
  let(:product) { ... }
  let(:report) { ... }
  # ...50 more entities
end

2. Use Inheritance for Variations

FakeDataDSL::Scenarios.define(:base_order) do
  let(:user) { create(:user) }
  let(:order) { create(:order, user: user) }
end

FakeDataDSL::Scenarios.define(:pending_order, extends: :base_order) do
  let(:order) { create(:order, user: user, status: "pending") }
end

FakeDataDSL::Scenarios.define(:shipped_order, extends: :base_order) do
  let(:order) { create(:order, user: user, status: "shipped") }
end

3. Document Dependencies

FakeDataDSL::Scenarios.define(:complex_workflow) do
  # This scenario sets up a complete order fulfillment workflow
  # Dependencies:
  #   - buyer: Customer placing the order
  #   - seller: Merchant fulfilling the order
  #   - product: Item being purchased
  #   - order: The order connecting buyer and seller
  #   - shipment: Delivery tracking

  let(:buyer) { ... }
  let(:seller) { ... }
  # ...
end

Troubleshooting

Circular Dependencies

# Error: Circular dependency detected: user -> team -> user

# Fix: Break the cycle with nil defaults
FakeDataDSL::Scenarios.define(:team_setup) do
  let(:team) { create(:team, lead: nil) }
  let(:user) { create(:user, team: team) }

  after do
    team.update!(lead: user)
  end
end

Entity Not Found

# Error: Unknown entity: :admin

# Fix: Check includes
FakeDataDSL::Scenarios.define(:needs_admin) do
  include_scenario :users  # Make sure this defines :admin

  let(:action) { AdminAction.create(admin: admin) }
end

See Also