Skip to content

Commit

Permalink
Merge pull request ginty#7 from beorc/lint
Browse files Browse the repository at this point in the history
Add factories linter
  • Loading branch information
ginty authored Nov 6, 2016
2 parents 061494e + c3e4e45 commit 7c8d54d
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 17 deletions.
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
1 change: 1 addition & 0 deletions lib/cranky.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 32 additions & 3 deletions lib/cranky/factory.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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|
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -118,5 +145,7 @@ def new_job(what, overrides)

end

end
class Factory < FactoryBase
end

end
101 changes: 101 additions & 0 deletions lib/cranky/linter.rb
Original file line number Diff line number Diff line change
@@ -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
49 changes: 43 additions & 6 deletions spec/cranky_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions spec/linter_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 7c8d54d

Please sign in to comment.