Factories: Beyond ActiveRecord

Unlocking 10x productivity with factories

The humble Factory object

  • Not a FactoryBot gem concept
  • Not a Ruby on Rails concept
  • Not a Ruby concept
  • Dates back to the 80s and Smalltalk
  • Made popular in the Design Patterns (GoF) book

FactoryBot misconceptions

  • FactoryBot is only for creating ActiveRecord records
  • FactoryBot records should be saved to the database

Building Ruby classes with FactoryBot

class Product
  attr_reader :name, :price, :id

  def initialize(id:, name:, price:)
    @id = id
    @name = name.to_s
    @price = price.to_money
  end
end
FactoryBot.define do
  factory :product, class: 'Product' do
    skip_create

    id    { Random.random_number }
    name  { 'iPod' }
    price { '$299.99' }
    
    initialize_with { new(id: id, name: name, price: price) }
  end
end

Testing

test '#price returns a Money object' do
  product = build(:product)
  assert_instance_of Money, product.price 
end

Benefits

  • Cleaner tests
  • Consistent object construction

Building External API Payloads / Responses In Tests

Problem

Static JSON fixtures rot quickly and don’t allow easy variation

Solution

Use factories to generate realistic data objects.

Factory methods tend to become large and difficult to grok

The problem

  test '#call makes a request' do
    stub_api_request(foo: 'bar', sometimes: false, maybe: 'no')
  end

  test '#call makes a request with fizz param' # TODO

  private 

  def stub_api_request(foo: 'bar', sometimes: false, maybe: 'no', otherwise: 'yes')
    {
      # ...
    }
  end

Building External API Payloads (Hash/JSON)

factory :api_product_payload, class: Hash do
  skip_create

  transient do
    product factory: :product
  end
    
  id        { product.id }
  name      { product.name }
  price     { product.price }
  timestamp { Time.now.iso8601 }

  trait :pending do
    status { "pending"}
  end
  
  initialize_with { attributes.except(:product).to_json }
end

More complex example

factory :api_product_response, 
  aliases: [:api_v1_product_response], 
  class: Hash do
  skip_create

  transient do
    product factory: :product
  end

  # Core JSON:API top-level keys
  initialize_with do
    {
      data: {
        id: product.id.to_s,
        type: 'product',
        attributes: {
          name: product.name,
          price: product.price,
          timestamp: Time.now.iso8601,
        },
        relationships: {},
      },
      meta: {},
    }.to_json
  end
end

Putting it all together

test '.create sends a POST to the /products endpoint with the payload' do
  product = build(:product)
  request_body = build(:api_product_payload, product: product)
  response_body = build(:api_product_response, product: product)
  
  stub_request(:post, "/products")
    .with(body: request_body)
    .to_return_json(body: response_body, status: 201)
  
  described_class.create(product)
end

Generating common data types

FactoryBot.define do
  factory(:PAN, aliases: [:card_number], class: 'String') do
    skip_create

    transient do
      starting_number   { '4' }
      remaining_numbers { ('0'..'9').to_a.sample(15).join }
    end

    trait :visa # same as default
    trait :mastercard do
      starting_number { '5' }
    end

    initialize_with { "#{starting_number}#{remaining_numbers}" }
  end
end

Generating value objects

class CleoBank::Transaction  
  def message_type
    raw_transaction['MessageType']
  end
  
  def authorization?
    message_type[1] == "1"
  end
end

Generating value objects

class MessageType
  def initialize(mti)
    @mti = mti.to_s
  end

  def version
    @mti[0]
  end

  def message_class
    @mti[1]
  end

  def message_function
    @mti[2]
  end

  def message_origin
    @mti[3]
  end
  
  def authorization?
    message_class == '0'
  end
end

Generating value objects

class CleoBank::Transaction  
  def message_type
    MessageType.new(raw_transaction['MessageType'])
  end
  
  delegate :authorization?, to: :message_type
end
FactoryBot.define do
  factory(:ISO_8583_message_type, aliases: [:mti], class: 'MessageType') do
    skip_create

    version { '0' }
    message_class { '2' }
    message_function { '2' }
    message_origin { '0' }

    trait :authorization do
      message_class { '0' }
    end
    # ...

    initialize_with do
      new([
        version, 
        message_class, 
        message_function, 
        message_origin].join
      )
    end
  end
end

In Summary

FactoryBot can be used to build any Ruby object

  1. Hashes, Arrays, Strings, etc.
  2. Classes (POROs)
  3. Value objects with many permutations
  4. Complex collections of stuff (not shown)

Why it matters

  • Clearer intent
  • Cleaner tests (Less duplication)
  • More resilient tests
  • Stronger domain modelling

Further reading

Factory bot book

https://thoughtbot.github.io/factory_bot/

Factories: Beyond ActiveRecord

By Gavin Morrice

Factories: Beyond ActiveRecord

  • 6