Skip to content

Instantly share code, notes, and snippets.

@Ferdy89
Last active July 29, 2016 18:37
Show Gist options
  • Save Ferdy89/899c69a5a6ad355d2a2a to your computer and use it in GitHub Desktop.
Save Ferdy89/899c69a5a6ad355d2a2a to your computer and use it in GitHub Desktop.
A simpler and faster alternative to FactoryGirl for testing scopes
# 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
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
@MissingHandle
Copy link

Nice. Handling relationships and traits... recurse on this function perhaps?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment