Mind-Bending Factories

Josh Clayton

People often forget that FactoryBot1 is just using Ruby to instantiate objects. Most of the time, yes, you’ll use FactoryBot in conjunction with your favorite ORM to instantiate objects for testing Rails apps.

Want a simplistic stub?

FactoryBot.define do
  factory :tiny_stub, class: OpenStruct
end

# >> user = FactoryBot.build(:tiny_stub, admin?: true, name: 'John Doe')
# => #<OpenStruct admin?=true, name="John Doe">
# >> user.admin?
# => true
# >> user.name
# => "John Doe"

Let’s go a bit crazy: what about creating a factory that generates URLs? Writing out valid string URLs is a pain and copy/paste seems both problematic and error-prone. If I want to change the protocol to https:// or switch a subdomain, it gets rougher. Throw in ports and paths and you’re itching for a headache.

So, a factory for generating URLs. First thing’s first: what are the properties we need?

factory :url do
  protocol { 'http://' }
  host { 'www.example.com' }
  port { '80' }
end

That’s a start.

# >> FactoryBot.create(:url)
# NameError: uninitialized constant Url

Ah, forgot that we want this to be an instance of string.

factory :url, class: String do
  protocol { 'http://' }
  host { 'www.example.com' }
  port { '80' }
end

# >> FactoryBot.create(:url)
# NoMethodError: undefined method `protocol=' for "":String

Makes sense; let’s build this string manually.

factory :url, class: String do
  ignore do
    protocol { 'http://' }
    host { 'www.example.com' }
    port { '80' }
  end

  initialize_with { new("#{protocol}#{host}:#{port}") }
end

# >> FactoryBot.create(:url)
# NoMethodError: undefined method `save!' for "http://www.example.com:80":String

Let’s tell the factory to skip creation when we call create:

factory :url, class: String do
  skip_create

  ignore do
    protocol { 'http://' }
    host { 'www.example.com' }
    port { '80' }
  end

  initialize_with { new("#{protocol}#{host}:#{port}") }
end

# >> FactoryBot.create(:url)
# => "http://www.example.com:80"

This is looking better.

What don’t I like about this right now? I’d have to specify protocol manually if I want to make it secure:

# >> FactoryBot.create(:url, protocol: 'https://', port: nil)
# => "https://www.example.com:"

Yuck; it’s all sorts of broken. Let’s use a trait to make this more managable.

factory :url, class: String do
  skip_create

  ignore do
    protocol { 'http://' }
    host { 'www.example.com' }
    port { '80' }
  end

  trait :secure do
    protocol { 'https://' }
    port { nil }
  end

  initialize_with { new("#{protocol}#{[host, port].compact.join(':')}") }
end

# >> FactoryBot.create(:url, :secure)
# => "https://www.example.com"

Up next: subdomain and domain name!

factory :url, class: String do
  skip_create

  ignore do
    protocol { 'http://' }
    subdomain { 'www' }
    domain_name { 'example.com' }

    host { [subdomain, domain_name].compact.join('.') }
    port { '80' }
  end

  trait :secure do
    protocol { 'https://' }
    port { nil }
  end

  initialize_with { new("#{protocol}#{[host, port].compact.join(':')}") }
end

# >> FactoryBot.create(:url, subdomain: 'blog')
# => "http://blog.example.com:80"

Finally, path:

factory :url, class: String do
  skip_create

  ignore do
    protocol { 'http://' }
    subdomain { 'www' }
    domain_name { 'example.com' }

    host { [subdomain, domain_name].compact.join('.') }
    port { '80' }

    path { '/' }
  end

  trait :secure do
    protocol { 'https://' }
    port { nil }
  end

  initialize_with { new("#{protocol}#{[host, port].compact.join(':')}#{path}") }
end

# >> FactoryBot.create(:url, path: '/about/us')
# => "http://www.example.com:80/about/us"

So, what’s this give us?

# >> FactoryBot.create(:url)
# => "http://www.example.com:80/"
# >> FactoryBot.create(:url, :secure)
# => "https://www.example.com/"
# >> FactoryBot.create(:url, :secure, subdomain: 'blog', path:
# >> '/12345/great-post-title')
# => "https://blog.example.com/12345/great-post-title"
# >> FactoryBot.create(:url, domain_name: 'example.co.uk', port: '1234')
# => "http://www.example.co.uk:1234/"

For more fun(ctionality), we can add child factories:

factory :url, class: String do
  # ...

  factory :twitter do
    secure
    subdomain { nil }
    domain_name { 'twitter.com' }
  end

  factory :facebook do
    secure
    subdomain { nil }
    domain_name { 'facebook.com' }
  end

  factory :google_analytics do
    domain_name { 'google.com' }
    port { nil }
    path { '/analytics' }
  end
end

# >> FactoryBot.create(:twitter)
# => "https://twitter.com/"
# >> FactoryBot.create(:facebook)
# => "https://facebook.com/"
# >> FactoryBot.create(:google_analytics)
# => "http://www.google.com/analytics"

FactoryBot is really powerful. With the ability to specify the factory’s class, traits, initialize_with, skip_create, and dynamic attributes, we can build a flexible factory that can be used whenever you need URLs - all without having to build strings by hand.

What custom factories have you built?

Project name history can be found here.


  1. Looking for FactoryGirl? The library was renamed in 2017.