diff --git a/README.md b/README.md
index dc48cc27..efb60b8a 100644
--- a/README.md
+++ b/README.md
@@ -1314,6 +1314,29 @@ class UserControllerTest < ActionController::TestCase
end
```
+## Independent Matchers
+
+Matchers to test non-Rails-dependent code:
+
+#### delegate_method
+
+```ruby
+class Human < ActiveRecord::Base
+ has_one :robot
+ delegate :work, to: :robot
+end
+
+# RSpec
+describe Human do
+ it { should delegate_method(:work).to(:robot) }
+end
+
+# Test::Unit
+class HumanTest < ActiveSupport::TestCase
+ should delegate_method(:work).to(:robot)
+end
+```
+
## Versioning
shoulda-matchers follows Semantic Versioning 2.0 as defined at
diff --git a/lib/shoulda/matchers/independent.rb b/lib/shoulda/matchers/independent.rb
new file mode 100644
index 00000000..e8119a10
--- /dev/null
+++ b/lib/shoulda/matchers/independent.rb
@@ -0,0 +1,9 @@
+require 'shoulda/matchers/independent/delegate_matcher'
+
+module Shoulda
+ module Matchers
+ # = Matchers for non-Rails-dependent code.
+ module Independent
+ end
+ end
+end
diff --git a/lib/shoulda/matchers/independent/delegate_matcher.rb b/lib/shoulda/matchers/independent/delegate_matcher.rb
new file mode 100644
index 00000000..bcf0caa5
--- /dev/null
+++ b/lib/shoulda/matchers/independent/delegate_matcher.rb
@@ -0,0 +1,132 @@
+require 'active_support/deprecation'
+
+module Shoulda # :nodoc:
+ module Matchers
+ module Independent # :nodoc:
+
+ # Ensure that a given method is delegated properly.
+ #
+ # Basic Syntax:
+ # it { should delegate_method(:deliver_mail).to(:mailman) }
+ #
+ # Options:
+ # * :as - tests that the object being delegated to is called with a certain method
+ # (defaults to same name as delegating method)
+ # * :with_arguments - tests that the method on the object being delegated to is
+ # called with certain arguments
+ #
+ # Examples:
+ # it { should delegate_method(:deliver_mail).to(:mailman).as(:deliver_with_haste)
+ # it { should delegate_method(:deliver_mail).to(:mailman).
+ # with_arguments('221B Baker St.', :hastily => true) }
+ #
+ def delegate_method(delegating_method)
+ DelegateMatcher.new(delegating_method)
+ end
+
+ class DelegateMatcher
+ def initialize(delegating_method)
+ @delegating_method = delegating_method
+ end
+
+ def matches?(subject)
+ @subject = subject
+ ensure_target_method_is_present!
+
+ begin
+ extend Mocha::API
+
+ stubbed_object = stub(method_on_target)
+ subject.stubs(@target_method).returns(stubbed_object)
+ subject.send(@delegating_method)
+
+ matcher = Mocha::API::HaveReceived.new(method_on_target).with(*@delegated_arguments)
+ matcher.matches?(stubbed_object)
+ rescue NoMethodError, MiniTest::Assertion
+ false
+ end
+ end
+
+ def description
+ add_clarifications_to("delegate method ##{@delegating_method} to :#{@target_method}")
+ end
+
+ def does_not_match?(subject)
+ raise InvalidDelegateMatcher
+ end
+
+ def to(target_method)
+ @target_method = target_method
+ self
+ end
+
+ def as(method_on_target)
+ @method_on_target = method_on_target
+ self
+ end
+
+ def with_arguments(*arguments)
+ @delegated_arguments = arguments
+ self
+ end
+
+ def failure_message_for_should
+ base = "Expected #{delegating_method_name} to delegate to #{target_method_name}"
+ add_clarifications_to(base)
+ end
+
+ private
+
+ def add_clarifications_to(message)
+ if @delegated_arguments.present?
+ message << " with arguments: #{@delegated_arguments.inspect}"
+ end
+
+ if @method_on_target.present?
+ message << " as ##{@method_on_target}"
+ end
+
+ message
+ end
+
+ def delegating_method_name
+ method_name_with_class(@delegating_method)
+ end
+
+ def target_method_name
+ method_name_with_class(@target_method)
+ end
+
+ def method_name_with_class(method)
+ if Class === @subject
+ @subject.name + '.' + method.to_s
+ else
+ @subject.class.name + '#' + method.to_s
+ end
+ end
+
+ def method_on_target
+ @method_on_target || @delegating_method
+ end
+
+ def ensure_target_method_is_present!
+ if @target_method.blank?
+ raise TargetNotDefinedError
+ end
+ end
+ end
+
+ class DelegateMatcher::TargetNotDefinedError < StandardError
+ def message
+ 'Delegation needs a target. Use the #to method to define one, e.g. `post_office.should delegate(:deliver_mail).to(:mailman)`'
+ end
+ end
+
+ class DelegateMatcher::InvalidDelegateMatcher < StandardError
+ def message
+ '#delegate_to does not support #should_not syntax.'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/shoulda/matchers/integrations/rspec.rb b/lib/shoulda/matchers/integrations/rspec.rb
index 7ae1a3a3..455107f0 100644
--- a/lib/shoulda/matchers/integrations/rspec.rb
+++ b/lib/shoulda/matchers/integrations/rspec.rb
@@ -2,6 +2,9 @@
require 'rspec/core'
RSpec.configure do |config|
+ require 'shoulda/matchers/independent'
+ config.include Shoulda::Matchers::Independent
+
if defined?(::ActiveRecord)
require 'shoulda/matchers/active_record'
require 'shoulda/matchers/active_model'
diff --git a/lib/shoulda/matchers/integrations/test_unit.rb b/lib/shoulda/matchers/integrations/test_unit.rb
index dca1094a..f06ef90b 100644
--- a/lib/shoulda/matchers/integrations/test_unit.rb
+++ b/lib/shoulda/matchers/integrations/test_unit.rb
@@ -1,3 +1,16 @@
+# :enddoc:
+require 'test/unit/testcase'
+require 'shoulda/matchers/independent'
+
+module Test
+ module Unit
+ class TestCase
+ include Shoulda::Matchers::Independent
+ extend Shoulda::Matchers::Independent
+ end
+ end
+end
+
if defined?(ActionController) && defined?(ActionController::TestCase)
require 'shoulda/matchers/action_controller'
diff --git a/spec/shoulda/matchers/independent/delegate_matcher_spec.rb b/spec/shoulda/matchers/independent/delegate_matcher_spec.rb
new file mode 100644
index 00000000..47fb427c
--- /dev/null
+++ b/spec/shoulda/matchers/independent/delegate_matcher_spec.rb
@@ -0,0 +1,204 @@
+require 'spec_helper'
+
+describe Shoulda::Matchers::Independent::DelegateMatcher do
+ context '#description' do
+ context 'by default' do
+ it 'states that it should delegate method to the right object' do
+ matcher = delegate_method(:method_name).to(:target)
+
+ matcher.description.should == 'delegate method #method_name to :target'
+ end
+ end
+
+ context 'with #as chain' do
+ it 'states that it should delegate method to the right object and method' do
+ matcher = delegate_method(:method_name).to(:target).as(:alternate)
+
+ matcher.description.should == 'delegate method #method_name to :target as #alternate'
+ end
+ end
+
+ context 'with #with_argument chain' do
+ it 'states that it should delegate method to the right object with right argument' do
+ matcher = delegate_method(:method_name).to(:target).with_arguments(:foo, :bar => [1, 2])
+
+ matcher.description.should ==
+ 'delegate method #method_name to :target with arguments: [:foo, {:bar=>[1, 2]}]'
+ end
+ end
+ end
+
+ it 'supports chaining on #to' do
+ matcher = delegate_method(:method)
+
+ matcher.to(:another_method).should == matcher
+ end
+
+ it 'supports chaining on #with_arguments' do
+ matcher = delegate_method(:method)
+
+ matcher.with_arguments(1, 2, 3).should == matcher
+ end
+
+ it 'supports chaining on #as' do
+ matcher = delegate_method(:method)
+
+ matcher.as(:some_other_method).should == matcher
+ end
+
+ it 'raises an error if no delegation target is defined' do
+ expect { Object.new.should delegate_method(:name) }.
+ to raise_exception described_class::TargetNotDefinedError
+ end
+
+ it 'raises an error if called with #should_not' do
+ expect { Object.new.should_not delegate_method(:name).to(:anyone) }.
+ to raise_exception described_class::InvalidDelegateMatcher
+ end
+
+ context 'given a method that does not delegate' do
+ before do
+ define_class(:post_office) do
+ def deliver_mail
+ :delivered
+ end
+ end
+ end
+
+ it 'rejects' do
+ post_office = PostOffice.new
+ matcher = delegate_method(:deliver_mail).to(:mailman)
+
+ matcher.matches?(post_office).should be_false
+ end
+
+ it 'has a failure message that indicates which method should have been delegated' do
+ post_office = PostOffice.new
+ matcher = delegate_method(:deliver_mail).to(:mailman)
+
+ matcher.matches?(post_office)
+
+ message = 'Expected PostOffice#deliver_mail to delegate to PostOffice#mailman'
+ matcher.failure_message_for_should.should == message
+ end
+
+ it 'uses the proper syntax for class methods in errors' do
+ matcher = delegate_method(:deliver_mail).to(:mailman)
+
+ matcher.matches?(PostOffice)
+
+ message = 'Expected PostOffice.deliver_mail to delegate to PostOffice.mailman'
+ matcher.failure_message_for_should.should == message
+ end
+ end
+
+ context 'given a method that delegates properly' do
+ it 'accepts' do
+ define_class(:mailman)
+ define_class(:post_office) do
+ def deliver_mail
+ mailman.deliver_mail
+ end
+
+ def mailman
+ Mailman.new
+ end
+ end.new.should delegate_method(:deliver_mail).to(:mailman)
+ end
+ end
+
+ context 'given a method that delegates properly with certain arguments' do
+ before do
+ define_class(:mailman)
+ define_class(:post_office) do
+ def deliver_mail
+ mailman.deliver_mail('221B Baker St.', :hastily => true)
+ end
+
+ def mailman
+ Mailman.new
+ end
+ end
+ end
+
+ context 'when given the correct arguments' do
+ it 'accepts' do
+ PostOffice.new.should delegate_method(:deliver_mail).
+ to(:mailman).with_arguments('221B Baker St.', :hastily => true)
+ end
+ end
+
+ context 'when not given the correct arguments' do
+ it 'rejects' do
+ post_office = PostOffice.new
+ matcher = delegate_method(:deliver_mail).to(:mailman).
+ with_arguments('123 Nowhere Ln.')
+ matcher.matches?(post_office).should be_false
+ end
+
+ it 'has a failure message that indicates which arguments were expected' do
+ post_office = PostOffice.new
+ matcher = delegate_method(:deliver_mail).to(:mailman).with_arguments('123 Nowhere Ln.')
+
+ matcher.matches?(post_office)
+
+ message = 'Expected PostOffice#deliver_mail to delegate to PostOffice#mailman with arguments: ["123 Nowhere Ln."]'
+ matcher.failure_message_for_should.should == message
+ end
+ end
+ end
+
+ context 'given a method that delegates properly to a method of a different name' do
+ before do
+ define_class(:mailman)
+ define_class(:post_office) do
+ def deliver_mail
+ mailman.deliver_mail_and_avoid_dogs
+ end
+
+ def mailman
+ Mailman.new
+ end
+ end
+ end
+
+ context 'when given the correct method name' do
+ it 'accepts' do
+ PostOffice.new.
+ should delegate_method(:deliver_mail).to(:mailman).as(:deliver_mail_and_avoid_dogs)
+ end
+ end
+
+ context 'when given an incorrect method name' do
+ it 'rejects' do
+ post_office = PostOffice.new
+ matcher = delegate_method(:deliver_mail).to(:mailman).as(:watch_tv)
+ matcher.matches?(post_office).should be_false
+ end
+
+ it 'has a failure message that indicates which method was expected' do
+ post_office = PostOffice.new
+ matcher = delegate_method(:deliver_mail).to(:mailman).as(:watch_tv)
+
+ matcher.matches?(post_office)
+
+ message = 'Expected PostOffice#deliver_mail to delegate to PostOffice#mailman as #watch_tv'
+ matcher.failure_message_for_should.should == message
+ end
+ end
+ end
+end
+
+describe Shoulda::Matchers::Independent::DelegateMatcher::TargetNotDefinedError do
+ it 'has a useful message' do
+ error = Shoulda::Matchers::Independent::DelegateMatcher::TargetNotDefinedError.new
+ error.message.should include 'Delegation needs a target'
+ end
+end
+
+describe Shoulda::Matchers::Independent::DelegateMatcher::InvalidDelegateMatcher do
+ it 'has a useful message' do
+ error = Shoulda::Matchers::Independent::DelegateMatcher::InvalidDelegateMatcher.new
+ error.message.should include 'does not support #should_not'
+ end
+end