Last active
December 1, 2023 11:20
-
-
Save fjfish/78bd55ffc708c16a400d to your computer and use it in GitHub Desktop.
Automatic generation of rspec model tests and factories for factory girl
This file contains hidden or 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
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 |
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
@fjfish small error:
def namespace
@namespace ||= begin
parts = required_file_name.split("/")
if parts.length > 1
parts[0..-1]
else
parts
end
end
end
@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
Assumes you have the shoulda matchers as well.
Fire up the rails console
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.