Skip to content

Instantly share code, notes, and snippets.

@robacarp
Last active October 12, 2015 06:18
Show Gist options
  • Save robacarp/3983694 to your computer and use it in GitHub Desktop.
Save robacarp/3983694 to your computer and use it in GitHub Desktop.
Sane, Simple Rails Generator framework to replace FactoryGirl, Machinist, Fabrication, and Rails Fixtures

##Intro

Generator frameworks spend way too much time creating obscure and pointless DSL syntax just to make the code trendy and cute. This generator comes in the form of pure ruby, instantiates real model objects (which fire their pre-, post- and other hooks appropriately), and still allows for a simple syntax to override parameters of generated objects.

Standard Ruby syntax allows for easier adoption by new programmers and reinforces ruby paradigms, instead of breaking them down with a cute, often incomplete, and entirely superfluous DSL syntax.

Specific complaints against existing frameworks:

  • Rails fixtures: definition syntax (yaml) doesn't provide a powerful enough interface to rapidly creating objects.
  • FactoryGirl: Factories are evaluated at load-time, making some factories just awful to define. No further comment on DSLs.
  • Machinist: See FactoryGirl
  • Fabrication: Where do I start. Requiring a full block to override object attributes at test-time is an indicator of over-engineering. Requiring a block within that block to set a variable equal to a value is downright excessive. Finally, what on earth would give you the idea that you need to animate your entire website?!

###A test directory might look like this:

- test/
  - generate/ <---- folder which contains generators, autoladed by generate.rb
      address.rb
      line_item.rb
      order.rb
      pcb.rb
      user.rb
  - integration/ <---- a few folders containing tests
  - mailers/
  - models/
  - support/
    generate.rb <---- from this gist
    test_helper.rb

###In test helper: require 'generate'

###Next, in a generate file (generate/whatever.rb):

class Generate
  def self.address
    a = Address.new
    a.first_name = "Elwin"
    a.last_name = "Ransom"
    a.street_1 = "1 Malacandra Street"
    a.street_2 = "Hyoi's Water Dwelling n"
    a.city = "Malacandra"

    a.country = Country.usa
    a.province = a.country.provinces[ rand(a.country.provinces.count)]
    a.postal_code = '80019'

    a
  end

  def self.domestic_address
    a = Generate.new :address
    a.first_name = "Jonathan"
    a.last_name  = "Edwards"
    a.street_1 = '409 Prospect Street'
    a.city = 'New Haven'
    a.postal_code = '06511'
    a.province_id = 14 #CT
    a.country_id = 223 #USA

    a
  end

  def self.international_address
    a = Generate.new :address
    a.first_name  = 'Martin'
    a.last_name   = 'Luther'
    a.street_1    = 'Friedrichstrasse 1a'
    a.city        = 'Lutherstadt Wittenberg'
    a.postal_code = 'D-06886'
    a.country     = Country.find_by_name('Germany')

    a
  end
end

###Lastly, in your tests: @password = '010203'

# Using the default generator code:
@other_user = Generate.create(:user)

# The hash keys indicate the names of functions to be called, and the values their parameter.
# If 'key=' is found as a function, it'll call that instead of 'key'.
@user = Generate.create(:user, new_password: @password, new_password_confirmation: @password)

###A note about sequences:

I've provided a simple construct to create named sequences which allow for creating, for example, email addresses which do not collide in the database. From within any subclass of Generate, you can easily call sequence(:sequence_name) to fetch a number. By default, a new sequence is initialized to zero. All sequences increment by one at each call.

Additionally, you can specify a number you want to initialize the sequence to as the second parameter: sequence(:email_counter, 32767). Use sparingly.

The Generate&sequence method is publicly available should you desire to use it directly in a test.

###Further usage explanation:

Find on page 'advanced'...or, you know, scroll down.

###Contact

If for some reason you want to contact me with some question or comment on the contents of this gist, feel free to email me.

# Quick hack to generate random lengths of text from my favorite sensical filler text generator, fillerati
require 'net/http'
require 'crack/json'
class Generate
def self.text wc = 10
urls = [
'http://www.fillerati.com/json/getpara?b=lcnwndrlnd&c=93',
'http://www.fillerati.com/json/getpara?b=mbydck&c=93',
]
response = Net::HTTP.get( URI.parse urls.sample )
obj = Crack::JSON.parse response
words = obj['p'].join(' ').split
len = words.length
start = rand(len - wc)
words.slice(start,wc).join(' ')
end
end
class Generate
# Intro, Howto, original code at:
# https://gist.github.com/gists/3983694
generators = Dir['test/generate/*']
generators.each do |g|
load g
end
@sequences = {}
def self.new name, *opts
unless self.respond_to? name
raise NameError, "No Generator found for #{name}"
end
attributes = {}
if opts.last.kind_of? Hash
attributes = opts.pop
end
object = self.send name, *opts
raise RuntimeError, "Generator did not return an ActiveRecord::Base. Instead found #{object.class}" unless object.kind_of? ActiveRecord::Base
apply_attribs object, attributes
object
end
def self.create name, *opts
object = new name, *opts
object.save
object
end
def self.sequence name, val = nil
if @sequences[name].nil?
@sequences[name] = 0
else
@sequences[name] += 1
end
@sequences[name] = val unless val.nil?
@sequences[name]
end
private
def self.apply_attribs object, attributes
attributes.each do |(key, value)|
setter = "#{key.to_s}=".to_sym
if object.respond_to? setter
object.send setter, value
elsif object.respond_to? key
object.send key, value
else
raise ArgumentError, "Cannot set #{key} on #{object.class}"
end
end
end
end
# The MIT License (MIT)
#
# Copyright (c) 2012 Robert Carpenter
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
require 'test_helper'
class GeneratorsTest < Test::Unit::TestCase
def test_generators
not_generators = [:apply_attribs, :new, :create, :sequence]
(Generate.methods.sort - Object.methods - not_generators).each do |method|
Generate.send method
end
end
def test_sequencer
n = Generate.sequence(:foo)
assert_equal n+1, Generate.sequence(:foo)
assert_equal 0, Generate.sequence(:foo, 0)
assert_equal 1, Generate.sequence(:foo)
end
end

###By way of example To illustrate the correct usage of generator inheritance, sequences and generator parameters, I'm including a user generator from a recent project:

class Generate
  def self.user password = '12345'
    u = User.new
    u.email = "test_user_#{sequence(:email)}@test.com"
    u.new_password = password
    u.new_password_confirmation = password

    u
  end

  def self.admin
    u = Generate.new :user
    u.roles = [:user,:admin]

    u
  end
end

With the above generator, an example test:

class UserIntegration < IntegrationTest
  def test_allows_users_to_update_profile_without_his_password
    password = '010203'
    user = Generate.create(:user, password)
    assert sign_in(user.email, password), "could not sign in user"

    visit edit_user_path(user)

    fill_in 'First name', with: 'Elwin'
    fill_in 'Last name', with: 'Ransom'

    click_button :save

    assert_equal page.current_path, user_path(user), "page did not correctly redirect"
    assert sign_out
  end
end

2013-05-25

  • Add generator test
  • Add fillerati dummy text generator
  • As if organizing files lexicographically wasn't enough, now gists are named after the first file within.

2012-11-12

  • Add changelog. Irritated by the need to organize files lexicographically, adds indicative alphabetic prefixes to get the desired order to files presented in the gist.
  • Add support for initializing or resetting the sequencer to a value other than 0.
  • Also adds basic support for passing arguments to the generator functions directly.
  • Adds "advanced usage" doc.

2012-10-30

  • Started gist.
  • Blatantly refusing to create a gem.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment