May 15, 2015

Arranging Complex Factories (for fun and profit)

FactoryGirl the oddly named testing tool for database models is something we use a lot at Wizard Development. If you're unfamiliar with how it works, I strongly suggest you check it out. Unlike fixtures which loads a bunch of data into your database, factories will create the objects you need with the data preloaded. The difference allows you to only get what you need, allows skipping using the database all together (for much faster and more isolated tests) and a few other great things.

Individual models are fairly straightforward to test. I'll be using examples with rspec and ActiveRecord.

# Model
class User < ActiveRecord::Base
  scope :admins, -> { where(admins: true) }
  def admin!
    update!(admin: true)
  end
end
# Factory
FactoryGirl.define do
  factory :user do
    sequence(:name) { |n| "#{Faker::Name.name} #{n}" }
    sequence(:email) { |n| "#{n}#{Faker::Internet.email}" }
    admin false

    trait :as_admin do
      admin true
    end
  end
end
# Test
require 'rails_helper'

describe User do
  let(:user) { build_stubbed(:user) }
  let(:admin) { build_stubbed(:user, :as_admin) }

  describe '#admin!' do
    it 'makes a user an admin' do
      user.admin!
      expect(user.admin?).to eq(true)
    end
    it 'keeps admins in power' do
      admin.admin!
      expect(admin.admin?).to eq(true)
    end
  end

  describe '.admins' do
    it 'returns only the admins' do
      admin = create(:admin)
      create(:user)
      expect(User.admins).to contain_exactly(admin)
    end
  end
end

For the model's instance functions we used FactoryGirl.build_stubbed a method that creates a model that pretends it's saved to the database. All validations, and database methods will pretend to work as expected and we'll be sure to never actually talk to the database, which is significantly faster.

For the scope we have to talk to the database so we create both models and ensure the result only has the admin object. Since only those two objects are needed, that's all we make.

As your app grows you'll start needing to test service objects that work with several models at once, and your models themselves will get more complected requiring each other to be in specific states to be valid. It's going to get difficult to have a single factory properly setup the environment for testing. (If you find it impossible to use factories you need to have a long hard look at your design because it wont ever get easier on it's own.)

A common situation is when you want "Multitenancy" where your app needs to support users having their own objects. This is very straightforward to support with factories, at first.

class User < ActiveRecord::Base
  has_one :photo
end

class Photo < ActiveRecord::Base
  validates :user, presence: true
end

FactoryGirl.define do
  factory :user do
    sequence(:name) { |n| "#{Faker::Name.name} #{n}" }
    trait :with_photo do
      photo
    end
  end

  factory :photo do
    title "My cat Kris"
  end
end

# to build a stubbed user with a stubbed photo
FactoryGirl.build_stubbed(:user, :with_photo)

Now you'll probably want to support a user with many photos. FactoryGirl suggessts using the after and before callbacks for creating the associations. Lets try it

class User < ActiveRecord::Base
  has_many :photos
end

class Photo < ActiveRecord::Base
end

FactoryGirl.define do
  factory :user do
    sequence(:name) { |n| "#{Faker::Name.name} #{n}" }
    trait :with_photos do
      transient do
        photo_count 2
      end

      after(:create) do |user, evaluator|
        create_list(:photo, evaluator.photo_count, user: user)
      end
    end
  end

  factory :photo do
    title "My cat Kris"
  end
end

# to create a user with 2 photos
FactoryGirl.create(:user, :with_photos)

# When we build_stubbed or build or any of the other methods, we no longer have any photos!
FactoryGirl.build(:user, :with_photo) # no photos!
FactoryGirl.build_stubbed(:user, :with_photos) # no photos!

Since this approach only works with specific methods, you'll either need to write callbacks for each method or do some magic. I'll rewrite the factory with some magic.

FactoryGirl.define do
  factory :user do
    sequence(:name) { |n| "#{Faker::Name.name} #{n}" }
    trait :with_photos do
      transient do
        photo_count 2
      end

      photos do |t|
        photo_count.times.map {
          t.association(:photo, user: t.instance_variable_get(:@instance))
       }
     end
    end
  end

  factory :photo do
    title "My cat Kris"
  end
end

# Now however we want our test data we'll get what we expect!
FactoryGirl.create(:user, :with_photos)
FactoryGirl.build(:user, :with_photo)
FactoryGirl.build_stubbed(:user, :with_photos)

The t that's passed into the block on photos, I think this is called an evaluator internal to FactoryGirl, but I'm not positive. Names are hard. We're able to use the t.association to mimic however we called the parent factory. When we are building a factory it uses build() when we're creating it uses create(). Yay!

I know t.instance_variable_get(:@instance) looks very strange but there doesn't seem to be another way to get a reference to the parent object to give to the child object. Not all children need their parents, but when they do you need to provide them.

We should also note that we're using a transient attribute to allow us to customize how many photos get created.

# if we want a ton of photos
FactoryGirl.create(:user, :with_photos, photo_count: 400)

Lets go for an even more complex example.

class User < ActiveRecord::Base
  has_many :photos
  has_one :album
end

class Album < ActiveRecord::Base
  has_many :photos
  validates :user, presence: true
end

class Photo < ActiveRecord::Base
  validates :user, presence: true
end

Photos still belong to users but can now also belong to an album that belongs to a user. Lets also ensure there's always a user for these objects.

A factory setup could be

FactoryGirl.define do
  factory :user do
    sequence(:name) { |n| "#{Faker::Name.name} #{n}" }
    trait :with_album do
      album
    end
  end

  factory :photo do
    title "My cat Kris"
    user
  end

  factory :album do
    user
    title "Kitties"
    transient { photo_count 2 }
    photos do |t|
      photo_count.times.map { t.association(:photo) }
    end
  end
end

Lets try this out

user = FactoryGirl.create(:user, :with_album)
user.album.photos.count # 2
user.photos.count # 0 !?!?!?!
User.count # 4 !!!!!

We have a user with an album of other users photos! That's not what we wanted.

The photo factory was creating it's own users for it's photos since we didn't specify who should own them. Additionally the album factory created a user and then got assigned to the one we created. Lets try again.

FactoryGirl.define do
  factory :user do
    sequence(:name) { |n| "#{Faker::Name.name} #{n}" }
    trait :with_album do
      album { t.association(:album, :with_photos, user: t.instance_variable_get(:@instance))
    end
  end

  factory :photo do
    title "My cat Kris"
    user
  end

  factory :album do
    user
    title "Kitties"
    trait :with_photos do
      transient { photo_count 2 }
      photos do |t|
        photo_count.times.map { t.association(:photo, user: user) }
     end
    end
  end
end

The user now gives itself to the album, the album now gives it's user to the photos and we always get what we expect. We can create any factory and get a user who owns the photos that were generated and never get more users than we expect.

I think this is too complicated. I'm convinced there are easier ways to do the advanced examples in this blog post. When I find them I'll happily update this post and a bunch of my factory code. In the meantime I'll live with slightly complicated factories and enjoy easier testing.

Let me leave you with a with a small spec we include with most projects. It ensures that every factory and trait is valid and can be stubbed. And helps you keep all factories usable with expected results. FactoryGirl.lint has some unexpected creation of models and doesn't cleanup after itself. If you're using Foreign Key Constraints you'll get an added bonus of errors when you accidently create models related to stubbed models.

require 'rails_helper'

FactoryGirl.factories.map(&:name).each do |factory_name|
  describe "#{factory_name} factory" do
    it 'builds valid' do
      model = FactoryGirl.build(factory_name)
      expect(model).to be_valid if model.respond_to?(:valid?)
    end

    it 'builds stubbed' do
      model = FactoryGirl.build_stubbed(factory_name)
      expect(model).to be_valid if model.respond_to?(:valid?)
    end
  end
end
Roborooter.com © 2024
Powered by ⚡️ and 🤖.