Last active
July 29, 2016 18:37
-
-
Save Ferdy89/899c69a5a6ad355d2a2a to your computer and use it in GitHub Desktop.
A simpler and faster alternative to FactoryGirl for testing scopes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# A simpler and faster alternative to FactoryGirl for testing scopes. | |
# Because the Sputnik was fast, but Spuni is even faster to pronounce. | |
# | |
# FactoryGirl is awesome on a greenfield application. It allows you to shove | |
# objects into the database very quickly and save a ton of code on your tests. | |
# The problem starts when your application grows and your god factories start | |
# having a lot of traits and callbacks. Then FactoryGirl's factories start | |
# becoming slow and hard to maintain. | |
# | |
# Not only that, your very own models might have callbacks that can make it | |
# difficult for certain deterministic records to reach the database. The logic | |
# inside your models might change and, with that, the data that reaches the | |
# database might change as well. This can be tremendously painful when testing | |
# scopes, where the only thing you want is for a specific tuple to be saved. | |
# | |
# Here's where Spuni shines. Its syntax is very similar to the one FactoryGirl | |
# has, but it lacks most of its features. It simply stores rows directly into | |
# the database, skipping all the Rails' paraphernalia. | |
# | |
# @example Storing an imaginary Foo object | |
# Spuni.create(:foo, first_attribute: 'bar', second_attribute: 'baz') | |
# | |
# That's it. That's the full API. Spuni does one thing, but does it well and | |
# very fast. Here's how you would test the following scope: | |
# | |
# class Blog < ActiveRecord::Base | |
# has_many :posts | |
# | |
# scope :active_with_published_posts, -> { | |
# joins(:posts) | |
# .where(active: true, posts: { published: true }) | |
# } | |
# end | |
# | |
# class Post < ActiveRecord::Base | |
# belongs_to :blog | |
# end | |
# | |
# There are three logical branches you want to test here: | |
# * The scope finds a blog that is active and has published posts | |
# * The scope does not find a blog that is inactive but has published posts | |
# * The scope does not find a blog that is active but has no published posts | |
# | |
# In order to do that, you need to create those three types of blogs and make | |
# sure the scope only finds one: | |
# | |
# expected_blog = Spuni.create(:blog, active: true).tap do |blog| | |
# Spuni.create(:post, blog_id: blog.id, published: true) | |
# end | |
# Spuni.create(:blog, active: false).tap do |blog| | |
# Spuni.create(:post, blog_id: blog.id, published: true) | |
# end | |
# Spuni.create(:blog, active: true).tap do |blog| | |
# Spuni.create(:post, blog_id: blog.id, published: false) | |
# end | |
# | |
# Then: | |
# | |
# expect(Blog.active_with_posts).to match_array([expected_blog]) | |
# | |
# As you can see, there's a lot of repetition in there. I like to simplify that | |
# as follows: | |
# | |
# def create_blog(blog: {}, post: {}) | |
# blog_attrs = { active: true } | |
# Spuni.create(:blog, blog_attrs.merge(blog)).tap do |blog| | |
# post_attrs = { blog_id: blog.id, published: true } | |
# Spuni.create(:post, post_attrs.merge(post)) | |
# end | |
# end | |
# | |
# expected_blog = create_blog | |
# inactive_blog = create_blog(active: false) | |
# unpublished_blog = create_blog(post: { published: false }) | |
# | |
# This pattern might not look extremely beneficial in this case, but on other | |
# more complex situations it can clarify things a lot. | |
class Spuni | |
MissingAttribute = Class.new(ArgumentError) | |
MissingTable = Class.new(ArgumentError) | |
def self.create(name, attrs = {}) | |
new(name, attrs).send(:create) | |
end | |
private | |
attr_reader :name, :attrs | |
def initialize(name, attrs = {}) | |
@name = name | |
@attrs = attrs | |
end | |
def create | |
validate_attributes | |
raw_results = ActiveRecord::Base.connection.execute(sql_statement).to_a | |
normalized_attrs = parse_into_timezone(raw_results) | |
klass.new(normalized_attrs, without_protection: true) | |
end | |
def klass | |
@klass ||= name.to_s.camelize.constantize | |
rescue NameError | |
raise MissingTable, "Table #{name} does not exist" | |
end | |
def validate_attributes | |
missing_attributes = attrs.keys.map(&:to_s) - klass.column_names | |
if missing_attributes.any? | |
raise MissingAttribute, "Table #{name} does not have columns: #{missing_attributes.join(', ')}" | |
end | |
end | |
def sql_statement | |
timestamped_attrs = attrs.reverse_merge(created_at: Time.current, updated_at: Time.current) | |
tuple = timestamped_attrs.map { |k, v| [klass.arel_table[k], v] } | |
insert_manager = klass.arel_table.compile_insert(tuple) | |
# We want to get back the id of the new record (and why not the rest of the | |
# attributes?) but Arel doesn't provide a way of doing this. Even Rails does | |
# this manually: | |
# https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L84 | |
insert_manager.to_sql + ' RETURNING *' | |
end | |
def parse_into_timezone(raw_results) | |
raw_results.first.map do |field, value| | |
if klass.column_types[field].type == :datetime && value | |
[field, Time.parse(value + ' UTC').in_time_zone] | |
else | |
[field, value] | |
end | |
end.to_h | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'rails_helper' | |
require 'support/spuni' | |
RSpec.describe Spuni do | |
SpuniModel = Class.new(ActiveRecord::Base) | |
before(:all) do | |
ActiveRecord::Migration.new.create_table(:spuni_models) do |t| | |
t.timestamps | |
end | |
end | |
after(:all) do | |
ActiveRecord::Migration.new.drop_table(:spuni_models) | |
Object.send(:remove_const, :SpuniModel) | |
end | |
describe '.create' do | |
it 'gives back a model equivalent to the one Rails would have generated' do | |
spuni_model = described_class.create(:spuni_model) | |
rails_model = SpuniModel.find(spuni_model.id) | |
expect(spuni_model.attributes).to eql(rails_model.attributes) | |
end | |
it 'blows up when passed an attribute that does not exist on the model' do | |
expect { described_class.create(:spuni_model, foo: 'bar') }.to raise_error(Spuni::MissingAttribute, /foo/) | |
end | |
it 'blows up when trying to create a record for a table that does not exist' do | |
expect { described_class.create(:foo) }.to raise_error(Spuni::MissingTable, /foo/) | |
end | |
it 'records timestamps for you' do | |
spuni_model = described_class.create(:spuni_model) | |
expect(spuni_model.created_at).to_not be_nil | |
expect(spuni_model.updated_at).to_not be_nil | |
end | |
it 'allows overriding the timestamps' do | |
custom_timestamp = Time.new(2016, 7, 28, 11, 33, 42) | |
spuni_model = described_class.create(:spuni_model, created_at: custom_timestamp) | |
expect(spuni_model.created_at).to eql(custom_timestamp) | |
expect(spuni_model.updated_at).to_not eql(custom_timestamp) | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Nice. Handling relationships and traits... recurse on this function perhaps?