Factory Girl without Active Record
https://upload.wikimedia.org/wikipedia/commons/0/0e/Rhodes-mfg-co.jpg
Factory Girl has been around for more than five years now and has become the standard for building and saving valid model data for your test suite. Out of the box, Factory Girl plays nicely with the major ORMs: Active Record, Mongoid, DataMapper and MongoMapper. But what about those pesky models that fall outside of your ORM? Fear not… Factory Girl's got you covered there too!
Non-ORM Models
Developers often define their models around their database tables but as your application grows and becomes more interconnected, you will begin to pull data from sources outside your own database. Make no mistake: the Ruby objects that encapsulate this external data are still models, even without your ORM of choice.
I'll share examples of two external models from an application I've been working on.
Facebook Auth
The application uses OmniAuth to register and authenticate users via Facebook. OmniAuth documentation describes the structure of the Auth Hash returned from Facebook. This is our external data. OmniAuth wraps this hash in a class called OmniAuth::AuthHash
. That is our model.
When a user authenticates via Facebook, the application either finds an existing user or creates a new one from the auth hash that Facebook returned.
class User < ActiveRecord::Base
def self.from_omniauth(facebook_auth)
raw_info = facebook_auth.extra.raw_info
find_or_create_by!(facebook_id: raw_info.id) do |user|
user.first_name = raw_info.first_name
user.last_name = raw_info.last_name
user.email = raw_info.email
user.gender = raw_info.gender
end
end
end
In testing this method, it would be really helpful to have a realistic auth hash to work with. Enter: Factory Girl.
FactoryGirl.define do
factory :facebook_auth, class: OmniAuth::AuthHash do
skip_create
ignore do
id { SecureRandom.random_number(1_000_000_000).to_s }
name { "#{first_name} #{last_name}" }
first_name "Joe"
last_name "Bloggs"
link { "http://www.facebook.com/#{username}" }
username "jbloggs"
location_id "123456789"
location_name "Palo Alto, California"
gender "male"
email "joe@bloggs.com"
timezone(-8)
locale "en_US"
verified true
updated_time { SecureRandom.random_number(1.month).seconds.ago }
token { SecureRandom.urlsafe_base64(100).delete("-_").first(100) }
expires_at { SecureRandom.random_number(1.month).seconds.from_now }
end
provider "facebook"
uid { id }
info do
{
nickname: username,
email: email,
name: name,
first_name: first_name,
last_name: last_name,
image: "http://graph.facebook.com/#{id}/picture?type=square",
urls: { Facebook: link },
location: location_name,
verified: verified
}
end
credentials do
{
token: token,
expires_at: expires_at.to_i,
expires: true
}
end
extra do
{
raw_info: {
id: uid,
name: name,
first_name: first_name,
last_name: last_name,
link: link,
username: username,
location: { id: location_id, name: location_name },
gender: gender,
email: email,
timezone: timezone,
locale: locale,
verified: verified,
updated_time: updated_time.strftime("%FT%T%z")
}
}
end
end
end
What makes this factory special is the skip_create
method call and the ignore
block.
The skip_create
method does just what it says. Rather than using Factory Girl's default behavior of trying to save the instance to the database, persistence is skipped.
The ignore
block defines a list of attributes that won't be passed into the new instance but can be used elsewhere in the factory. Ignored attributes are perfect for deeply nested structures like the auth hash factory above. I can specify a first_name
and it will appear throughout the new auth hash.
facebook_auth = FactoryGirl.create(:facebook_auth, first_name: "Steve")
facebook_auth.info.first_name # => "Steve"
facebook_auth.info.name # => "Steve Bloggs"
facebook_auth.extra.raw_info.first_name # => "Steve"
facebook_auth.extra.raw_info.name # => "Steve Bloggs"
It's incredibly handy to have a completely valid Facebook auth hash available from anywhere in your test suite. Plus, this factory should not need to change often, if at all.
Balanced Customer
Balanced is a payment processing company designed for marketplaces. They can process payments from customers and payouts to sellers. They have a robust API with its own Ruby wrapper. The API is our external data and the wrapper's resource classes are our models.
Defining factories for these external resources is done similarly to the Facebook auth hash above and takes advantage of a couple more techniques: the initialize_with
method and "traits."
FactoryGirl.define do
trait :balanced_resource do
skip_create
initialize_with do
new.class.construct_from_response(attributes)
end
ignore do
balanced_marketplace_uri { ENV["BALANCED_MARKETPLACE_URI"] }
end
id { SecureRandom.urlsafe_base64(24).delete("-_").first(24) }
end
factory :balanced_card, class: Balanced::Card do
balanced_resource
account nil
brand "MasterCard"
card_type "mastercard"
country_code nil
created_at { Time.current.xmlschema(6) }
customer nil
expiration_month 12
expiration_year 2020
hash { SecureRandom.hex(32) }
is_valid true
is_verified true
last_four "5100"
meta({})
name nil
postal_code nil
postal_code_check "unknown"
security_code_check "passed"
street_address nil
uri { "#{balanced_marketplace_uri}/cards/#{id}" }
end
factory :balanced_bank_account, class: Balanced::BankAccount do
balanced_resource
account_number "xxxxxx0001"
bank_name "BANK OF AMERICA, N.A."
can_debit false
created_at { Time.current.xmlschema(6) }
credits_uri { "#{uri}/credits" }
customer nil
debits_uri { "#{uri}/debits" }
fingerprint { SecureRandom.hex(32) }
meta({})
name "Johann Bernoulli"
routing_number "121000358"
type "checking"
uri { "/v1/bank_accounts/#{id}" }
verification_uri nil
verifications_uri { "#{uri}/verifications" }
end
end
By default, Factory Girl passes its attributes hash to the new
method. This works in most cases but unfortunately, not for our Balanced resources. They instead use the class method construct_from_response
. Currently, getting access to a class method within the initialize_with
block is awkward but can be done by calling the method on new.class
.
You can see that we also registered a balanced_resource
trait. A trait is a set of attributes and behaviors that can be reused across other factories. The balanced_resource
trait sets up initialization, skips create and gives us some default attributes. The balanced_card
and balanced_bank_account
factories can then call that trait and inherit all of the Balanced-specific behavior.
One more thing…
Using Factory Girl for API-backed models like the Balanced models above allows you to go further and stub how the models find their instances.
Even though we skip_create
in our Balanced factories, the after(:create)
callback can still be fired. If we tap into that callback, we can stub the Balanced library's find method to return the instance we just "created."
factory :balanced_card, class: Balanced::Card do
balanced_resource
account nil
# ...
uri { "#{balanced_marketplace_uri}/cards/#{id}" }
after(:create) do |card|
card.class.stub(:find).with(uri).and_return(card)
end
end
This approach can clean up your test suite and allow you to more easily write tests that don't require a connection to your external services.
Check out our latest product, Dead Man’s Snitch for monitoring cron, heroku scheduler or any periodic task.
Comments
This is really a great way to use FactoryGirl for non-ActiveRecord models. Thanks Steve!
Excellent, just what I was looking for! Thank you very much!
hello, i have some trouble about
while i create a instance by FactoryGirl.create and try to access the attribute define in ignore block, i got
NoMethodError: undefined method `info’
What’s wrong?
i just do something like this:
FactoryGirl.define do
factory :facebook_auth, class: MyAPI::Cls do
skip_create
ignore do
id { SecureRandom.random_number(1_000_000_000).to_s }
end
end
end
Wayne, ignored attributes are only available from within the factory definition. They are not actually set on the resulting instance. I hope that helps!
This still doesn’t work if you want to use the stub strategy. It still attempts to pretend to save your model, and set an :id value. This is useless if your custom model doesn’t have an :id field.
Excellent. Hugely helpful article - thank you
I love u
I love u 2
It is what I am looking for. Thanks