From 16f24cd10ffca6be49e394b9404e9564a94aeeda Mon Sep 17 00:00:00 2001
From: Genadi Samokovarov <gsamokovarov@gmail.com>
Date: Mon, 13 Jun 2016 23:28:05 +0300
Subject: Introduce `assert_changes` and `assert_no_changes`

Those are assertions that I really do miss from the standard
`ActiveSupport::TestCase`. Think of those as a more general version of
`assert_difference` and `assert_no_difference` (those can be implemented
by assert_changes, should this change be accepted).

Why do we need those? They are useful when you want to check a
side-effect of an operation. `assert_difference` do cover a really
common case, but we `assert_changes` gives us more control. Having a
global error flag? You can test it easily with `assert_changes`. In
fact, you can be really specific about the initial state and the
terminal one.

```ruby
error = Error.new(:bad)
assert_changes -> { Error.current }, from: nil, to: error do
  expected_bad_operation
end
```

`assert_changes` follows `assert_difference` and a string can be given
for evaluation as well.

```ruby
error = Error.new(:bad)
assert_changes 'Error.current', from: nil, to: error do
  expected_bad_operation
end
```

Check out the test cases if you wanna see more examples.

:beers:
---
 activesupport/CHANGELOG.md                         |  50 ++++++++--
 .../lib/active_support/testing/assertions.rb       |  89 +++++++++++++++++
 activesupport/test/test_case_test.rb               | 106 +++++++++++++++++++++
 3 files changed, 236 insertions(+), 9 deletions(-)

(limited to 'activesupport')

diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md
index 6103857a41..3749dda9fc 100644
--- a/activesupport/CHANGELOG.md
+++ b/activesupport/CHANGELOG.md
@@ -1,3 +1,36 @@
+*   Introduce `assert_changes` and `assert_no_changes`.
+
+    `assert_changes` is a more general `assert_difference` that works with any
+    value.
+
+        assert_changes 'Error.current', from: nil, to: 'ERR' do
+          expected_bad_operation
+        end
+
+    Can be called with strings, to be evaluated in the binding (context) of
+    the block given to the assertion, or a lambda.
+
+        assert_changes -> { Error.current }, from: nil, to: 'ERR' do
+          expected_bad_operation
+        end
+
+    The `from` and `to` arguments are compared with the case operator (`===`).
+
+        assert_changes 'Error.current', from: nil, to: Error do
+          expected_bad_operation
+        end
+
+    This is pretty useful, if you need to loosely compare a value. For example,
+    you need to test a token has been generated and it has that many random
+    characters.
+
+        user = User.start_registration
+        assert_changes 'user.token', to: /\w{32}/ do
+          user.finish_registration
+        end
+
+    *Genadi Samokovarov*
+
 *   Add `:fallback_string` option to `Array#to_sentence`. If an empty array
     calls the function and a fallback string option is set then it returns the
     fallback string other than an empty string.
@@ -15,14 +48,14 @@
 
 *   `travel/travel_to` travel time helpers, now raise on nested calls, 
      as this can lead to confusing time stubbing.
-       
+
      Instead of:
-     
+
          travel_to 2.days.from_now do
            # 2 days from today
            travel_to 3.days.from_now do
              # 5 days from today
-           end          
+           end
          end
 
      preferred way to achieve above is:
@@ -30,13 +63,12 @@
          travel 2.days do 
            # 2 days from today
          end
-         
-         travel 5.days do  
-           # 5 days from today          
-         end        
-        
+
+         travel 5.days do
+           # 5 days from today
+         end
+
      *Vipul A M*
-     
 
 *   Support parsing JSON time in ISO8601 local time strings in
     `ActiveSupport::JSON.decode` when `parse_json_times` is enabled.
diff --git a/activesupport/lib/active_support/testing/assertions.rb b/activesupport/lib/active_support/testing/assertions.rb
index ad83638572..7770aa8006 100644
--- a/activesupport/lib/active_support/testing/assertions.rb
+++ b/activesupport/lib/active_support/testing/assertions.rb
@@ -1,6 +1,8 @@
 module ActiveSupport
   module Testing
     module Assertions
+      UNTRACKED = Object.new # :nodoc:
+
       # Asserts that an expression is not truthy. Passes if <tt>object</tt> is
       # +nil+ or +false+. "Truthy" means "considered true in a conditional"
       # like <tt>if foo</tt>.
@@ -92,6 +94,93 @@ module ActiveSupport
       def assert_no_difference(expression, message = nil, &block)
         assert_difference expression, 0, message, &block
       end
+
+      # Assertion that the result of evaluating an expression is changed before
+      # and after invoking the passed in block.
+      #
+      #   assert_changes 'Status.all_good?' do
+      #     post :create, params: { status: { ok: false } }
+      #   end
+      #
+      # You can pass the block as a string to be evaluated in the context of
+      # the block. A lambda can be passed for the block as well.
+      #
+      #   assert_changes -> { Status.all_good? } do
+      #     post :create, params: { status: { ok: false } }
+      #   end
+      #
+      # The assertion is useful to test side effects. The passed block can be
+      # anything that can be converted to string with #to_s.
+      #
+      #   assert_changes :@object do
+      #     @object = 42
+      #   end
+      #
+      # The keyword arguments :from and :to can be given to specify the
+      # expected initial value and the expected value after the block was
+      # executed.
+      #
+      #   assert_changes :@object, from: nil, to: :foo do
+      #     @object = :foo
+      #   end
+      #
+      # An error message can be specified.
+      #
+      #   assert_changes -> { Status.all_good? }, 'Expected the status to be bad' do
+      #     post :create, params: { status: { incident: true } }
+      #   end
+      def assert_changes(expression, message = nil, from: UNTRACKED, to: UNTRACKED, &block)
+        exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) }
+
+        before = exp.call
+        retval = yield
+
+        unless from == UNTRACKED
+          error = "#{expression.inspect} isn't #{from}"
+          error = "#{message}.\n#{error}" if message
+          assert from === before, error
+        end
+
+        after = exp.call
+
+        if to == UNTRACKED
+          error = "#{expression.inspect} didn't changed"
+          error = "#{message}.\n#{error}" if message
+          assert_not_equal before, after, error
+        else
+          message = "#{expression.inspect} didn't change to #{to}"
+          error = "#{message}.\n#{error}" if message
+          assert to === after, error
+        end
+
+        retval
+      end
+
+      # Assertion that the result of evaluating an expression is changed before
+      # and after invoking the passed in block.
+      #
+      #   assert_no_changes 'Status.all_good?' do
+      #     post :create, params: { status: { ok: true } }
+      #   end
+      #
+      # An error message can be specified.
+      #
+      #   assert_no_changes -> { Status.all_good? }, 'Expected the status to be good' do
+      #     post :create, params: { status: { ok: false } }
+      #   end
+      def assert_no_changes(expression, message = nil, &block)
+        exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) }
+
+        before = exp.call
+        retval = yield
+        after = exp.call
+
+        error = "#{expression.inspect} did change to #{after}"
+        error = "#{message}.\n#{error}" if message
+        assert_equal before, after, error
+
+        retval
+      end
     end
   end
 end
diff --git a/activesupport/test/test_case_test.rb b/activesupport/test/test_case_test.rb
index 18228a2ac5..772c3cfca7 100644
--- a/activesupport/test/test_case_test.rb
+++ b/activesupport/test/test_case_test.rb
@@ -111,6 +111,112 @@ class AssertDifferenceTest < ActiveSupport::TestCase
       end
     end
   end
+
+  def test_assert_changes_pass
+    assert_changes '@object.num' do
+      @object.increment
+    end
+  end
+
+  def test_assert_changes_pass_with_lambda
+    assert_changes -> { @object.num } do
+      @object.increment
+    end
+  end
+
+  def test_assert_changes_with_from_option
+    assert_changes '@object.num', from: 0 do
+      @object.increment
+    end
+  end
+
+  def test_assert_changes_with_from_option_with_wrong_value
+    assert_raises Minitest::Assertion do
+      assert_changes '@object.num', from: -1 do
+        @object.increment
+      end
+    end
+  end
+
+  def test_assert_changes_with_to_option
+    assert_changes '@object.num', to: 1 do
+      @object.increment
+    end
+  end
+
+  def test_assert_changes_with_wrong_to_option
+    assert_raises Minitest::Assertion do
+      assert_changes '@object.num', to: 2 do
+        @object.increment
+      end
+    end
+  end
+
+  def test_assert_changes_with_from_option_and_to_option
+    assert_changes '@object.num', from: 0, to: 1 do
+      @object.increment
+    end
+  end
+
+  def test_assert_changes_with_from_and_to_options_and_wrong_to_value
+    assert_raises Minitest::Assertion do
+      assert_changes '@object.num', from: 0, to: 2 do
+        @object.increment
+      end
+    end
+  end
+
+  def test_assert_changes_works_with_any_object
+    retval = silence_warnings do
+      assert_changes :@new_object, from: nil, to: 42 do
+        @new_object = 42
+      end
+    end
+
+    assert_equal 42, retval
+  end
+
+  def test_assert_changes_works_with_nil
+    oldval = @object
+
+    retval = assert_changes :@object, from: oldval, to: nil do
+      @object = nil
+    end
+
+    assert_nil retval
+  end
+
+  def test_assert_changes_with_to_and_case_operator
+    token = nil
+
+    assert_changes 'token', to: /\w{32}/ do
+      token = SecureRandom.hex
+    end
+  end
+
+  def test_assert_changes_with_to_and_from_and_case_operator
+    token = SecureRandom.hex
+
+    assert_changes 'token', from: /\w{32}/, to: /\w{32}/ do
+      token = SecureRandom.hex
+    end
+  end
+
+  def test_assert_no_changes_pass
+    assert_no_changes '@object.num' do
+      # ...
+    end
+  end
+
+  def test_assert_no_changes_with_message
+    error = assert_raises Minitest::Assertion do
+      assert_no_changes '@object.num', '@object.num should not change' do
+        @object.increment
+      end
+    end
+
+    assert_equal "@object.num should not change.\n\"@object.num\" did change to 1.\nExpected: 0\n  Actual: 1", error.message
+  end
 end
 
 class AlsoDoingNothingTest < ActiveSupport::TestCase
-- 
cgit v1.2.3