0

I have an interesting problem, and do not know if I am overthinking something, or just missing the big picture.

I have a Rspec feature test iterating over each possible “user flow”. Each “user flow” has its own defined values in let, to determine different mock values that each “it” block needs. I have for example:

flow_scenarios:
  - description: "When user is viewing after hours
    stubbed_values:
      - hours: "off"
      - graphic: "after_hours"
    ...

I have a Ruby class that reads in this YAML and gives helper methods. Let’s call this UserFlowTest. It takes the YAML and the pre-defined user.

So in my Spec file I have:

RSpec.describe "user flow", type: :feature do
  let(:user) { create :user, name: "Brandon" }
  let(:user_flows) { UserFlowTest.new(flow_file: Rails.root.join("after_hours.yml", user:))}

  user_flow.each do |user_flow|
    context "something" do
      user_flow.stubbed_values.each do |stubbed_value|
        stubbed_value.each do |key, value|
         let(key.to_sym) { value }
       end

      it "will show the correct graphic" do
          ...
      end
      ….

This does not work as context/describe will not allow user_flow to be iterated over because user is lazily loaded. Without user going into user_flow instantiation, this works great.

I would just like to be able to have an it block but each it block will have to have some let blocks for the setup..

What can I do?

2
  • It feels like there are some anti-patterns there. Creating a context within a loop seems like a smell. Maybe you could be better off using RSpec shared examples instead, but it's hard to go deeper on this idea without seeing the real code (the one covered by the tests) Commented Feb 16 at 19:41
  • 1) Where is user_flow defined? 2) What is the error message? 3) You don't have to use let, nor make individual variables for each key/value pair. Try using one hash. Commented Feb 17 at 18:45

1 Answer 1

2

It's easy to get fixated on declaring everything with let, but you don't have to. Use let to take advantage of its lazy-evaluation, but let is most useful with shared examples as we'll see below.

If you're solving a problem by breaking up a hash into dynamically named variables, now you have two problems. That's code which is difficult to understand and maintain.

We can implement your code without using let at all. Just normal variables. And we can use the hash directly.

require 'rspec'

RSpec.describe "user flow" do
  user_flows = [
    { name: "This", foo: :bar },
    { name: "That", in: :out, foo: :bar }
  ]

  user_flows.each do |user_flow|
    context "something #{user_flow[:name]}" do
      it "has foo set to bar" do
        expect(user_flow[:foo]).to eq :bar
      end
    end
  end
end

But it's probably better to get rid of your flow scenarios file entirely and instead write it as contexts using shared examples. Now we can take full advantage of let.

require 'rspec'

RSpec.describe "user flow" do
  shared_examples "it is a user flow" do
    it "has foo set to bar" do
      expect(foo).to eq :bar
    end

    it "has a name" do
      expect(name).not_to be_empty
    end
  end

  context "this user flow" do
    let(:foo) { :bar }
    let(:name) { "This" }

    it_behaves_like "it is a user flow"
  end

  context "that user flow" do
    let(:foo) { :bar }
    let(:name) { "That" }

    it_behaves_like "it is a user flow"
  end
end

Here is a more practical example of using shared examples in multiple contexts.

require 'rspec'

class Secure
  def self.something
    42
  end
end

class User
  attr_accessor :name

  def initialize(name, admin)
    @name = name
    @admin = admin
  end

  def some_admin_function
    raise "Not an admin" unless @admin

    Secure.something
  end

  def logged_in?
    @logged_in
  end

  def login
    @logged_in = true
  end

  def logout
    @logged_in = false
  end
end

RSpec.describe User do
  shared_examples "it is a User" do
    it 'has a username' do
      expect(user.name).not_to be_empty
    end

    it "can log in and log out" do
      user.login
      expect(user).to be_logged_in
      user.logout
      expect(user).not_to be_logged_in
    end
  end

  context "regular user" do
    let(:user) { User.new("Regular", false) }

    it_behaves_like "it is a User"

    it "cannot change admin settings" do
      expect { user.some_admin_function }.to raise_error "Not an admin"
    end
  end

  context "admin user" do
    let(:user) { User.new("Admin", true) }

    it_behaves_like "it is a User"

    it "can change admin settings" do
      expect( user.some_admin_function ).to eq 42
    end
  end
end

Both contexts share the examples about being a user, then they have their own examples specific to their contexts.

Finally, sometimes you do have complex test data that would be too much clutter for a single test file. Rather than reading data from a YAML file, write test factories using factory_bot.

factory :user do
  name { Faker::Name.name }
  admin { false }

  trait :admin do
    admin { true }
  end

  initialize_with { new(name, admin) }
end

And then we can use that to create all sorts of semi-random test data.

  context "regular user" do
    let(:user) { build(:user) }

    ...
  end

  context "admin user" do
    let(:user) { build(:user, :admin) }

    ...
  end
Sign up to request clarification or add additional context in comments.

1 Comment

You can also pass arguments to shared examples by adding block arguments to the shared example block e.g. shared_examples "it is a user flow" do |foo:,name:| or you can can call let inside a block passed to it_behaves_like e.g. it_behaves_like "it is a user flow" { let(:foo) { :bar } }

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.