Skip to content
This repository

Add matchers start_with end_with #135

Merged
merged 5 commits into from about 2 years ago

3 participants

Jeremy Wadsack David Chelimsky Myron Marston
Jeremy Wadsack

This pull request provides a pair of new matchers start_with and end_with that are basically sugar to make specs more readable. They work with strings and arrays (and basically anything else that acts like a collection).

"A test string".should start_with 'A test'
[1, 2, 3].should start_with 1
[1, 2, 3].should end_with [2, 3]

I recognize that these are somewhat specialized and have noted that most built-in matchers are very, very generalized, but I found we were using a custom matcher like this often in our specs and thought it would be useful for others.

Reasoning

Currently, under Rails with ActiveSupport you can use the existing starts_with? and ends_with? methods to match the beginning and end of strings but they have some drawbacks. If used on the expected value then a failure reports an unhelpful message:

"A test string".starts_with?("Something").should be_true
# ==> "expected true but got false"

When used as a predicate, it doesn't quite read like english and is awkward:

"A test string".should be_starts_with("A test")

Finally, while we are using this for our Rails apps that have ActiveSupport for starts_with? and ends_with?, this implementation work independently of ActiveSupport so can be used outside if a Rails app as the rest of Rspec does.

Open to feedback as to whether you think these are valuable as built-in specs.

David Chelimsky dchelimsky merged commit 05d9853 into from April 08, 2012
David Chelimsky dchelimsky closed this April 08, 2012
David Chelimsky
Owner

I like it. I'm gonna a tweak a few things, but nice job.

Myron Marston myronmarston commented on the diff April 08, 2012
lib/rspec/matchers.rb
@@ -355,6 +355,20 @@ def cover(*values)
355 355
       BuiltIn::Cover.new(*values)
356 356
     end if (1..2).respond_to?(:cover?)
357 357
 
  358
+    # Matches if the target ends with the expected value. In the case
  359
+    # of strings tries to match the last expected.length characters of
  360
+    # target. In the case of an array tries to match the last expected.length
  361
+    # elements of target.
  362
+    #
  363
+    # @example
  364
+    #
  365
+    #   "A test string".should end_with 'string'
  366
+    #   [0, 1, 2, 3, 4].should end_with 4
  367
+    #   [0, 2, 3, 4, 4].should end_with [3, 4]
1
Myron Marston Owner

This looks like a typo. Shouldn't it be [0, 2, 3, 4, 4].should end_with [4, 4]?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Myron Marston myronmarston commented on the diff April 08, 2012
lib/rspec/matchers.rb
@@ -534,6 +548,20 @@ def satisfy(&block)
534 548
       BuiltIn::Satisfy.new(&block)
535 549
     end
536 550
 
  551
+    # Matches if the target starts with the expected value. In the case
  552
+    # of strings tries to match the first expected.length characters of
  553
+    # target. In the case of an array tries to match the first expected.length
  554
+    # elements of target.
  555
+    #
  556
+    # @example
  557
+    #
  558
+    #   "A test string".should start_with 'A test'
  559
+    #   [0, 1, 2, 3, 4].should start_with 0
  560
+    #   [0, 2, 3, 4, 4].should start_with [0, 1]
1
Myron Marston Owner

Again, this looks like a typo....[0, 2, 3, 4, 4].should start_with [0, 1] wouldn't pass, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Myron Marston
Owner

I agree...great stuff! One suggestion: (which may be one of the things @dchelimsky was planning to change anyway?): I think that it makes more sense to use splat args so that it would be [0, 1, 2].should start_with(0, 1) rather than [0, 1, 2].should start_with [0, 1]. To me, the latter suggests it's matching an array of tuples, where the first tuple is [0, 1]. When you do indeed have a tuple (such as a for a 1.9 ordered hash), you could do something like hash.should start_with([some_key, some_value]).

Thoughts?

David Chelimsky
Owner

I like @myronmarston's splat args idea and will make that change. Among other things, it is better aligned with the include matcher that way. Not sure about ordered hashes - need to think about that a bit more.

I'm also making adjustments to the features and specs to better align them with their neighbors. Don't know if I have time to wrap that all up this morning before work, but coming soon ...

Myron Marston
Owner

Not sure about ordered hashes - need to think about that a bit more.

I think that'll just naturally fall out of this once you change it to splat args.

Jeremy Wadsack

@dchelimsky Thanks for reviewing this. Let me know if you want me to make the changes that @myronmarston identified in the rdoc.

@myronmarston as for tuples, I can see some value to that. It would be a clearer expectation of interface. Good idea. I didn't test the ordered hash idea, but it should fall through because it acts like an ordered collection.

That does bring up a good point that some sets that are not ordered (e.g. a 1.8-style hash) would fail because it responds to :[] but doesn't support a range index (e.g. hash[0, 3]). Is it better to somehow try to test for that or to just let Ruby raise an error? The former may be less robust but the latter may be more surprising.

Myron Marston
Owner

That does bring up a good point that some sets that are not ordered (e.g. a 1.8-style hash) would fail because it responds to :[] but doesn't support a range index (e.g. hash[0, 3]). Is it better to somehow try to test for that or to just let Ruby raise an error? The former may be less robust but the latter may be more surprising.

I don't think it makes sense to try to put special-case logic for 1.8 hashes, but maybe it'd be good to rescue the error ruby raises from #[0, 3] and give the user a more helpful error? That way it would potentially work for other types that have the same issue.

David Chelimsky
Owner

@jeremywadsack I'm working on a bunch of changes at once, though it's all mid-flight on my home computer which won't get much of my attention for a few days.

David Chelimsky dchelimsky referenced this pull request from a commit April 08, 2012
David Chelimsky Change start_with and end_with matchers to take varargs.
- Clean up rdoc, features, and specs.
- Refactor the two matchers a bit.
- Add changelog.
- #135
2e0cdbc
David Chelimsky
Owner

Got to it sooner than I thought. @jeremywadsack let me know if you have any questions.

Jeremy Wadsack

Thanks @dchelimsky.

Also, noticed that the feature description has varying code style between end_with and start_with. In the former you use should end_with 1, 2 while the latter says should start_with(0, 1). Similarly the scenario for end_with.feature is, e.g. "string usage" but in start_with.feature you changed it to "with a string".

David Chelimsky
Owner

@jeremywadsack thanks - was trying to make things consistent and missed a couple of spots - will address before release.

Jeremy Wadsack

@dchelimsky should we also address the case where actual responds_to(:[]) but doesn't have ordered data? I believe that Ruby will raise an ArgumentError "wrong number of arguments (2 for 1)" which might be a little unexpected.

Incidentally, I'm not convinced that this works for a hash. In testing with ruby 1.9.2 hashes didn't accept numeric indexes and the docs for 1.9.3 state that the argument should be a key.

Per @myronmarston's example above I think that hash.should start_with([some_key, some_value]) will fail incorrectly because hash[0] returns nil.

I can give a stab at this tonight and see if I can come up with something a little more expected.

David Chelimsky
Owner

@jeremywadsack have at it.

Jeremy Wadsack jeremywadsack referenced this pull request from a commit in jeremywadsack/rspec-expectations April 10, 2012
Jeremy Wadsack Added more helpful message when actual has #[] but does not support #…
…[0,3]

Pull request #135
34e4285
Jeremy Wadsack

So basically this puts a message in place (feel free to edit for consistency of voice) for things like hashes that don't support ordered index or elements.

Without going down custom code for hashes I didn't come up with a generic way to actually allow one to match the first key of a hash because hash[0] always returns nil. I think this is probably good enough.

Myron Marston
Owner

@jeremywadsack -- sounds fine. My original comment about hashes wasn't intended to mean we should add code to specifically support hashes; rather, I was just thinking about a collection of ordered tuples (which could just be an array-of-arrays, and in that case, this matcher should work fine, right?), and mentioned a hash as one example of an object that behaves like an ordered collection of tuples on 1.9.

Thanks for following up on this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
47  features/built_in_matchers/end_with.feature
... ...
@@ -0,0 +1,47 @@
  1
+Feature: end_with matcher
  2
+
  3
+  The end_with matcher is mostly sugar to make your string tests
  4
+  read better
  5
+
  6
+    "A test string".should end_with "string"
  7
+    "A test string".should_not end_with "Something"
  8
+
  9
+  The test is case sensitive.
  10
+
  11
+  Scenario: string usage
  12
+    Given a file named "string_end_with_matcher_spec.rb" with:
  13
+      """
  14
+      describe "A test string" do
  15
+        it { should end_with "string" }
  16
+        it { should_not end_with "Something" }
  17
+
  18
+        # deliberate failures
  19
+        it { should_not end_with "string" }
  20
+        it { should end_with "Something" }
  21
+      end
  22
+      """
  23
+    When I run `rspec string_end_with_matcher_spec.rb`
  24
+    Then the output should contain all of these:
  25
+      | 4 examples, 2 failures                            |
  26
+      | expected "A test string" not to end with "string" |
  27
+      | expected "A test string" to end with "Something"  |
  28
+
  29
+  Scenario: array usage
  30
+    Given a file named "array_end_with_matcher_spec.rb" with:
  31
+      """
  32
+      describe [0, 1, 2, 3, 4] do
  33
+        it { should end_with 4 }
  34
+        it { should end_with [3, 4] }
  35
+        it { should_not end_with "Something" }
  36
+        it { should_not end_with [0, 1, 2, 3, 4, 5] }
  37
+
  38
+        # deliberate failures
  39
+        it { should_not end_with 4 }
  40
+        it { should end_with "Something" }
  41
+      end
  42
+      """
  43
+    When I run `rspec array_end_with_matcher_spec.rb`
  44
+    Then the output should contain all of these:
  45
+      | 6 examples, 2 failures                           |
  46
+      | expected [0, 1, 2, 3, 4] not to end with 4       |
  47
+      | expected [0, 1, 2, 3, 4] to end with "Something" |
48  features/built_in_matchers/start_with.feature
... ...
@@ -0,0 +1,48 @@
  1
+Feature: start_with matcher
  2
+
  3
+  The start_with matcher is mostly sugar to make your string tests
  4
+  read better
  5
+
  6
+    "A test string".should start_with "A test"
  7
+    "A test string".should_not start_with "Something"
  8
+
  9
+  The test is case sensitive.
  10
+
  11
+  Scenario: string usage
  12
+    Given a file named "string_start_with_matcher_spec.rb" with:
  13
+      """
  14
+      describe "A test string" do
  15
+        it { should start_with "A test" }
  16
+        it { should_not start_with "Something" }
  17
+
  18
+        # deliberate failures
  19
+        it { should_not start_with "A test" }
  20
+        it { should start_with "Something" }
  21
+      end
  22
+      """
  23
+    When I run `rspec string_start_with_matcher_spec.rb`
  24
+    Then the output should contain all of these:
  25
+      | 4 examples, 2 failures                              |
  26
+      | expected "A test string" not to start with "A test" |
  27
+      | expected "A test string" to start with "Something"  |
  28
+
  29
+  Scenario: array usage
  30
+    Given a file named "array_start_with_matcher_spec.rb" with:
  31
+      """
  32
+      describe [0, 1, 2, 3, 4] do
  33
+        it { should start_with 0 }
  34
+        it { should start_with [0, 1] }
  35
+        it { should_not start_with "Something" }
  36
+        it { should_not start_with [0, 1, 2, 3, 4, 5] }
  37
+
  38
+        # deliberate failures
  39
+        it { should_not start_with 0 }
  40
+        it { should start_with "Something" }
  41
+      end
  42
+      """
  43
+    When I run `rspec array_start_with_matcher_spec.rb`
  44
+    Then the output should contain all of these:
  45
+      | 6 examples, 2 failures                             |
  46
+      | expected [0, 1, 2, 3, 4] not to start with 0       |
  47
+      | expected [0, 1, 2, 3, 4] to start with "Something" |
  48
+
28  lib/rspec/matchers.rb
@@ -355,6 +355,20 @@ def cover(*values)
355 355
       BuiltIn::Cover.new(*values)
356 356
     end if (1..2).respond_to?(:cover?)
357 357
 
  358
+    # Matches if the target ends with the expected value. In the case
  359
+    # of strings tries to match the last expected.length characters of
  360
+    # target. In the case of an array tries to match the last expected.length
  361
+    # elements of target.
  362
+    #
  363
+    # @example
  364
+    #
  365
+    #   "A test string".should end_with 'string'
  366
+    #   [0, 1, 2, 3, 4].should end_with 4
  367
+    #   [0, 2, 3, 4, 4].should end_with [3, 4]
  368
+    def end_with(expected)
  369
+      BuiltIn::EndWith.new(expected)
  370
+    end
  371
+
358 372
     # Passes if <tt>actual == expected</tt>.
359 373
     #
360 374
     # See http://www.ruby-doc.org/core/classes/Object.html#M001057 for more information about equality in Ruby.
@@ -534,6 +548,20 @@ def satisfy(&block)
534 548
       BuiltIn::Satisfy.new(&block)
535 549
     end
536 550
 
  551
+    # Matches if the target starts with the expected value. In the case
  552
+    # of strings tries to match the first expected.length characters of
  553
+    # target. In the case of an array tries to match the first expected.length
  554
+    # elements of target.
  555
+    #
  556
+    # @example
  557
+    #
  558
+    #   "A test string".should start_with 'A test'
  559
+    #   [0, 1, 2, 3, 4].should start_with 0
  560
+    #   [0, 2, 3, 4, 4].should start_with [0, 1]
  561
+    def start_with(expected)
  562
+      BuiltIn::StartWith.new(expected)
  563
+    end
  564
+
537 565
     # Given no argument, matches if a proc throws any Symbol.
538 566
     #
539 567
     # Given a Symbol, matches if the given proc throws the specified Symbol.
2  lib/rspec/matchers/built_in.rb
@@ -24,6 +24,8 @@ module BuiltIn
24 24
       autoload :MatchArray,     'rspec/matchers/built_in/match_array'
25 25
       autoload :RaiseError,     'rspec/matchers/built_in/raise_error'
26 26
       autoload :RespondTo,      'rspec/matchers/built_in/respond_to'
  27
+      autoload :StartWith,      'rspec/matchers/built_in/start_with_end_with'
  28
+      autoload :EndWith,        'rspec/matchers/built_in/start_with_end_with'
27 29
       autoload :Satisfy,        'rspec/matchers/built_in/satisfy'
28 30
       autoload :ThrowSymbol,    'rspec/matchers/built_in/throw_symbol'
29 31
     end
61  lib/rspec/matchers/built_in/start_with_end_with.rb
... ...
@@ -0,0 +1,61 @@
  1
+module RSpec
  2
+  module Matchers
  3
+    module BuiltIn
  4
+      class StartWith
  5
+        include BaseMatcher
  6
+        def initialize(expected)
  7
+          @expected = expected
  8
+        end
  9
+
  10
+        def matches?(actual)
  11
+          @actual = actual
  12
+          if @actual.respond_to?(:[])
  13
+            if @expected.respond_to?(:length)
  14
+              @actual[0, @expected.length] == @expected
  15
+            else
  16
+              @actual[0] == @expected
  17
+            end
  18
+          else
  19
+            raise ArgumentError.new("#{@expected.inspect} does not respond to :[]")
  20
+          end
  21
+        end
  22
+
  23
+        def failure_message_for_should
  24
+          "expected #{@actual.inspect} to start with #{@expected.inspect}"
  25
+        end
  26
+
  27
+        def failure_message_for_should_not
  28
+          "expected #{@actual.inspect} not to start with #{@expected.inspect}"
  29
+        end
  30
+      end
  31
+
  32
+      class EndWith
  33
+        include BaseMatcher
  34
+        def initialize(expected)
  35
+          @expected = expected
  36
+        end
  37
+
  38
+        def matches?(actual)
  39
+          @actual = actual
  40
+          if @actual.respond_to?(:[])
  41
+            if @expected.respond_to?(:length)
  42
+              @actual[-@expected.length, @expected.length] == @expected
  43
+            else
  44
+              @actual[-1] == @expected
  45
+            end
  46
+          else
  47
+            raise ArgumentError.new("#{@expected.inspect} does not respond to :[]")
  48
+          end
  49
+        end
  50
+
  51
+        def failure_message_for_should
  52
+          "expected #{@actual.inspect} to end with #{@expected.inspect}"
  53
+        end
  54
+
  55
+        def failure_message_for_should_not
  56
+          "expected #{@actual.inspect} not to end with #{@expected.inspect}"
  57
+        end
  58
+      end
  59
+    end
  60
+  end
  61
+end
163  spec/rspec/matchers/start_with_end_with_spec.rb
... ...
@@ -0,0 +1,163 @@
  1
+require "spec_helper"
  2
+
  3
+describe "should start_with" do
  4
+
  5
+  context "A test string" do
  6
+    it "passes if it matches the start of the string" do
  7
+      subject.should start_with "A test"
  8
+    end
  9
+
  10
+    it "fails if it does not match the start of the string" do
  11
+      lambda {
  12
+        subject.should start_with "Something"
  13
+      }.should fail_with("expected \"A test string\" to start with \"Something\"")
  14
+    end
  15
+  end
  16
+
  17
+  context [0, 1, 2, 3, 4] do
  18
+    it "passes if it is the first element of the array" do
  19
+       subject.should start_with 0
  20
+    end
  21
+
  22
+    it "passes if the first elements of the array match" do
  23
+      subject.should start_with [0, 1]
  24
+    end
  25
+
  26
+    it "fails if it does not match the first element of the array" do
  27
+      lambda {
  28
+        subject.should start_with "Something"
  29
+      }.should fail_with("expected [0, 1, 2, 3, 4] to start with \"Something\"")
  30
+    end
  31
+
  32
+    it "fails if it the first elements of the array do not match" do
  33
+      lambda {
  34
+        subject.should start_with [1, 2]
  35
+      }.should fail_with("expected [0, 1, 2, 3, 4] to start with [1, 2]")
  36
+    end
  37
+  end
  38
+
  39
+  context Object.new do
  40
+    it "should raise an error if expected value can't be indexed'" do
  41
+      expect { subject.should start_with 0 }.to raise_error(ArgumentError, /does not respond to :\[\]/)
  42
+    end
  43
+  end
  44
+end
  45
+
  46
+describe "should_not start_with" do
  47
+
  48
+  context "A test string" do
  49
+    it "passes if it does not match the start of the string" do
  50
+      subject.should_not start_with "Something"
  51
+    end
  52
+
  53
+    it "fails if it does match the start of the string" do
  54
+      lambda {
  55
+        subject.should_not start_with "A test"
  56
+      }.should fail_with("expected \"A test string\" not to start with \"A test\"")
  57
+    end
  58
+  end
  59
+
  60
+  context [0, 1, 2, 3, 4] do
  61
+    it "passes if it is not the first element of the array" do
  62
+       subject.should_not start_with "Something"
  63
+    end
  64
+
  65
+    it "passes if the first elements of the array do not match" do
  66
+      subject.should_not start_with [1, 2]
  67
+    end
  68
+
  69
+    it "fails if it matches the first element of the array" do
  70
+      lambda {
  71
+        subject.should_not start_with 0
  72
+      }.should fail_with("expected [0, 1, 2, 3, 4] not to start with 0")
  73
+    end
  74
+
  75
+    it "fails if it the first elements of the array match" do
  76
+      lambda {
  77
+        subject.should_not start_with [0, 1]
  78
+      }.should fail_with("expected [0, 1, 2, 3, 4] not to start with [0, 1]")
  79
+    end
  80
+  end
  81
+end
  82
+
  83
+describe "should end_with" do
  84
+
  85
+  context "A test string" do
  86
+    it "passes if it matches the end of the string" do
  87
+      subject.should end_with "string"
  88
+    end
  89
+
  90
+    it "fails if it does not match the end of the string" do
  91
+      lambda {
  92
+        subject.should end_with "Something"
  93
+      }.should fail_with("expected \"A test string\" to end with \"Something\"")
  94
+    end
  95
+  end
  96
+
  97
+  context [0, 1, 2, 3, 4] do
  98
+    it "passes if it is the last element of the array" do
  99
+       subject.should end_with 4
  100
+    end
  101
+
  102
+    it "passes if the last elements of the array match" do
  103
+      subject.should end_with [3, 4]
  104
+    end
  105
+
  106
+    it "fails if it does not match the last element of the array" do
  107
+      lambda {
  108
+        subject.should end_with "Something"
  109
+      }.should fail_with("expected [0, 1, 2, 3, 4] to end with \"Something\"")
  110
+    end
  111
+
  112
+    it "fails if it the last elements of the array do not match" do
  113
+      lambda {
  114
+        subject.should end_with [1, 2]
  115
+      }.should fail_with("expected [0, 1, 2, 3, 4] to end with [1, 2]")
  116
+    end
  117
+  end
  118
+
  119
+  context Object.new do
  120
+    it "should raise an error if expected value can't be indexed'" do
  121
+      expect { subject.should start_with 0 }.to raise_error(ArgumentError, /does not respond to :\[\]/)
  122
+    end
  123
+  end
  124
+end
  125
+
  126
+describe "should_not end_with" do
  127
+
  128
+  context "A test string" do
  129
+    it "passes if it does not match the end of the string" do
  130
+      subject.should_not end_with "Something"
  131
+    end
  132
+
  133
+    it "fails if it matches the end of the string" do
  134
+      lambda {
  135
+        subject.should_not end_with "string"
  136
+      }.should fail_with("expected \"A test string\" not to end with \"string\"")
  137
+
  138
+    end
  139
+  end
  140
+
  141
+  context [0, 1, 2, 3, 4] do
  142
+    it "passes if it is not the last element of the array" do
  143
+       subject.should_not end_with "Something"
  144
+    end
  145
+
  146
+    it "passes if the last elements of the array do not match" do
  147
+      subject.should_not end_with [0, 1]
  148
+    end
  149
+
  150
+    it "fails if it matches the last element of the array" do
  151
+      lambda {
  152
+        subject.should_not end_with 4
  153
+      }.should fail_with("expected [0, 1, 2, 3, 4] not to end with 4")
  154
+    end
  155
+
  156
+    it "fails if it the last elements of the array match" do
  157
+      lambda {
  158
+        subject.should_not end_with [3, 4]
  159
+      }.should fail_with("expected [0, 1, 2, 3, 4] not to end with [3, 4]")
  160
+    end
  161
+
  162
+  end
  163
+end
Commit_comment_tip

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.