FactoryGirl is one of my favorite testing tools. This is one of the first tools I choose when working outside of
Ruby frameworks.
Recently I have been working on Rails projects that do not use databases.
and therefore does not use
ORM such as
ActiveRecord . Instead, the
JSON API is used and converted to values for ordinary Ruby objects.
Initially, in these projects, the values for unit tests were written manually for each object. But in the future, adding values has become tedious; values for previous objects also had to be rewritten time after time.
But luckily,
FactoryGirl can be used in regular
Ruby objects,
with all the useful features.
')
A small introduction:
Note: The examples use single-line classes and Factory for short.Let's start with a simple
Factory for a regular
Ruby object.
it "supports building a PORO object" do class User attr_accessor :name end FactoryGirl.define do factory :user do name "Amy" end end user = FactoryGirl.build(:user) expect(user.name).to eq "Amy" end
Notable here is the use of the
build strategy to create an object. With the create strategy, we get an error:
NoMethodError: undefined method `save!' for #<User:0x007ff042882a90 @name="Amy">
As it is clear from the description of the error: it appears because - we do not have the #save method! .. If we used
ORM like
ActiveRecord, then when inheriting from
ActiveRecord :: Persistence it (this method) would be implemented.
Immutability
Now let's add something to our
User model, namely:
- Let's make it unchangeable by replacing attr_accessor with attr_reader .
- Let's make a class object with a constructor — filled with hash that came from JSON .
By implementing this, we get something like:
class User attr_reader :name def initialize(data = {}) @name = data[:name] end end
And when you call without changes in the
Factory we get:
NoMethodError: undefined method `name=' for #<User:0x007fec9a9f3d08 @name=nil>
This happened because, by default,
FactoryGirl uses the
#new method to initialize an object, and then assigns values to an object attribute. This can be overridden using the method
initialize_with method, in the description of the
Factory :
t "supports custom initialization" do class User attr_reader :name def initialize(data) @name = data[:name] end end FactoryGirl.define do factory :user do name "Amy" initialize_with { new(attributes) } end end user = FactoryGirl.build(:user) expect(user.name).to eq "Amy" end
Recognition of nested resources
Let's imagine some JSON object with nested resources, for example:
{ "name": "Bob", "location": { "city": "New York" } }
Let's add a class description for our nested
Location object:
class Location attr_reader :city def initialize(data) @city = data[:city] end end class User attr_reader :name, :location def initialize(data) @name = data[:name] @location = Location.new(data[:location]) end end
Now we need to add one more thing to our
User Factory :
Location :
it "supports constructing nested models" do class Location attr_reader :city def initialize(data) @city = data[:city] end end class User attr_reader :name, :location def initialize(data) @name = data[:name] @location = Location.new(data[:location]) end end FactoryGirl.define do factory :location do city "London" initialize_with { new(attributes) } end factory :user do name "Amy" location { attributes_for(:location) } initialize_with { new(attributes) } end end user = FactoryGirl.build(:user) expect(user.name).to eq "Amy" expect(user.location.city).to eq "London" end
And now - let's check what happened:
puts FactoryGirl.attributes_for(:user)
The structure that simulates a nested object is passed to the hash in the
initialize method of the
User class in our
initialize_with block.
Time to lint
The last part of using
FactoryGirl with clean
Ruby objects. Usually,
Linting is called before testing begins, in order to avoid heaps of errors in a
factory that is not properly described.
After using the FactoryGirl.lint method of the Rake task, we get the following:
FactoryGirl::InvalidFactoryError: The following factories are invalid: * user - undefined method `save!' for #<User:0x007fc890ae0e88 @name="Amy"> (NoMethodError)
#Save method not found
! because the out of the box
#lint method uses a strategy with creating and saving an object. In order to change this - we have 2 options:
Option One:
#skip_create MethodLet's add the
#skip_create method to our
Factory description:
FactoryGirl.define do factory :location do city "London" skip_create initialize_with { new(attributes) } end factory :user do name "Amy" location { attributes_for(:location) } skip_create initialize_with { new(attributes) } end end
Now our
Factory will earn. The
#skip_create method also allows you to call the
create method in tests:
FactoryGirl.create(:user)
Option Two: Lint by building
We can add a
rake task to check our factories:
namespace :factory_girl do desc "Lint factories by building" task lint_by_build: :environment do if Rails.env.production? abort "Can't lint factories in production" end FactoryGirl.factories.each do |factory| lint_factory(factory.name) end end private def lint_factory(factory_name) FactoryGirl.build(factory_name) rescue StandardError puts "Error building factory: #{factory_name}" raise end end
An example of an incorrect Factory:
class User attr_reader :name def initialize(data) @name = data.fetch(:name)
Now after calling
factory_girl: lint_by_build of the
Rake task, we get:
Error building factory: user rake aborted! KeyError: key not found: :name /path/models.rb:5:in `fetch' /path/models.rb:5:in `initialize' /path/factories.rb:8:in `block (3 levels) in <top (required)>' /path/Rakefile:15:in `lint_factory'
Total:
To use FactoryGirl + "pure" Ruby:
- Use FactoryGirl.build instead of FactoryGirl.create.
- Use initialize_with to change the object's initialization.
- External Factory can use attributes_for to build nested resources.
- To override the object creation strategy:
Or adding method #skip_create
Or use custom linting to check Factory
PS This translation is experimental and does not pretend to be professional.Original article