Skip to content

Instantly share code, notes, and snippets.

@fjfish
Last active December 1, 2023 11:20
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fjfish/78bd55ffc708c16a400d to your computer and use it in GitHub Desktop.
Save fjfish/78bd55ffc708c16a400d to your computer and use it in GitHub Desktop.
Automatic generation of rspec model tests and factories for factory girl
class Models
def self.generate what = :model
Rails.application.eager_load!
ActiveRecord::Base.descendants.each do |model|
method(what)[model]
end
true
end
def self.factory model
factory_file_name = "spec/factories/#{model.name.underscore}.rb"
unless File.exists?(factory_file_name)
File.open(factory_file_name,"w") do |file|
factory_for model, file
end
end
end
def self.factory_for model, file
file << <<-EOT
FactoryBot.define do
factory :#{model.name.underscore}, :class => '#{model.name}' do
#{factory_cols model}
end
end
EOT
end
def self.factory_cols model
associations = model.reflections
"".tap do |output_text|
model.columns.each do |col|
next if col.name == 'id'
stripped_name = col.name.gsub(/_id$/,'').to_sym
output_text << "\n "
assoc = associations[stripped_name]
if assoc && [:has_one,:belongs_to].include?(assoc.macro)
output_text << if assoc.options[:class_name]
"association :#{stripped_name.to_s}, factory: :#{assoc.options[:class_name].underscore}"
else
stripped_name.to_s
end
else
output_text << "#{preprocess_name col.name, col.type } #{factory_default_for(col.name, col.type)}"
end
end
end
end
def self.preprocess_name name, type
case name
when /retry/
"self.#{name}"
when /e.*mail/,/name/
if [:text,:string].include?(type)
"sequence(:#{name})"
else
name
end
else
name
end
end
def self.factory_default_for name, type
case type
when :integer, :decimal
"1"
when :date
"Time.now.to_date"
when :datetime
"Time.now"
when :boolean
"true"
when :spatial
"nil"
else
case name
when /e.*mail/
'{ |n| "test#{n}@example.com" }'
when /name/
'{ |n| "name#{n}" }'
when /country/
'"GB"'
when /_ip$/
'"192.168.0.1"'
when /phone/
'"+44000000000"'
else
'"test123"'
end
end
end
def self.model model
test_file_name = "spec/models/#{model.name.underscore}_spec.rb"
unless File.exists?(test_file_name)
File.open(test_file_name,"w") do |file|
describe_model model, file
end
end
end
def self.describe_model model, file
file << <<-EOT
require 'rails_helper'
describe #{model.name}, :type => :model do
# let (:subject) { build :#{model.name.underscore} }
#{read_write_tests model}
#{associations model.reflections}
end
EOT
end
def self.read_write_tests model
" context \"validation\" do".tap do |output_text|
model.validators.select { |val| val.is_a? ActiveModel::Validations::PresenceValidator }.map(&:attributes).
flatten.each { |col| output_text << "\n it { should validate_presence_of :#{col} }"}
model.validators.select { |val| !val.is_a? ActiveModel::Validations::PresenceValidator }.
each { |col| output_text << "\n it \"#{col.class.to_s.demodulize.underscore} test for #{col.attributes.map(&:to_sym).to_s}\""}
end << "\n end"
end
def self.associations reflections
" context \"associations\" do".tap do |output_text|
reflections.each_pair { |key,assoc| output_text << "\n it { should #{translate_assoc assoc.macro} :#{test_assoc_name assoc} }"}
end << "\n end"
end
def self.translate_assoc macro
macro.to_s.gsub(/belongs/,'belong').gsub(/has/,'have')
end
def self.test_assoc_name assoc
case assoc.macro
when /have_many/
assoc.plural_name
when /has_one/,/belongs_to/
assoc.name
else
assoc.name
end
end
end
@fjfish
Copy link
Author

fjfish commented Nov 7, 2014

Assumes you have the shoulda matchers as well.

Fire up the rails console

load 'models.rb'
Models.generate :factory
Models.generate :models

Existing files are left alone.

It creates tests for all of the existing relationships and mandatory columns, plus the validations it doesn't understand are set up as pending tests.

It's a way forward if you have no tests at all.

@fjfish
Copy link
Author

fjfish commented Sep 2, 2021

Here is a version I worked on more recently that uses the file system instead of calling eager_load!. It came out of needing something that would work with engines and incomplete dependencies.

bundle exec rails runner "load '../models.rb' ; error = Models.generate([:model,:factory]) rescue \$! ; puts error.backtrace.join(\"\\n\") if error.respond_to?(:backtrace)"

class Models
  def self.generate what = :model
    require_models.each do |model|
      Array(what).each { |call_method| method(call_method)[model] }
    end
    true
  end

  def self.factory model
    factory_file_name = "spec/factories/#{model.name.underscore}.rb"
    unless File.exists?(factory_file_name)
      FileUtils.mkdir_p(File.dirname(factory_file_name))
      File.open(factory_file_name, "w") do |file|
        factory_for model, file
      end
    end
  end

  def self.factory_for model, file
    file << <<-EOT
FactoryBot.define do
  factory :#{model.name.underscore}, :class => '#{model.name}' do
#{factory_cols model}
  end
end
    EOT
  end

  def self.factory_cols model
    associations = model.reflections
    "".tap do |output_text|
      model.columns.each do |col|
        next if col.name == 'id'
        stripped_name = col.name.gsub(/_id$/, '').to_sym
        output_text << "\n    "
        assoc = associations[stripped_name]
        if assoc && [:has_one, :belongs_to].include?(assoc.macro)
          output_text << if assoc.options[:class_name]
                           "association :#{stripped_name.to_s}, factory: :#{assoc.options[:class_name].underscore}"
                         else
                           stripped_name.to_s
                         end
        else
          output_text << "#{preprocess_name col.name, col.type } #{factory_default_for(col.name, col.type)}"
        end
      end
    end
  end

  def self.preprocess_name name, type
    case name
    when /retry/
      "self.#{name}"
    when /e.*mail/, /name/
      if [:text, :string].include?(type)
        "sequence(:#{name})"
      else
        name
      end
    else
      name
    end
  end

  def self.factory_default_for name, type
    case type
    when :integer, :decimal
      "1"
    when :date
      "Time.now.to_date"
    when :datetime
      "Time.now"
    when :boolean
      "true"
    when :spatial
      "nil"
    else
      case name
      when /e.*mail/
        '{ |n| "test#{n}@example.com" }'
      when /name/
        '{ |n| "name#{n}" }'
      when /country/
        '"GB"'
      when /_ip$/
        '"192.168.0.1"'
      when /phone/
        '"+44000000000"'
      else
        '"test123"'
      end
    end
  end

  def self.model model
    test_file_name = "spec/models/#{model.name.underscore}_spec.rb"
    unless File.exists?(test_file_name)
      FileUtils.mkdir_p(File.dirname(test_file_name))
      File.open(test_file_name, "w") do |file|
        describe_model model, file
      end
    end
  end

  def self.describe_model model, file
    file << <<-EOT
require 'rails_helper'

describe #{model.name}, :type => :model do
  # let (:subject) { build :#{model.name.underscore} }
#{read_write_tests model}
#{associations model.reflections}
#{methods model}
end
    EOT
  end

  def self.read_write_tests model
    "  context \"validation\" do".tap do |output_text|
      model.validators.select { |val| val.is_a? ActiveModel::Validations::PresenceValidator }.map(&:attributes).
        flatten.each { |col| output_text << "\n    it { should validate_presence_of :#{col} }" }
      model.validators.select { |val| !val.is_a? ActiveModel::Validations::PresenceValidator }.
        each { |col| output_text << "\n    it \"#{col.class.to_s.demodulize.underscore} test for #{col.attributes.map(&:to_sym).to_s}\"" }
    end << "\n  end"
  end

  def self.associations reflections
    "  context \"associations\" do".tap do |output_text|
      reflections.each_pair { |key, assoc| output_text << "\n    it { should #{translate_assoc assoc.macro} :#{test_assoc_name assoc} }" }
    end << "\n  end"
  end

  def self.methods model
    "".tap do |output_text|
      instance = model.new
      (model.instance_methods(false) - model.columns.map(&:name).map(&:to_sym)).sort.each do |method|
        arity = instance.method(method).arity

        output_text <<
          "  context \"#{method}\" do\n" +
          "    it \"exercises #{method} somehow\" do\n" +
          "      subject.#{method} #{(1..arity).to_a.join(", ")}\n" +
          "    end\n" +
          "  end\n"
      end
    end
  end

  def self.translate_assoc macro
    macro.to_s.gsub(/belongs/, 'belong').gsub(/has/, 'have')
  end

  def self.test_assoc_name assoc
    case assoc.macro
    when /have_many/
      assoc.plural_name
    when /has_one/, /belongs_to/
      assoc.name
    else
      assoc.name
    end
  end

  class ModelDef
    attr_reader :file_name

    def initialize(file_name:)
      @file_name = file_name
    end

    def class_name
      @class_name ||= namespace.map(&:camelcase).join('::').constantize
    end

    private

    def required_file_name
      @required_file_name ||= file_name.sub('app/models/', '')[0..-4]
    end

    def namespace
      @namespace ||= begin
                       parts = required_file_name.split("/")
                       if parts.length > 1
                         parts[0..-1]
                       else
                         parts
                       end
                     end
    end
  end

  def self.require_models
    @model_list ||= [].tap do |model_list|
     Dir.glob('app/models/**/**').each do |file|
        next if File.directory?(file)|| !file.ends_with?('.rb') || file =~ %r{application_record}
        new_def = ModelDef.new(file_name: file)
        model_list << new_def.class_name
      end
    end
  end
end

@jmscholen
Copy link

jmscholen commented Oct 20, 2021

@fjfish small error:

    def namespace
      @namespace ||= begin
                       parts = required_file_name.split("/")
                       if parts.length > 1
                         parts[0..-1]
                       else
                         parts
                       end
                     end
    end

@fjfish
Copy link
Author

fjfish commented Nov 15, 2021

@jmscholen - thanks - amended.

Hope you found it useful.

IIRC my local copy had to put brackets around the factory stuff, but I haven't needed it for a few days so not checked.

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