From c3e4e45470bff335de951cf7ff79a8254af38da8 Mon Sep 17 00:00:00 2001 From: Yury Kotov Date: Thu, 11 Aug 2016 21:31:20 +0300 Subject: [PATCH] Add factories linter --- README.md | 60 +++++++++++++++++++++++++ lib/cranky.rb | 1 + lib/cranky/factory.rb | 35 +++++++++++++-- lib/cranky/linter.rb | 101 ++++++++++++++++++++++++++++++++++++++++++ spec/cranky_spec.rb | 49 +++++++++++++++++--- spec/linter_spec.rb | 38 ++++++++++++++++ spec/spec_helper.rb | 31 +++++++++---- 7 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 lib/cranky/linter.rb create mode 100644 spec/linter_spec.rb diff --git a/README.md b/README.md index 8b000fa..c336082 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,66 @@ def users_collection end ~~~ +## Linting Factories + +Cranky allows for linting known factories: + +~~~ruby +Factory.lint! +~~~ + +`Factory.lint!` creates each factory and catches any exceptions raised during the creation process. `Cranky::Linter::InvalidFactoryError` is raised with a list of factories (and corresponding exceptions) for factories which could not be created. + +Recommended usage of `Factory.lint!` is to run this in a task before your test suite is executed. Running it in a `before(:suite)`, will negatively impact the performance of your tests when running single tests. + +Example Rake task: + +~~~ruby +# lib/tasks/factory_girl.rake +namespace :cranky do + desc "Verify that all factories are valid" + task lint: :environment do + if Rails.env.test? + begin + DatabaseCleaner.start + Factory.lint! + ensure + DatabaseCleaner.clean + end + else + system("bundle exec rake cranky:lint RAILS_ENV='test'") + end + end +end +~~~ + +After calling `Factory.lint!`, you'll likely want to clear out the database, as records will most likely be created. The provided example above uses the database_cleaner gem to clear out the database; be sure to add the gem to your Gemfile under the appropriate groups. + +You can lint factories selectively by passing only factories you want linted: + +~~~ruby +factories_to_lint = Factory.factory_names.reject do |name| + name =~ /^old_/ +end + +Factory.lint! factories_to_lint +~~~ + +This would lint all factories that aren't prefixed with `old_`. + +Traits can also be linted. This option verifies that each +and every trait of a factory generates a valid object on its own. This is turned on by passing traits: true to the lint method: + +~~~ruby +Factory.lint! traits: true +~~~ + +This can also be combined with other arguments: + +~~~ruby +Factory.lint! factories_to_lint, traits: true +~~~ + ## Helpers Of course its nice to get some help... diff --git a/lib/cranky.rb b/lib/cranky.rb index d751996..0da8492 100644 --- a/lib/cranky.rb +++ b/lib/cranky.rb @@ -1,5 +1,6 @@ require 'cranky/version' require 'cranky/job' +require 'cranky/linter' require 'cranky/factory' # Instantiate a factory, this enables an easy drop in for tests written for Factory Girl diff --git a/lib/cranky/factory.rb b/lib/cranky/factory.rb index cad2686..49dec89 100644 --- a/lib/cranky/factory.rb +++ b/lib/cranky/factory.rb @@ -1,7 +1,7 @@ module Cranky - class Factory + class FactoryBase - attr_writer :debug + TRAIT_METHOD_REGEXP = /apply_trait_(\w+)_to_(\w+)/.freeze def initialize # Factory jobs can be nested, i.e. a factory method can itself invoke another factory method to @@ -22,6 +22,12 @@ def create(what, overrides={}) item end + def create!(what, overrides={}) + item = build(what, overrides) + Array(item).each(&:save!) + item + end + # Reset the factory instance, clear all instance variables def reset self.instance_variables.each do |var| @@ -57,6 +63,27 @@ def debug!(*args) item end + # Look for errors in factories and (optionally) their traits. + # Parameters: + # factory_names - which factories to lint; omit for all factories + # options: + # traits : true - to lint traits as well as factories + def lint!(factory_names: nil, traits: false) + factories_to_lint = Array(factory_names || self.factory_names) + strategy = traits ? :factory_and_traits : :factory + Linter.new(self, factories_to_lint, strategy).lint! + end + + def factory_names + public_methods(false).reject {|m| TRAIT_METHOD_REGEXP === m } + end + + def traits_for(factory_name) + regexp = /^apply_trait_(\w+)_to_#{factory_name}$/.freeze + trait_methods = public_methods(false).select {|m| regexp === m } + trait_methods.map {|m| regexp.match(m)[1] } + end + private def apply_traits(what, item) @@ -118,5 +145,7 @@ def new_job(what, overrides) end -end + class Factory < FactoryBase + end +end diff --git a/lib/cranky/linter.rb b/lib/cranky/linter.rb new file mode 100644 index 0000000..52aa97a --- /dev/null +++ b/lib/cranky/linter.rb @@ -0,0 +1,101 @@ +module Cranky + class Linter + + def initialize(factory, factories_to_lint, linting_strategy) + @factory = factory + @factories_to_lint = factories_to_lint + @linting_method = "lint_#{linting_strategy}" + @invalid_factories = calculate_invalid_factories + end + + def lint! + if invalid_factories.any? + raise InvalidFactoryError, error_message + end + end + + attr_reader :factories_to_lint, :invalid_factories + private :factories_to_lint, :invalid_factories + + private + + def calculate_invalid_factories + factories_to_lint.reduce(Hash.new([])) do |result, factory| + errors = send(@linting_method, factory) + result[factory] |= errors unless errors.empty? + result + end + end + + # Raised when any factory is considered invalid + class InvalidFactoryError < RuntimeError; end + + class FactoryError + def initialize(wrapped_error, factory_name) + @wrapped_error = wrapped_error + @factory_name = factory_name + end + + def message + message = @wrapped_error.message + "* #{location} - #{message} (#{@wrapped_error.class.name})" + end + + def location + @factory_name + end + end + + class FactoryTraitError < FactoryError + def initialize(wrapped_error, factory_name, trait_name) + super(wrapped_error, factory_name) + @trait_name = trait_name + end + + def location + "#{@factory_name}+#{@trait_name}" + end + end + + def lint_factory(factory_name) + result = [] + begin + @factory.create!(factory_name) + rescue => error + result |= [FactoryError.new(error, factory_name)] + end + result + end + + def lint_traits(factory_name) + result = [] + @factory.traits_for(factory_name).each do |trait_name| + begin + @factory.create!(factory_name, traits: trait_name) + rescue => error + result |= + [FactoryTraitError.new(error, factory_name, trait_name)] + end + end + result + end + + def lint_factory_and_traits(factory_name) + errors = lint_factory(factory_name) + errors |= lint_traits(factory_name) + errors + end + + def error_message + lines = invalid_factories.map do |_factory, exceptions| + exceptions.map(&:message) + end.flatten + + <<-ERROR_MESSAGE.strip +The following factories are invalid: + +#{lines.join("\n")} + ERROR_MESSAGE + end + end +end diff --git a/spec/cranky_spec.rb b/spec/cranky_spec.rb index 3ff4e50..3d4e14a 100644 --- a/spec/cranky_spec.rb +++ b/spec/cranky_spec.rb @@ -2,7 +2,44 @@ describe "The Cranky factory" do - before(:each) do + describe '#factory_names' do + it 'returns an array of names of defined factories' do + Factory.factory_names.should eq [:user, + :address, + :users_collection, + :user_manually, + :user_by_define, + :admin_manually, + :admin_by_define, + :user_hash, + :invalid_user] + end + end + + describe '#traits_for' do + it 'returns an array of trait names defined for the given factory' do + Factory.traits_for(:user_manually).should eq ['manager'] + end + end + + describe '#create!' do + context 'given invalid record' do + let(:factory_name) { :invalid_user } + + it 'raises an error if validation failed' do + expect { Factory.create!(factory_name) }.to raise_error('Validation failed: {:required_attr=>["can\'t be blank"]}') + end + end + + context 'given valid record' do + let(:factory_name) { :user } + + it 'creates object' do + result = nil + expect { result = Factory.create!(factory_name) }.to_not raise_error + result.should be_saved + end + end end it "is alive" do @@ -37,10 +74,10 @@ end it "clears all instance variables when reset" do - Factory.some_instance_variable = true - Factory.some_instance_variable.should == true + Factory.instance_variable_set('@some_instance_variable', true) + Factory.instance_variable_get('@some_instance_variable').should be_true Factory.reset - Factory.some_instance_variable.should == nil + Factory.instance_variable_get('@some_instance_variable').should be_nil end it "can create items using the define helper or manually" do @@ -79,7 +116,7 @@ describe "debugger" do it "raises an error if the factory produces an invalid object when enabled (rails only)" do - expect { Factory.debug(:user) }.to raise_error( + expect { Factory.debug(:user, required_attr: nil) }.to raise_error( 'Oops, the User created by the Factory has the following errors: {:required_attr=>["can\'t be blank"]}' ) end @@ -147,7 +184,7 @@ end it "returns nothing extra in the attributes" do - crank(:user_attrs).size.should == 5 + crank(:user_attrs).size.should == 6 end specify "attributes for works with factory methods using inherit" do diff --git a/spec/linter_spec.rb b/spec/linter_spec.rb new file mode 100644 index 0000000..f0248e1 --- /dev/null +++ b/spec/linter_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe 'Factory.lint' do + it 'raises when a factory is invalid' do + error_message = <<-ERROR_MESSAGE.strip +The following factories are invalid: + +* user_hash - undefined method `save!' for [:name, "Fred"]:Array (NoMethodError) +* invalid_user - Validation failed: {:required_attr=>["can't be blank"]} (RuntimeError) + ERROR_MESSAGE + + expect do + Factory.lint! + end.to raise_error Cranky::Linter::InvalidFactoryError, error_message + end + + it 'does not raise when all factories are valid' do + expect { Factory.lint!(factory_names: [:admin_manually, :address]) }.not_to raise_error + end + + describe "trait validation" do + context "enabled" do + it "raises if a trait produces an invalid object" do + error_message = <<-ERROR_MESSAGE.strip +The following factories are invalid: + +* user+invalid - Validation failed: {:required_attr=>["can't be blank"]} (RuntimeError) +* user_hash - undefined method `save!' for [:name, "Fred"]:Array (NoMethodError) +* invalid_user - Validation failed: {:required_attr=>["can't be blank"]} (RuntimeError) + ERROR_MESSAGE + + expect do + Factory.lint!(traits: true) + end.to raise_error Cranky::Linter::InvalidFactoryError, error_message + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0c6e73b..5679db6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -19,6 +19,13 @@ def save @saved = true end + def save! + if invalid? + raise "Validation failed: #{errors.messages}" + end + save + end + def saved? !!@saved end @@ -38,7 +45,6 @@ class User < TestClass attr_accessor :address end - class Address < TestClass attr_accessor :address attr_accessor :city @@ -47,32 +53,32 @@ class Address < TestClass # Some basic factory methods class Cranky::Factory - attr_accessor :some_instance_variable - def user_manually - u = User.new + u = User.new u.name = "Fred" u.role = options[:role] || :user u.unique = "value#{n}" u.email = "fred@home.com" u.address = Factory.build(:address) + u.required_attr = true u end def user_by_define - u = define :class => :user, + u = define :class => :user, :name => "Fred", :role => :user, :unique => "value#{n}", :email => "fred@home.com", - :address => Factory.create(:address) + :address => Factory.create(:address), + :required_attr => true u.argument_received = true if options[:argument_supplied] u end alias :user :user_by_define def admin_manually - inherit(:user_manually, :role => :admin) + inherit(:user_manually, role: :admin, required_attr: true) end def admin_by_define @@ -81,7 +87,8 @@ def admin_by_define def address define :address => "25 Wisteria Lane", - :city => "New York" + :city => "New York", + :required_attr => true end def user_hash @@ -97,4 +104,12 @@ def users_collection def apply_trait_manager_to_user_manually(user) user.role = :manager end + + def apply_trait_invalid_to_user(user) + user.required_attr = nil + end + + def invalid_user + inherit(:user, required_attr: false) + end end