From 39c64bd97fc3ed38561afa77762ae34ebb69fa15 Mon Sep 17 00:00:00 2001 From: Ken Collins Date: Wed, 22 Oct 2008 20:24:11 -0400 Subject: [PATCH] Add old shoulda (for 1.2.6) to test/lib. Also make default rake task run all rails versions. --- .gitignore | 1 - Rakefile | 2 +- test/lib/shoulda.rb | 43 ++ test/lib/shoulda/active_record_helpers.rb | 576 ++++++++++++++++++ test/lib/shoulda/color.rb | 77 +++ .../controller_tests/controller_tests.rb | 467 ++++++++++++++ .../shoulda/controller_tests/formats/html.rb | 201 ++++++ .../shoulda/controller_tests/formats/xml.rb | 170 ++++++ test/lib/shoulda/gem/proc_extensions.rb | 14 + test/lib/shoulda/gem/shoulda.rb | 239 ++++++++ test/lib/shoulda/general.rb | 129 ++++ test/lib/shoulda/private_helpers.rb | 22 + 12 files changed, 1939 insertions(+), 2 deletions(-) create mode 100755 test/lib/shoulda.rb create mode 100755 test/lib/shoulda/active_record_helpers.rb create mode 100755 test/lib/shoulda/color.rb create mode 100755 test/lib/shoulda/controller_tests/controller_tests.rb create mode 100755 test/lib/shoulda/controller_tests/formats/html.rb create mode 100755 test/lib/shoulda/controller_tests/formats/xml.rb create mode 100755 test/lib/shoulda/gem/proc_extensions.rb create mode 100755 test/lib/shoulda/gem/shoulda.rb create mode 100755 test/lib/shoulda/general.rb create mode 100755 test/lib/shoulda/private_helpers.rb diff --git a/.gitignore b/.gitignore index 7cd3efe..d406c47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ .DS_Store test/debug.log -test/lib/shoulda* autotest diff --git a/Rakefile b/Rakefile index 4ed5c36..2fbc276 100644 --- a/Rakefile +++ b/Rakefile @@ -10,7 +10,7 @@ end desc 'Default: run unit tests.' -task :default => :test +task :default => :test_rails desc 'Test the GroupedScope plugin.' Rake::TestTask.new(:test) do |t| diff --git a/test/lib/shoulda.rb b/test/lib/shoulda.rb new file mode 100755 index 0000000..a90ff18 --- /dev/null +++ b/test/lib/shoulda.rb @@ -0,0 +1,43 @@ +require 'shoulda/gem/shoulda' +require 'shoulda/private_helpers' +require 'shoulda/general' +require 'shoulda/active_record_helpers' +require 'shoulda/controller_tests/controller_tests.rb' +require 'yaml' + +shoulda_options = {} + +possible_config_paths = [] +possible_config_paths << File.join(ENV["HOME"], ".shoulda.conf") if ENV["HOME"] +possible_config_paths << "shoulda.conf" +possible_config_paths << File.join("test", "shoulda.conf") +possible_config_paths << File.join(RAILS_ROOT, "test", "shoulda.conf") if defined?(RAILS_ROOT) + +possible_config_paths.each do |config_file| + if File.exists? config_file + shoulda_options = YAML.load_file(config_file).symbolize_keys + break + end +end + +require 'shoulda/color' if shoulda_options[:color] + +module Test # :nodoc: all + module Unit + class TestCase + + include ThoughtBot::Shoulda::General + include ThoughtBot::Shoulda::Controller + + extend ThoughtBot::Shoulda::ActiveRecord + end + end +end + +module ActionController #:nodoc: all + module Integration + class Session + include ThoughtBot::Shoulda::General + end + end +end diff --git a/test/lib/shoulda/active_record_helpers.rb b/test/lib/shoulda/active_record_helpers.rb new file mode 100755 index 0000000..8133755 --- /dev/null +++ b/test/lib/shoulda/active_record_helpers.rb @@ -0,0 +1,576 @@ +module ThoughtBot # :nodoc: + module Shoulda # :nodoc: + # = Macro test helpers for your active record models + # + # These helpers will test most of the validations and associations for your ActiveRecord models. + # + # class UserTest < Test::Unit::TestCase + # should_require_attributes :name, :phone_number + # should_not_allow_values_for :phone_number, "abcd", "1234" + # should_allow_values_for :phone_number, "(123) 456-7890" + # + # should_protect_attributes :password + # + # should_have_one :profile + # should_have_many :dogs + # should_have_many :messes, :through => :dogs + # should_belong_to :lover + # end + # + # For all of these helpers, the last parameter may be a hash of options. + # + module ActiveRecord + # Ensures that the model cannot be saved if one of the attributes listed is not present. + # + # Options: + # * :message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /blank/ + # + # Example: + # should_require_attributes :name, :phone_number + # + def should_require_attributes(*attributes) + message = get_options!(attributes, :message) + message ||= /blank/ + klass = model_class + + attributes.each do |attribute| + should "require #{attribute} to be set" do + object = klass.new + object.send("#{attribute}=", nil) + assert !object.valid?, "#{klass.name} does not require #{attribute}." + assert object.errors.on(attribute), "#{klass.name} does not require #{attribute}." + assert_contains(object.errors.on(attribute), message) + end + end + end + + # Ensures that the model cannot be saved if one of the attributes listed is not unique. + # Requires an existing record + # + # Options: + # * :message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /taken/ + # + # Example: + # should_require_unique_attributes :keyword, :username + # + def should_require_unique_attributes(*attributes) + message, scope = get_options!(attributes, :message, :scoped_to) + message ||= /taken/ + + klass = model_class + attributes.each do |attribute| + attribute = attribute.to_sym + should "require unique value for #{attribute}#{" scoped to #{scope}" if scope}" do + assert existing = klass.find(:first), "Can't find first #{klass}" + object = klass.new + + object.send(:"#{attribute}=", existing.send(attribute)) + if scope + assert_respond_to object, :"#{scope}=", "#{klass.name} doesn't seem to have a #{scope} attribute." + object.send(:"#{scope}=", existing.send(scope)) + end + + assert !object.valid?, "#{klass.name} does not require a unique value for #{attribute}." + assert object.errors.on(attribute), "#{klass.name} does not require a unique value for #{attribute}." + + assert_contains(object.errors.on(attribute), message) + + # Now test that the object is valid when changing the scoped attribute + # TODO: There is a chance that we could change the scoped field + # to a value that's already taken. An alternative implementation + # could actually find all values for scope and create a unique + # one. + if scope + # Assume the scope is a foreign key if the field is nil + object.send(:"#{scope}=", existing.send(scope).nil? ? 1 : existing.send(scope).next) + object.errors.clear + object.valid? + assert_does_not_contain(object.errors.on(attribute), message, + "after :#{scope} set to #{object.send(scope.to_sym)}") + end + end + end + end + + # Ensures that the attribute cannot be set on mass update. + # Requires an existing record. + # + # should_protect_attributes :password, :admin_flag + # + def should_protect_attributes(*attributes) + get_options!(attributes) + klass = model_class + + attributes.each do |attribute| + attribute = attribute.to_sym + should "protect #{attribute} from mass updates" do + protected_attributes = klass.protected_attributes || [] + assert protected_attributes.include?(attribute.to_s), + "#{klass} is protecting #{protected_attributes.to_a.to_sentence}, but not #{attribute}." + end + end + end + + # Ensures that the attribute cannot be set to the given values + # Requires an existing record + # + # Options: + # * :message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /invalid/ + # + # Example: + # should_not_allow_values_for :isbn, "bad 1", "bad 2" + # + def should_not_allow_values_for(attribute, *bad_values) + message = get_options!(bad_values, :message) + message ||= /invalid/ + klass = model_class + bad_values.each do |v| + should "not allow #{attribute} to be set to #{v.inspect}" do + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", v) + assert !object.save, "Saved #{klass} with #{attribute} set to \"#{v}\"" + assert object.errors.on(attribute), "There are no errors set on #{attribute} after being set to \"#{v}\"" + assert_contains(object.errors.on(attribute), message, "when set to \"#{v}\"") + end + end + end + + # Ensures that the attribute can be set to the given values. + # Requires an existing record + # + # Example: + # should_allow_values_for :isbn, "isbn 1 2345 6789 0", "ISBN 1-2345-6789-0" + # + def should_allow_values_for(attribute, *good_values) + get_options!(good_values) + klass = model_class + good_values.each do |v| + should "allow #{attribute} to be set to #{v.inspect}" do + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", v) + object.save + assert_nil object.errors.on(attribute) + end + end + end + + # Ensures that the length of the attribute is in the given range + # Requires an existing record + # + # Options: + # * :short_message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /short/ + # * :long_message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /long/ + # + # Example: + # should_ensure_length_in_range :password, (6..20) + # + def should_ensure_length_in_range(attribute, range, opts = {}) + short_message, long_message = get_options!([opts], :short_message, :long_message) + short_message ||= /short/ + long_message ||= /long/ + + klass = model_class + min_length = range.first + max_length = range.last + same_length = (min_length == max_length) + + if min_length > 0 + should "not allow #{attribute} to be less than #{min_length} chars long" do + min_value = "x" * (min_length - 1) + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", min_value) + assert !object.save, "Saved #{klass} with #{attribute} set to \"#{min_value}\"" + assert object.errors.on(attribute), + "There are no errors set on #{attribute} after being set to \"#{min_value}\"" + assert_contains(object.errors.on(attribute), short_message, "when set to \"#{min_value}\"") + end + end + + if min_length > 0 + should "allow #{attribute} to be exactly #{min_length} chars long" do + min_value = "x" * min_length + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", min_value) + object.save + assert_does_not_contain(object.errors.on(attribute), short_message, "when set to \"#{min_value}\"") + end + end + + should "not allow #{attribute} to be more than #{max_length} chars long" do + max_value = "x" * (max_length + 1) + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", max_value) + assert !object.save, "Saved #{klass} with #{attribute} set to \"#{max_value}\"" + assert object.errors.on(attribute), + "There are no errors set on #{attribute} after being set to \"#{max_value}\"" + assert_contains(object.errors.on(attribute), long_message, "when set to \"#{max_value}\"") + end + + unless same_length + should "allow #{attribute} to be exactly #{max_length} chars long" do + max_value = "x" * max_length + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", max_value) + object.save + assert_does_not_contain(object.errors.on(attribute), long_message, "when set to \"#{max_value}\"") + end + end + end + + # Ensures that the length of the attribute is at least a certain length + # Requires an existing record + # + # Options: + # * :short_message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /short/ + # + # Example: + # should_ensure_length_at_least :name, 3 + # + def should_ensure_length_at_least(attribute, min_length, opts = {}) + short_message = get_options!([opts], :short_message) + short_message ||= /short/ + + klass = model_class + + if min_length > 0 + min_value = "x" * (min_length - 1) + should "not allow #{attribute} to be less than #{min_length} chars long" do + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", min_value) + assert !object.save, "Saved #{klass} with #{attribute} set to \"#{min_value}\"" + assert object.errors.on(attribute), "There are no errors set on #{attribute} after being set to \"#{min_value}\"" + assert_contains(object.errors.on(attribute), short_message, "when set to \"#{min_value}\"") + end + end + should "allow #{attribute} to be at least #{min_length} chars long" do + valid_value = "x" * (min_length) + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", valid_value) + assert object.save, "Could not save #{klass} with #{attribute} set to \"#{valid_value}\"" + end + end + + # Ensure that the attribute is in the range specified + # Requires an existing record + # + # Options: + # * :low_message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /included/ + # * :high_message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /included/ + # + # Example: + # should_ensure_value_in_range :age, (0..100) + # + def should_ensure_value_in_range(attribute, range, opts = {}) + low_message, high_message = get_options!([opts], :low_message, :high_message) + low_message ||= /included/ + high_message ||= /included/ + + klass = model_class + min = range.first + max = range.last + + should "not allow #{attribute} to be less than #{min}" do + v = min - 1 + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", v) + assert !object.save, "Saved #{klass} with #{attribute} set to \"#{v}\"" + assert object.errors.on(attribute), "There are no errors set on #{attribute} after being set to \"#{v}\"" + assert_contains(object.errors.on(attribute), low_message, "when set to \"#{v}\"") + end + + should "allow #{attribute} to be #{min}" do + v = min + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", v) + object.save + assert_does_not_contain(object.errors.on(attribute), low_message, "when set to \"#{v}\"") + end + + should "not allow #{attribute} to be more than #{max}" do + v = max + 1 + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", v) + assert !object.save, "Saved #{klass} with #{attribute} set to \"#{v}\"" + assert object.errors.on(attribute), "There are no errors set on #{attribute} after being set to \"#{v}\"" + assert_contains(object.errors.on(attribute), high_message, "when set to \"#{v}\"") + end + + should "allow #{attribute} to be #{max}" do + v = max + assert object = klass.find(:first), "Can't find first #{klass}" + object.send("#{attribute}=", v) + object.save + assert_does_not_contain(object.errors.on(attribute), high_message, "when set to \"#{v}\"") + end + end + + # Ensure that the attribute is numeric + # Requires an existing record + # + # Options: + # * :message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /number/ + # + # Example: + # should_only_allow_numeric_values_for :age + # + def should_only_allow_numeric_values_for(*attributes) + message = get_options!(attributes, :message) + message ||= /number/ + klass = model_class + attributes.each do |attribute| + attribute = attribute.to_sym + should "only allow numeric values for #{attribute}" do + assert object = klass.find(:first), "Can't find first #{klass}" + object.send(:"#{attribute}=", "abcd") + assert !object.valid?, "Instance is still valid" + assert_contains(object.errors.on(attribute), message) + end + end + end + + # Ensures that the has_many relationship exists. Will also test that the + # associated table has the required columns. Works with polymorphic + # associations. + # + # Options: + # * :through - association name for has_many :through + # + # Example: + # should_have_many :friends + # should_have_many :enemies, :through => :friends + # + def should_have_many(*associations) + through = get_options!(associations, :through) + klass = model_class + associations.each do |association| + name = "have many #{association}" + name += " through #{through}" if through + should name do + reflection = klass.reflect_on_association(association) + assert reflection, "#{klass.name} does not have any relationship to #{association}" + assert_equal :has_many, reflection.macro + + if through + through_reflection = klass.reflect_on_association(through) + assert through_reflection, "#{klass.name} does not have any relationship to #{through}" + assert_equal(through, reflection.options[:through]) + end + + unless reflection.options[:through] + # This is not a through association, so check for the existence of the foreign key on the other table + if reflection.options[:foreign_key] + fk = reflection.options[:foreign_key] + elsif reflection.options[:as] + fk = reflection.options[:as].to_s.foreign_key + else + fk = reflection.primary_key_name + end + associated_klass = (reflection.options[:class_name] || association.to_s.classify).constantize + assert associated_klass.column_names.include?(fk.to_s), "#{associated_klass.name} does not have a #{fk} foreign key." + end + end + end + end + + # Ensure that the has_one relationship exists. Will also test that the + # associated table has the required columns. Works with polymorphic + # associations. + # + # Example: + # should_have_one :god # unless hindu + # + def should_have_one(*associations) + get_options!(associations) + klass = model_class + associations.each do |association| + should "have one #{association}" do + reflection = klass.reflect_on_association(association) + assert reflection, "#{klass.name} does not have any relationship to #{association}" + assert_equal :has_one, reflection.macro + + associated_klass = (reflection.options[:class_name] || association.to_s.camelize).constantize + + if reflection.options[:foreign_key] + fk = reflection.options[:foreign_key] + elsif reflection.options[:as] + fk = reflection.options[:as].to_s.foreign_key + fk_type = fk.gsub(/_id$/, '_type') + assert associated_klass.column_names.include?(fk_type), + "#{associated_klass.name} does not have a #{fk_type} column." + else + fk = klass.name.foreign_key + end + assert associated_klass.column_names.include?(fk.to_s), + "#{associated_klass.name} does not have a #{fk} foreign key." + end + end + end + + # Ensures that the has_and_belongs_to_many relationship exists, and that the join + # table is in place. + # + # should_have_and_belong_to_many :posts, :cars + # + def should_have_and_belong_to_many(*associations) + get_options!(associations) + klass = model_class + + associations.each do |association| + should "should have and belong to many #{association}" do + reflection = klass.reflect_on_association(association) + assert reflection, "#{klass.name} does not have any relationship to #{association}" + assert_equal :has_and_belongs_to_many, reflection.macro + table = reflection.options[:join_table] + assert ::ActiveRecord::Base.connection.tables.include?(table), "table #{table} doesn't exist" + end + end + end + + # Ensure that the belongs_to relationship exists. + # + # should_belong_to :parent + # + def should_belong_to(*associations) + get_options!(associations) + klass = model_class + associations.each do |association| + should "belong_to #{association}" do + reflection = klass.reflect_on_association(association) + assert reflection, "#{klass.name} does not have any relationship to #{association}" + assert_equal :belongs_to, reflection.macro + + unless reflection.options[:polymorphic] + associated_klass = (reflection.options[:class_name] || association.to_s.classify).constantize + fk = reflection.options[:foreign_key] || reflection.primary_key_name + assert klass.column_names.include?(fk.to_s), "#{klass.name} does not have a #{fk} foreign key." + end + end + end + end + + # Ensure that the given class methods are defined on the model. + # + # should_have_class_methods :find, :destroy + # + def should_have_class_methods(*methods) + get_options!(methods) + klass = model_class + methods.each do |method| + should "respond to class method ##{method}" do + assert_respond_to klass, method, "#{klass.name} does not have class method #{method}" + end + end + end + + # Ensure that the given instance methods are defined on the model. + # + # should_have_instance_methods :email, :name, :name= + # + def should_have_instance_methods(*methods) + get_options!(methods) + klass = model_class + methods.each do |method| + should "respond to instance method ##{method}" do + assert_respond_to klass.new, method, "#{klass.name} does not have instance method #{method}" + end + end + end + + # Ensure that the given columns are defined on the models backing SQL table. + # + # should_have_db_columns :id, :email, :name, :created_at + # + def should_have_db_columns(*columns) + column_type = get_options!(columns, :type) + klass = model_class + columns.each do |name| + test_name = "have column #{name}" + test_name += " of type #{column_type}" if column_type + should test_name do + column = klass.columns.detect {|c| c.name == name.to_s } + assert column, "#{klass.name} does not have column #{name}" + end + end + end + + # Ensure that the given column is defined on the models backing SQL table. The options are the same as + # the instance variables defined on the column definition: :precision, :limit, :default, :null, + # :primary, :type, :scale, and :sql_type. + # + # should_have_db_column :email, :type => "string", :default => nil, :precision => nil, :limit => 255, + # :null => true, :primary => false, :scale => nil, :sql_type => 'varchar(255)' + # + def should_have_db_column(name, opts = {}) + klass = model_class + test_name = "have column named :#{name}" + test_name += " with options " + opts.inspect unless opts.empty? + should test_name do + column = klass.columns.detect {|c| c.name == name.to_s } + assert column, "#{klass.name} does not have column #{name}" + opts.each do |k, v| + assert_equal column.instance_variable_get("@#{k}").to_s, v.to_s, ":#{name} column on table for #{klass} does not match option :#{k}" + end + end + end + + # Ensures that there are DB indices on the given columns or tuples of columns. + # Also aliased to should_have_index for readability + # + # should_have_indices :email, :name, [:commentable_type, :commentable_id] + # should_have_index :age + # + def should_have_indices(*columns) + table = model_class.name.tableize + indices = ::ActiveRecord::Base.connection.indexes(table).map(&:columns) + + columns.each do |column| + should "have index on #{table} for #{column.inspect}" do + columns = [column].flatten.map(&:to_s) + assert_contains(indices, columns) + end + end + end + + alias_method :should_have_index, :should_have_indices + + # Ensures that the model cannot be saved if one of the attributes listed is not accepted. + # + # Options: + # * :message - value the test expects to find in errors.on(:attribute). + # Regexp or string. Default = /must be accepted/ + # + # Example: + # should_require_acceptance_of :eula + # + def should_require_acceptance_of(*attributes) + message = get_options!(attributes, :message) + message ||= /must be accepted/ + klass = model_class + + attributes.each do |attribute| + should "require #{attribute} to be accepted" do + object = klass.new + object.send("#{attribute}=", false) + + assert !object.valid?, "#{klass.name} does not require acceptance of #{attribute}." + assert object.errors.on(attribute), "#{klass.name} does not require acceptance of #{attribute}." + assert_contains(object.errors.on(attribute), message) + end + end + end + + private + + include ThoughtBot::Shoulda::Private + end + end +end diff --git a/test/lib/shoulda/color.rb b/test/lib/shoulda/color.rb new file mode 100755 index 0000000..1ccfad2 --- /dev/null +++ b/test/lib/shoulda/color.rb @@ -0,0 +1,77 @@ +require 'test/unit/ui/console/testrunner' + +# Completely stolen from redgreen gem +# +# Adds colored output to your tests. Specify color: true in +# your ~/.shoulda.conf file to enable. +# +# *Bug*: for some reason, this adds another line of output to the end of +# every rake task, as though there was another (empty) set of tests. +# A fix would be most welcome. +# +module ThoughtBot::Shoulda::Color + COLORS = { :clear => 0, :red => 31, :green => 32, :yellow => 33 } # :nodoc: + def self.method_missing(color_name, *args) # :nodoc: + color(color_name) + args.first + color(:clear) + end + def self.color(color) # :nodoc: + "\e[#{COLORS[color.to_sym]}m" + end +end + +module Test # :nodoc: + module Unit # :nodoc: + class TestResult # :nodoc: + alias :old_to_s :to_s + def to_s + if old_to_s =~ /\d+ tests, \d+ assertions, (\d+) failures, (\d+) errors/ + ThoughtBot::Shoulda::Color.send($1.to_i != 0 || $2.to_i != 0 ? :red : :green, $&) + end + end + end + + class AutoRunner # :nodoc: + alias :old_initialize :initialize + def initialize(standalone) + old_initialize(standalone) + @runner = proc do |r| + Test::Unit::UI::Console::RedGreenTestRunner + end + end + end + + class Failure # :nodoc: + alias :old_long_display :long_display + def long_display + # old_long_display.sub('Failure', ThoughtBot::Shoulda::Color.red('Failure')) + ThoughtBot::Shoulda::Color.red(old_long_display) + end + end + + class Error # :nodoc: + alias :old_long_display :long_display + def long_display + # old_long_display.sub('Error', ThoughtBot::Shoulda::Color.yellow('Error')) + ThoughtBot::Shoulda::Color.yellow(old_long_display) + end + end + + module UI # :nodoc: + module Console # :nodoc: + class RedGreenTestRunner < Test::Unit::UI::Console::TestRunner # :nodoc: + def output_single(something, level=NORMAL) + return unless (output?(level)) + something = case something + when '.' then ThoughtBot::Shoulda::Color.green('.') + when 'F' then ThoughtBot::Shoulda::Color.red("F") + when 'E' then ThoughtBot::Shoulda::Color.yellow("E") + else something + end + @io.write(something) + @io.flush + end + end + end + end + end +end diff --git a/test/lib/shoulda/controller_tests/controller_tests.rb b/test/lib/shoulda/controller_tests/controller_tests.rb new file mode 100755 index 0000000..f0059c0 --- /dev/null +++ b/test/lib/shoulda/controller_tests/controller_tests.rb @@ -0,0 +1,467 @@ +module ThoughtBot # :nodoc: + module Shoulda # :nodoc: + module Controller + def self.included(other) # :nodoc: + other.class_eval do + extend ThoughtBot::Shoulda::Controller::ClassMethods + include ThoughtBot::Shoulda::Controller::InstanceMethods + ThoughtBot::Shoulda::Controller::ClassMethods::VALID_FORMATS.each do |format| + include "ThoughtBot::Shoulda::Controller::#{format.to_s.upcase}".constantize + end + end + end + + # = Macro test helpers for your controllers + # + # By using the macro helpers you can quickly and easily create concise and easy to read test suites. + # + # This code segment: + # context "on GET to :show for first record" do + # setup do + # get :show, :id => 1 + # end + # + # should_assign_to :user + # should_respond_with :success + # should_render_template :show + # should_not_set_the_flash + # + # should "do something else really cool" do + # assert_equal 1, assigns(:user).id + # end + # end + # + # Would produce 5 tests for the +show+ action + # + # Furthermore, the should_be_restful helper will create an entire set of tests which will verify that your + # controller responds restfully to a variety of requested formats. + module ClassMethods + # Formats tested by #should_be_restful. Defaults to [:html, :xml] + VALID_FORMATS = Dir.glob(File.join(File.dirname(__FILE__), 'formats', '*.rb')).map { |f| File.basename(f, '.rb') }.map(&:to_sym) # :doc: + VALID_FORMATS.each {|f| require "shoulda/controller_tests/formats/#{f}.rb"} + + # Actions tested by #should_be_restful + VALID_ACTIONS = [:index, :show, :new, :edit, :create, :update, :destroy] # :doc: + + # A ResourceOptions object is passed into should_be_restful in order to configure the tests for your controller. + # + # Example: + # class UsersControllerTest < Test::Unit::TestCase + # load_all_fixtures + # + # def setup + # ...normal setup code... + # @user = User.find(:first) + # end + # + # should_be_restful do |resource| + # resource.identifier = :id + # resource.klass = User + # resource.object = :user + # resource.parent = [] + # resource.actions = [:index, :show, :new, :edit, :update, :create, :destroy] + # resource.formats = [:html, :xml] + # + # resource.create.params = { :name => "bob", :email => 'bob@bob.com', :age => 13} + # resource.update.params = { :name => "sue" } + # + # resource.create.redirect = "user_url(@user)" + # resource.update.redirect = "user_url(@user)" + # resource.destroy.redirect = "users_url" + # + # resource.create.flash = /created/i + # resource.update.flash = /updated/i + # resource.destroy.flash = /removed/i + # end + # end + # + # Whenever possible, the resource attributes will be set to sensible defaults. + # + class ResourceOptions + # Configuration options for the create, update, destroy actions under should_be_restful + class ActionOptions + # String evaled to get the target of the redirection. + # All of the instance variables set by the controller will be available to the + # evaled code. + # + # Example: + # resource.create.redirect = "user_url(@user.company, @user)" + # + # Defaults to a generated url based on the name of the controller, the action, and the resource.parents list. + attr_accessor :redirect + + # String or Regexp describing a value expected in the flash. Will match against any flash key. + # + # Defaults: + # destroy:: /removed/ + # create:: /created/ + # update:: /updated/ + attr_accessor :flash + + # Hash describing the params that should be sent in with this action. + attr_accessor :params + end + + # Configuration options for the denied actions under should_be_restful + # + # Example: + # context "The public" do + # setup do + # @request.session[:logged_in] = false + # end + # + # should_be_restful do |resource| + # resource.parent = :user + # + # resource.denied.actions = [:index, :show, :edit, :new, :create, :update, :destroy] + # resource.denied.flash = /get outta here/i + # resource.denied.redirect = 'new_session_url' + # end + # end + # + class DeniedOptions + # String evaled to get the target of the redirection. + # All of the instance variables set by the controller will be available to the + # evaled code. + # + # Example: + # resource.create.redirect = "user_url(@user.company, @user)" + attr_accessor :redirect + + # String or Regexp describing a value expected in the flash. Will match against any flash key. + # + # Example: + # resource.create.flash = /created/ + attr_accessor :flash + + # Actions that should be denied (only used by resource.denied). Note that these actions will + # only be tested if they are also listed in +resource.actions+ + # The special value of :all will deny all of the REST actions. + attr_accessor :actions + end + + # Name of key in params that references the primary key. + # Will almost always be :id (default), unless you are using a plugin or have patched rails. + attr_accessor :identifier + + # Name of the ActiveRecord class this resource is responsible for. Automatically determined from + # test class if not explicitly set. UserTest => "User" + attr_accessor :klass + + # Name of the instantiated ActiveRecord object that should be used by some of the tests. + # Defaults to the underscored name of the AR class. CompanyManager => :company_manager + attr_accessor :object + + # Name of the parent AR objects. Can be set as parent= or parents=, and can take either + # the name of the parent resource (if there's only one), or an array of names (if there's + # more than one). + # + # Example: + # # in the routes... + # map.resources :companies do + # map.resources :people do + # map.resources :limbs + # end + # end + # + # # in the tests... + # class PeopleControllerTest < Test::Unit::TestCase + # should_be_restful do |resource| + # resource.parent = :companies + # end + # end + # + # class LimbsControllerTest < Test::Unit::TestCase + # should_be_restful do |resource| + # resource.parents = [:companies, :people] + # end + # end + attr_accessor :parent + alias parents parent + alias parents= parent= + + # Actions that should be tested. Must be a subset of VALID_ACTIONS (default). + # Tests for each actionw will only be generated if the action is listed here. + # The special value of :all will test all of the REST actions. + # + # Example (for a read-only controller): + # resource.actions = [:show, :index] + attr_accessor :actions + + # Formats that should be tested. Must be a subset of VALID_FORMATS (default). + # Each action will be tested against the formats listed here. The special value + # of :all will test all of the supported formats. + # + # Example: + # resource.actions = [:html, :xml] + attr_accessor :formats + + # ActionOptions object specifying options for the create action. + attr_accessor :create + + # ActionOptions object specifying options for the update action. + attr_accessor :update + + # ActionOptions object specifying options for the desrtoy action. + attr_accessor :destroy + + # DeniedOptions object specifying which actions should return deny a request, and what should happen in that case. + attr_accessor :denied + + def initialize # :nodoc: + @create = ActionOptions.new + @update = ActionOptions.new + @destroy = ActionOptions.new + @denied = DeniedOptions.new + + @create.flash ||= /created/i + @update.flash ||= /updated/i + @destroy.flash ||= /removed/i + @denied.flash ||= /denied/i + + @create.params ||= {} + @update.params ||= {} + + @actions = VALID_ACTIONS + @formats = VALID_FORMATS + @denied.actions = [] + end + + def normalize!(target) # :nodoc: + @denied.actions = VALID_ACTIONS if @denied.actions == :all + @actions = VALID_ACTIONS if @actions == :all + @formats = VALID_FORMATS if @formats == :all + + @denied.actions = @denied.actions.map(&:to_sym) + @actions = @actions.map(&:to_sym) + @formats = @formats.map(&:to_sym) + + ensure_valid_members(@actions, VALID_ACTIONS, 'actions') + ensure_valid_members(@denied.actions, VALID_ACTIONS, 'denied.actions') + ensure_valid_members(@formats, VALID_FORMATS, 'formats') + + @identifier ||= :id + @klass ||= target.name.gsub(/ControllerTest$/, '').singularize.constantize + @object ||= @klass.name.tableize.singularize + @parent ||= [] + @parent = [@parent] unless @parent.is_a? Array + + collection_helper = [@parent, @object.to_s.pluralize, 'url'].flatten.join('_') + collection_args = @parent.map {|n| "@#{object}.#{n}"}.join(', ') + @destroy.redirect ||= "#{collection_helper}(#{collection_args})" + + member_helper = [@parent, @object, 'url'].flatten.join('_') + member_args = [@parent.map {|n| "@#{object}.#{n}"}, "@#{object}"].flatten.join(', ') + @create.redirect ||= "#{member_helper}(#{member_args})" + @update.redirect ||= "#{member_helper}(#{member_args})" + @denied.redirect ||= "new_session_url" + end + + private + + def ensure_valid_members(ary, valid_members, name) # :nodoc: + invalid = ary - valid_members + raise ArgumentError, "Unsupported #{name}: #{invalid.inspect}" unless invalid.empty? + end + end + + # :section: should_be_restful + # Generates a full suite of tests for a restful controller. + # + # The following definition will generate tests for the +index+, +show+, +new+, + # +edit+, +create+, +update+ and +destroy+ actions, in both +html+ and +xml+ formats: + # + # should_be_restful do |resource| + # resource.parent = :user + # + # resource.create.params = { :title => "first post", :body => 'blah blah blah'} + # resource.update.params = { :title => "changed" } + # end + # + # This generates about 40 tests, all of the format: + # "on GET to :show should assign @user." + # "on GET to :show should not set the flash." + # "on GET to :show should render 'show' template." + # "on GET to :show should respond with success." + # "on GET to :show as xml should assign @user." + # "on GET to :show as xml should have ContentType set to 'application/xml'." + # "on GET to :show as xml should respond with success." + # "on GET to :show as xml should return as the root element." + # The +resource+ parameter passed into the block is a ResourceOptions object, and + # is used to configure the tests for the details of your resources. + # + def should_be_restful(&blk) # :yields: resource + resource = ResourceOptions.new + blk.call(resource) + resource.normalize!(self) + + resource.formats.each do |format| + resource.actions.each do |action| + if self.respond_to? :"make_#{action}_#{format}_tests" + self.send(:"make_#{action}_#{format}_tests", resource) + else + should "test #{action} #{format}" do + flunk "Test for #{action} as #{format} not implemented" + end + end + end + end + end + + # :section: Test macros + + # Macro that creates a test asserting that the flash contains the given value. + # val can be a String, a Regex, or nil (indicating that the flash should not be set) + # + # Example: + # + # should_set_the_flash_to "Thank you for placing this order." + # should_set_the_flash_to /created/i + # should_set_the_flash_to nil + def should_set_the_flash_to(val) + if val + should "have #{val.inspect} in the flash" do + assert_contains flash.values, val, ", Flash: #{flash.inspect}" + end + else + should "not set the flash" do + assert_equal({}, flash, "Flash was set to:\n#{flash.inspect}") + end + end + end + + # Macro that creates a test asserting that the flash is empty. Same as + # @should_set_the_flash_to nil@ + def should_not_set_the_flash + should_set_the_flash_to nil + end + + # Macro that creates a test asserting that the controller assigned to @name + # + # Example: + # + # should_assign_to :user + def should_assign_to(name) + should "assign @#{name}" do + assert assigns(name.to_sym), "The action isn't assigning to @#{name}" + end + end + + # Macro that creates a test asserting that the controller did not assign to @name + # + # Example: + # + # should_not_assign_to :user + def should_not_assign_to(name) + should "not assign to @#{name}" do + assert !assigns(name.to_sym), "@#{name} was visible" + end + end + + # Macro that creates a test asserting that the controller responded with a 'response' status code. + # Example: + # + # should_respond_with :success + def should_respond_with(response) + should "respond with #{response}" do + assert_response response + end + end + + # Macro that creates a test asserting that the controller rendered the given template. + # Example: + # + # should_render_template :new + def should_render_template(template) + should "render template #{template.inspect}" do + assert_template template.to_s + end + end + + # Macro that creates a test asserting that the controller returned a redirect to the given path. + # The given string is evaled to produce the resulting redirect path. All of the instance variables + # set by the controller are available to the evaled string. + # Example: + # + # should_redirect_to '"/"' + # should_redirect_to "users_url(@user)" + def should_redirect_to(url) + should "redirect to #{url.inspect}" do + instantiate_variables_from_assigns do + assert_redirected_to eval(url, self.send(:binding), __FILE__, __LINE__) + end + end + end + + # Macro that creates a test asserting that the rendered view contains a
element. + def should_render_a_form + should "display a form" do + assert_select "form", true, "The template doesn't contain a element" + end + end + end + + module InstanceMethods # :nodoc: + + private # :enddoc: + + SPECIAL_INSTANCE_VARIABLES = %w{ + _cookies + _flash + _headers + _params + _request + _response + _session + action_name + before_filter_chain_aborted + cookies + flash + headers + ignore_missing_templates + logger + params + request + request_origin + response + session + template + template_class + template_root + url + variables_added + }.map(&:to_s) + + def instantiate_variables_from_assigns(*names, &blk) + old = {} + names = (@response.template.assigns.keys - SPECIAL_INSTANCE_VARIABLES) if names.empty? + names.each do |name| + old[name] = instance_variable_get("@#{name}") + instance_variable_set("@#{name}", assigns(name.to_sym)) + end + blk.call + names.each do |name| + instance_variable_set("@#{name}", old[name]) + end + end + + def get_existing_record(res) # :nodoc: + returning(instance_variable_get("@#{res.object}")) do |record| + assert(record, "This test requires you to set @#{res.object} in your setup block") + end + end + + def make_parent_params(resource, record = nil, parent_names = nil) # :nodoc: + parent_names ||= resource.parents.reverse + return {} if parent_names == [] # Base case + parent_name = parent_names.shift + parent = record ? record.send(parent_name) : parent_name.to_s.classify.constantize.find(:first) + + { :"#{parent_name}_id" => parent.to_param }.merge(make_parent_params(resource, parent, parent_names)) + end + + end + end + end +end + diff --git a/test/lib/shoulda/controller_tests/formats/html.rb b/test/lib/shoulda/controller_tests/formats/html.rb new file mode 100755 index 0000000..ba92bba --- /dev/null +++ b/test/lib/shoulda/controller_tests/formats/html.rb @@ -0,0 +1,201 @@ +module ThoughtBot # :nodoc: + module Shoulda # :nodoc: + module Controller # :nodoc: + module HTML # :nodoc: all + def self.included(other) + other.class_eval do + extend ThoughtBot::Shoulda::Controller::HTML::ClassMethods + end + end + + module ClassMethods + def controller_name_from_class + self.name.gsub(/Test/, '') + end + + def make_show_html_tests(res) + context "on GET to #{controller_name_from_class}#show" do + setup do + record = get_existing_record(res) + parent_params = make_parent_params(res, record) + get :show, parent_params.merge({ res.identifier => record.to_param }) + end + + if res.denied.actions.include?(:show) + should_not_assign_to res.object + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + else + should_assign_to res.object + should_respond_with :success + should_render_template :show + should_not_set_the_flash + end + end + end + + def make_edit_html_tests(res) + context "on GET to #{controller_name_from_class}#edit" do + setup do + @record = get_existing_record(res) + parent_params = make_parent_params(res, @record) + get :edit, parent_params.merge({ res.identifier => @record.to_param }) + end + + if res.denied.actions.include?(:edit) + should_not_assign_to res.object + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + else + should_assign_to res.object + should_respond_with :success + should_render_template :edit + should_not_set_the_flash + should_render_a_form + should "set @#{res.object} to requested instance" do + assert_equal @record, assigns(res.object) + end + end + end + end + + def make_index_html_tests(res) + context "on GET to #{controller_name_from_class}#index" do + setup do + record = get_existing_record(res) rescue nil + parent_params = make_parent_params(res, record) + get(:index, parent_params) + end + + if res.denied.actions.include?(:index) + should_not_assign_to res.object.to_s.pluralize + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + else + should_respond_with :success + should_assign_to res.object.to_s.pluralize + should_render_template :index + should_not_set_the_flash + end + end + end + + def make_new_html_tests(res) + context "on GET to #{controller_name_from_class}#new" do + setup do + record = get_existing_record(res) rescue nil + parent_params = make_parent_params(res, record) + get(:new, parent_params) + end + + if res.denied.actions.include?(:new) + should_not_assign_to res.object + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + else + should_respond_with :success + should_assign_to res.object + should_not_set_the_flash + should_render_template :new + should_render_a_form + end + end + end + + def make_destroy_html_tests(res) + context "on DELETE to #{controller_name_from_class}#destroy" do + setup do + @record = get_existing_record(res) + parent_params = make_parent_params(res, @record) + delete :destroy, parent_params.merge({ res.identifier => @record.to_param }) + end + + if res.denied.actions.include?(:destroy) + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + + should "not destroy record" do + assert_nothing_raised { assert @record.reload } + end + else + should_set_the_flash_to res.destroy.flash + if res.destroy.redirect.is_a? Symbol + should_respond_with res.destroy.redirect + else + should_redirect_to res.destroy.redirect + end + + should "destroy record" do + assert_raises(::ActiveRecord::RecordNotFound, "@#{res.object} was not destroyed.") do + @record.reload + end + end + end + end + end + + def make_create_html_tests(res) + context "on POST to #{controller_name_from_class}#create with #{res.create.params.inspect}" do + setup do + record = get_existing_record(res) rescue nil + parent_params = make_parent_params(res, record) + @count = res.klass.count + post :create, parent_params.merge(res.object => res.create.params) + end + + if res.denied.actions.include?(:create) + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + should_not_assign_to res.object + + should "not create new record" do + assert_equal @count, res.klass.count + end + else + should_assign_to res.object + should_set_the_flash_to res.create.flash + if res.create.redirect.is_a? Symbol + should_respond_with res.create.redirect + else + should_redirect_to res.create.redirect + end + + should "not have errors on @#{res.object}" do + assert_equal [], pretty_error_messages(assigns(res.object)), "@#{res.object} has errors:" + end + end + end + end + + def make_update_html_tests(res) + context "on PUT to #{controller_name_from_class}#update with #{res.create.params.inspect}" do + setup do + @record = get_existing_record(res) + parent_params = make_parent_params(res, @record) + put :update, parent_params.merge(res.identifier => @record.to_param, res.object => res.update.params) + end + + if res.denied.actions.include?(:update) + should_not_assign_to res.object + should_redirect_to res.denied.redirect + should_set_the_flash_to res.denied.flash + else + should_assign_to res.object + should_set_the_flash_to(res.update.flash) + if res.update.redirect.is_a? Symbol + should_respond_with res.update.redirect + else + should_redirect_to res.update.redirect + end + + should "not have errors on @#{res.object}" do + assert_equal [], pretty_error_messages(assigns(res.object)), "@#{res.object} has errors:" + end + end + end + end + end + end + end + end +end diff --git a/test/lib/shoulda/controller_tests/formats/xml.rb b/test/lib/shoulda/controller_tests/formats/xml.rb new file mode 100755 index 0000000..f3c16f1 --- /dev/null +++ b/test/lib/shoulda/controller_tests/formats/xml.rb @@ -0,0 +1,170 @@ +module ThoughtBot # :nodoc: + module Shoulda # :nodoc: + module Controller # :nodoc: + module XML + def self.included(other) #:nodoc: + other.class_eval do + extend ThoughtBot::Shoulda::Controller::XML::ClassMethods + end + end + + module ClassMethods + # Macro that creates a test asserting that the controller responded with an XML content-type + # and that the XML contains ++ as the root element. + def should_respond_with_xml_for(name = nil) + should "have ContentType set to 'application/xml'" do + assert_xml_response + end + + if name + should "return <#{name}/> as the root element" do + body = @response.body.first(100).map {|l| " #{l}"} + assert_select name.to_s.dasherize, 1, "Body:\n#{body}...\nDoes not have <#{name}/> as the root element." + end + end + end + alias should_respond_with_xml should_respond_with_xml_for + + protected + + def make_show_xml_tests(res) # :nodoc: + context "on GET to #{controller_name_from_class}#show as xml" do + setup do + request_xml + record = get_existing_record(res) + parent_params = make_parent_params(res, record) + get :show, parent_params.merge({ res.identifier => record.to_param }) + end + + if res.denied.actions.include?(:show) + should_not_assign_to res.object + should_respond_with 401 + else + should_assign_to res.object + should_respond_with :success + should_respond_with_xml_for res.object + end + end + end + + def make_edit_xml_tests(res) # :nodoc: + # XML doesn't need an :edit action + end + + def make_new_xml_tests(res) # :nodoc: + # XML doesn't need a :new action + end + + def make_index_xml_tests(res) # :nodoc: + context "on GET to #{controller_name_from_class}#index as xml" do + setup do + request_xml + parent_params = make_parent_params(res) + get(:index, parent_params) + end + + if res.denied.actions.include?(:index) + should_not_assign_to res.object.to_s.pluralize + should_respond_with 401 + else + should_respond_with :success + should_respond_with_xml_for res.object.to_s.pluralize + should_assign_to res.object.to_s.pluralize + end + end + end + + def make_destroy_xml_tests(res) # :nodoc: + context "on DELETE to #{controller_name_from_class}#destroy as xml" do + setup do + request_xml + @record = get_existing_record(res) + parent_params = make_parent_params(res, @record) + delete :destroy, parent_params.merge({ res.identifier => @record.to_param }) + end + + if res.denied.actions.include?(:destroy) + should_respond_with 401 + + should "not destroy record" do + assert @record.reload + end + else + should "destroy record" do + assert_raises(::ActiveRecord::RecordNotFound, "@#{res.object} was not destroyed.") do + @record.reload + end + end + end + end + end + + def make_create_xml_tests(res) # :nodoc: + context "on POST to #{controller_name_from_class}#create as xml" do + setup do + request_xml + parent_params = make_parent_params(res) + @count = res.klass.count + post :create, parent_params.merge(res.object => res.create.params) + end + + if res.denied.actions.include?(:create) + should_respond_with 401 + should_not_assign_to res.object + + should "not create new record" do + assert_equal @count, res.klass.count + end + else + should_assign_to res.object + + should "not have errors on @#{res.object}" do + assert_equal [], pretty_error_messages(assigns(res.object)), "@#{res.object} has errors:" + end + end + end + end + + def make_update_xml_tests(res) # :nodoc: + context "on PUT to #{controller_name_from_class}#update as xml" do + setup do + request_xml + @record = get_existing_record(res) + parent_params = make_parent_params(res, @record) + put :update, parent_params.merge(res.identifier => @record.to_param, res.object => res.update.params) + end + + if res.denied.actions.include?(:update) + should_not_assign_to res.object + should_respond_with 401 + else + should_assign_to res.object + + should "not have errors on @#{res.object}" do + assert_equal [], assigns(res.object).errors.full_messages, "@#{res.object} has errors:" + end + end + end + end + end + + # Sets the next request's format to 'application/xml' + def request_xml + @request.accept = "application/xml" + end + + # Asserts that the controller's response was 'application/xml' + def assert_xml_response + content_type = (@response.headers["Content-Type"] || @response.headers["type"]).to_s + regex = %r{\bapplication/xml\b} + + msg = "Content Type '#{content_type.inspect}' doesn't match '#{regex.inspect}'\n" + msg += "Body: #{@response.body.first(100).chomp} ..." + + assert_match regex, content_type, msg + end + + end + end + end +end diff --git a/test/lib/shoulda/gem/proc_extensions.rb b/test/lib/shoulda/gem/proc_extensions.rb new file mode 100755 index 0000000..0d577df --- /dev/null +++ b/test/lib/shoulda/gem/proc_extensions.rb @@ -0,0 +1,14 @@ +# Stolen straight from ActiveSupport + +class Proc #:nodoc: + def bind(object) + block, time = self, Time.now + (class << object; self end).class_eval do + method_name = "__bind_#{time.to_i}_#{time.usec}" + define_method(method_name, &block) + method = instance_method(method_name) + remove_method(method_name) + method + end.bind(object) + end +end diff --git a/test/lib/shoulda/gem/shoulda.rb b/test/lib/shoulda/gem/shoulda.rb new file mode 100755 index 0000000..d1fab1d --- /dev/null +++ b/test/lib/shoulda/gem/shoulda.rb @@ -0,0 +1,239 @@ +require File.join(File.dirname(__FILE__), 'proc_extensions') + +module Thoughtbot + module Shoulda + class << self + attr_accessor :current_context + end + + VERSION = '1.1.1' + + # = Should statements + # + # Should statements are just syntactic sugar over normal Test::Unit test methods. A should block + # contains all the normal code and assertions you're used to seeing, with the added benefit that + # they can be wrapped inside context blocks (see below). + # + # == Example: + # + # class UserTest << Test::Unit::TestCase + # + # def setup + # @user = User.new("John", "Doe") + # end + # + # should "return its full name" + # assert_equal 'John Doe', @user.full_name + # end + # + # end + # + # ...will produce the following test: + # * "test: User should return its full name. " + # + # Note: The part before should in the test name is gleamed from the name of the Test::Unit class. + + def should(name, &blk) + if Shoulda.current_context + Shoulda.current_context.should(name, &blk) + else + context_name = self.name.gsub(/Test/, "") + context = Thoughtbot::Shoulda::Context.new(context_name, self) do + should(name, &blk) + end + context.build + end + end + + # Just like should, but never runs, and instead prints an 'X' in the Test::Unit output. + def should_eventually(name, &blk) + context_name = self.name.gsub(/Test/, "") + context = Thoughtbot::Shoulda::Context.new(context_name, self) do + should_eventually(name, &blk) + end + context.build + end + + # = Contexts + # + # A context block groups should statements under a common set of setup/teardown methods. + # Context blocks can be arbitrarily nested, and can do wonders for improving the maintainability + # and readability of your test code. + # + # A context block can contain setup, should, should_eventually, and teardown blocks. + # + # class UserTest << Test::Unit::TestCase + # context "A User instance" do + # setup do + # @user = User.find(:first) + # end + # + # should "return its full name" + # assert_equal 'John Doe', @user.full_name + # end + # end + # end + # + # This code will produce the method "test: A User instance should return its full name. ". + # + # Contexts may be nested. Nested contexts run their setup blocks from out to in before each + # should statement. They then run their teardown blocks from in to out after each should statement. + # + # class UserTest << Test::Unit::TestCase + # context "A User instance" do + # setup do + # @user = User.find(:first) + # end + # + # should "return its full name" + # assert_equal 'John Doe', @user.full_name + # end + # + # context "with a profile" do + # setup do + # @user.profile = Profile.find(:first) + # end + # + # should "return true when sent :has_profile?" + # assert @user.has_profile? + # end + # end + # end + # end + # + # This code will produce the following methods + # * "test: A User instance should return its full name. " + # * "test: A User instance with a profile should return true when sent :has_profile?. " + # + # Just like should statements, a context block can exist next to normal def test_the_old_way; end + # tests. This means you do not have to fully commit to the context/should syntax in a test file. + + def context(name, &blk) + if Shoulda.current_context + Shoulda.current_context.context(name, &blk) + else + context = Thoughtbot::Shoulda::Context.new(name, self, &blk) + context.build + end + end + + class Context # :nodoc: + + attr_accessor :name # my name + attr_accessor :parent # may be another context, or the original test::unit class. + attr_accessor :subcontexts # array of contexts nested under myself + attr_accessor :setup_block # block given via a setup method + attr_accessor :teardown_block # block given via a teardown method + attr_accessor :shoulds # array of hashes representing the should statements + attr_accessor :should_eventuallys # array of hashes representing the should eventually statements + + def initialize(name, parent, &blk) + Shoulda.current_context = self + self.name = name + self.parent = parent + self.setup_block = nil + self.teardown_block = nil + self.shoulds = [] + self.should_eventuallys = [] + self.subcontexts = [] + + blk.bind(self).call + Shoulda.current_context = nil + end + + def context(name, &blk) + subcontexts << Context.new(name, self, &blk) + Shoulda.current_context = self + end + + def setup(&blk) + self.setup_block = blk + end + + def teardown(&blk) + self.teardown_block = blk + end + + def should(name, &blk) + self.shoulds << { :name => name, :block => blk } + end + + def should_eventually(name, &blk) + self.should_eventuallys << { :name => name, :block => blk } + end + + def full_name + parent_name = parent.full_name if am_subcontext? + return [parent_name, name].join(" ").strip + end + + def am_subcontext? + parent.is_a?(self.class) # my parent is the same class as myself. + end + + def test_unit_class + am_subcontext? ? parent.test_unit_class : parent + end + + def create_test_from_should_hash(should) + test_name = ["test:", full_name, "should", "#{should[:name]}. "].flatten.join(' ').to_sym + + if test_unit_class.instance_methods.include?(test_name.to_s) + warn " * WARNING: '#{test_name}' is already defined" + end + + context = self + test_unit_class.send(:define_method, test_name) do |*args| + begin + context.run_all_setup_blocks(self) + should[:block].bind(self).call + ensure + context.run_all_teardown_blocks(self) + end + end + end + + def run_all_setup_blocks(binding) + self.parent.run_all_setup_blocks(binding) if am_subcontext? + setup_block.bind(binding).call if setup_block + end + + def run_all_teardown_blocks(binding) + teardown_block.bind(binding).call if teardown_block + self.parent.run_all_teardown_blocks(binding) if am_subcontext? + end + + def print_should_eventuallys + should_eventuallys.each do |should| + test_name = [full_name, "should", "#{should[:name]}. "].flatten.join(' ') + puts " * DEFERRED: " + test_name + end + subcontexts.each { |context| context.print_should_eventuallys } + end + + def build + shoulds.each do |should| + create_test_from_should_hash(should) + end + + subcontexts.each { |context| context.build } + + print_should_eventuallys + end + + def method_missing(method, *args, &blk) + test_unit_class.send(method, *args, &blk) + end + + end + end +end + +module Test # :nodoc: all + module Unit + class TestCase + extend Thoughtbot::Shoulda + end + end +end + diff --git a/test/lib/shoulda/general.rb b/test/lib/shoulda/general.rb new file mode 100755 index 0000000..e9c57d2 --- /dev/null +++ b/test/lib/shoulda/general.rb @@ -0,0 +1,129 @@ +module ThoughtBot # :nodoc: + module Shoulda # :nodoc: + module General + def self.included(other) # :nodoc: + other.class_eval do + extend ThoughtBot::Shoulda::General::ClassMethods + end + end + + module ClassMethods + # Loads all fixture files (test/fixtures/*.yml) + def load_all_fixtures + all_fixtures = Dir.glob(File.join(Test::Unit::TestCase.fixture_path, "*.yml")).collect do |f| + File.basename(f, '.yml').to_sym + end + fixtures *all_fixtures + end + end + + # Prints a message to stdout, tagged with the name of the calling method. + def report!(msg = "") + puts("#{caller.first}: #{msg}") + end + + # Asserts that two arrays contain the same elements, the same number of times. Essentially ==, but unordered. + # + # assert_same_elements([:a, :b, :c], [:c, :a, :b]) => passes + def assert_same_elements(a1, a2, msg = nil) + [:select, :inject, :size].each do |m| + [a1, a2].each {|a| assert_respond_to(a, m, "Are you sure that #{a.inspect} is an array? It doesn't respond to #{m}.") } + end + + assert a1h = a1.inject({}) { |h,e| h[e] = a1.select { |i| i == e }.size; h } + assert a2h = a2.inject({}) { |h,e| h[e] = a2.select { |i| i == e }.size; h } + + assert_equal(a1h, a2h, msg) + end + + # Asserts that the given collection contains item x. If x is a regular expression, ensure that + # at least one element from the collection matches x. +extra_msg+ is appended to the error message if the assertion fails. + # + # assert_contains(['a', '1'], /\d/) => passes + # assert_contains(['a', '1'], 'a') => passes + # assert_contains(['a', '1'], /not there/) => fails + def assert_contains(collection, x, extra_msg = "") + collection = [collection] unless collection.is_a?(Array) + msg = "#{x.inspect} not found in #{collection.to_a.inspect} #{extra_msg}" + case x + when Regexp: assert(collection.detect { |e| e =~ x }, msg) + else assert(collection.include?(x), msg) + end + end + + # Asserts that the given collection does not contain item x. If x is a regular expression, ensure that + # none of the elements from the collection match x. + def assert_does_not_contain(collection, x, extra_msg = "") + collection = [collection] unless collection.is_a?(Array) + msg = "#{x.inspect} found in #{collection.to_a.inspect} " + extra_msg + case x + when Regexp: assert(!collection.detect { |e| e =~ x }, msg) + else assert(!collection.include?(x), msg) + end + end + + # Asserts that the given object can be saved + # + # assert_save User.new(params) + def assert_save(obj) + assert obj.save, "Errors: #{pretty_error_messages obj}" + obj.reload + end + + # Asserts that the given object is valid + # + # assert_valid User.new(params) + def assert_valid(obj) + assert obj.valid?, "Errors: #{pretty_error_messages obj}" + end + + # Asserts that the block uses ActionMailer to send emails + # + # assert_sends_email(2) { Mailer.deliver_messages } + def assert_sends_email(num = 1, &blk) + ActionMailer::Base.deliveries.clear + blk.call + msg = "Sent #{ActionMailer::Base.deliveries.size} emails, when #{num} expected:\n" + ActionMailer::Base.deliveries.each { |m| msg << " '#{m.subject}' sent to #{m.to.to_sentence}\n" } + assert(num == ActionMailer::Base.deliveries.size, msg) + end + + # Asserts that an email was delivered. Can take a blog that can further + # narrow down the types of emails you're expecting. + # + # assert_sent_email + # + # passes if ActionMailer::Base.deliveries has an email + # + # assert_sent_email do |email| + # email.subject =~ /hi there/ && email.to == 'none@none.com' + # end + # + # passes if there is an email with subject containing 'hi there' and + # recipient equal to 'none@none.com' + # + def assert_sent_email + emails = ActionMailer::Base.deliveries + assert !emails.empty?, "No emails were sent" + if block_given? + matching_emails = emails.select {|e| yield e } + assert !matching_emails.empty?, "None of the emails matched." + end + end + + # Asserts that no ActionMailer mails were delivered + # + # assert_did_not_send_email + def assert_did_not_send_email + msg = "Sent #{ActionMailer::Base.deliveries.size} emails.\n" + ActionMailer::Base.deliveries.each { |m| msg << " '#{m.subject}' sent to #{m.to.to_sentence}\n" } + assert ActionMailer::Base.deliveries.empty?, msg + end + + def pretty_error_messages(obj) + obj.errors.map { |a, m| "#{a} #{m} (#{obj.send(a).inspect})" } + end + + end + end +end diff --git a/test/lib/shoulda/private_helpers.rb b/test/lib/shoulda/private_helpers.rb new file mode 100755 index 0000000..036dce4 --- /dev/null +++ b/test/lib/shoulda/private_helpers.rb @@ -0,0 +1,22 @@ +module ThoughtBot # :nodoc: + module Shoulda # :nodoc: + module Private # :nodoc: + # Returns the values for the entries in the args hash who's keys are listed in the wanted array. + # Will raise if there are keys in the args hash that aren't listed. + def get_options!(args, *wanted) + ret = [] + opts = (args.last.is_a?(Hash) ? args.pop : {}) + wanted.each {|w| ret << opts.delete(w)} + raise ArgumentError, "Unsuported options given: #{opts.keys.join(', ')}" unless opts.keys.empty? + return *ret + end + + # Returns the model class constant, as determined by the test class name. + # + # class TestUser; model_class; end => User + def model_class + self.name.gsub(/Test$/, '').constantize + end + end + end +end