2
votes

Rspec's match operator is a fantastic tool to validate the structure of data structures using pattern matching.

This works great:

expect([1,2,2]).to match([a_kind_of(Integer), 2, 2)])

However, often we don't want to validate the specific values contained in the data structure, and instead want to validate that the relation between different elements is what we expect. Reusing the example, we might just want to say that the values in the second and third position are the same (e.g. because they are randomly generated IDs).

Conceptually, I'm looking for the capacity that variables provide in Elixir's pattern matching:

iex(1)> [1, a, a] = [1, 2, 2]
[1, 2, 2]
iex(2)> [1, a, a] = [1, 3, 3]
[1, 3, 3]
iex(3)> [1, a, a] = [1, 2, 3]
** (MatchError) no match of right hand side value: [1, 2, 3]

Converting to rspec, it might look like this example:

expect([1,2,2]).to match([a_kind_of(Integer), variable('a'), variable('a'))])

So, my main question is: Does anything like this magical variable matcher exist in RSpec?

If not, is there a way I could build it? I'm pretty sure I could build something out of the satisfies matcher using something akin to:

@state = {} # challenging to clean up this on every test
def variable(var_name)
   satisfying {|x|
     if @state.include?(var_name)
       x == @state[var_name]
     else
       @state[var_name] = x
       true
     end
   }
end

But then it becomes challenging to clean up the state between tests.

2
This sounds like something that might be resolved by ruby 2.7 patter matching, but sadly does not work at least as of 2.7.1: [1,2,2] in [1,a,a] #=> SyntaxError ((irb):1: duplicated variable name) - Siim Liiser

2 Answers

0
votes

I might have not fully understood the use case, but from the title it seems like contain_exactly should work as intended.

So something like:

RSpec.describe "sample" do
  let(:a) { 2 }
  it { 
    expect([a, 1, a]).to contain_exactly(1, a, a) 
    
  }
end
0
votes

I think I was able to answer my own question. It still has a few quirks, but it seems to work.

  1. I needed to do the ugly thing of leaking the example into the matcher (through the TestSelfLeaker module), so that I could store data in the example
  2. error messages aren't as great as I'd like them to, as it seems it's showing the error message of the match and not the one from my matcher

Here's my solution, along with a few examples (hopefully the uuid example is better at showing what I'm looking to achieve):

require 'rspec/expectations'
require 'securerandom'

RSpec::Matchers.define :variable do |match_name|
  match do |actual|
    test = leak_self
    var_name = :@a_unique_var_name_712398
    state = test.instance_variable_get(var_name)
    if state.nil?
      state = {}
      test.instance_variable_set(var_name, state)
    end

    if state.include?(match_name)
      actual == state[match_name]
    else
      state[match_name] = actual
      true
    end
  end

  failure_message do |actual|
    test = leak_self
    var_name = :@a_unique_var_name_712398
    state = test.instance_variable_get(var_name)
    expected = state[match_name]

    "expected that #{actual} would be equal to other instances of #{match_name}, but other instances of #{match_name} contain #{expected}"
  end
end

module TestSelfLeaker
  def leak_self
    self
  end
end

RSpec.configure do |conf|
  conf.include ::TestSelfLeaker
end

RSpec.describe 'variable' do
  it 'records the value of the first time it sees a name and only passes if every other time the value is the same' do
    expect([1, 2, 2]).to match([a_kind_of(Integer), variable('a'), variable('a')])
    expect([1, 3, 2, 2, 3]).to match([a_kind_of(Integer), variable('b'), variable('a'), variable('a'), variable('b')])
    expect([1, 2, 3]).not_to match([a_kind_of(Integer), variable('c'), variable('c')])
  end

  it "doesn't reuse the variables between different tests" do
    expect([1, 3, 3]).to match([a_kind_of(Integer), variable('a'), variable('a')])
  end

  it "does reuse variables within the same test (which is confusing, but that's life)" do
    expect([1, 3, 3]).to match([a_kind_of(Integer), variable('a'), variable('a')])

    expect([1, 4, 4]).not_to match([a_kind_of(Integer), variable('a'), variable('a')])
  end
end

class Subject
  def gen_num
    uuid1 = SecureRandom.uuid
    uuid2 = SecureRandom.uuid
    [1, uuid1, uuid1, uuid2, uuid2]
  end
end

RSpec.describe Subject do
  it 'passes when expectations match' do
    expect(Subject.new.gen_num).to match([a_kind_of(Integer), variable('id1'), variable('id1'), variable('id2'), variable('id2')])
  end
end

And here's the output:

....

Finished in 0.0116 seconds (files took 0.10609 seconds to load)
4 examples, 0 failures