Search code examples
ruby-on-railsrubyunit-testingrspecmocking

Mocking only middle layer of nested blocks with rspec


I'm writing some code using this gem called kubernetes_leader_election which helpfully has a full example showing how to use it in its README. My usage of it is basically the same as what the README says, but here are the most relevant lines:

lease_name = self.class.name.underscore.gsub(/[\/_]+/, "=")
is_leader = false
elector = KubernetesLeaderElection.new(lease_name, kubeclient, logger: Rails.logger)
Thread.new { elector.become_leader_for_life { is_leader = true } }
sleep 1 until is_leader

I'm trying to write a spec that tests that the lease_name I passed in is what I expect, and that the code will not sleep forever if the block, { is_leader = true }, executes. I know how to mock KubernetesLeaderElection.new, but I'm kind of new to using rspec mocks, so I'm really struggling to mock elector.become_leader_for_life with the same robustness (being able to test what it gets passed.) I've tried a bunch of variations on this overall concept, but none do quite what I need, and most give errors. Here's my latest attempt, which doesn't fail, but doesn't test everything I want:

describe LeaderElectionMixin
  class MixinUser
    include LeaderElectionMixin
  end

  describe "#wait_to_be_leader" do
    let(:dummy_client) { double(Kubeclient::Client) }
    let(:dummy_elector) { double(KubernetesLeaderElection) }

    before do
      allow(Kubeclient::Client).to receive(:new).and_return(dummy_client)
      expect(dummy_elector).to receive(:become_leader_for_life) do |&block|
        # only checks that the actual block and this proc return the same thing
        expect(block).to match(Proc.new { true })
      end.and_yield
    end

    it "should create leases based on the class name" do
      expect(KubernetesLeaderElection).to receive(:new)
        .with("mixin-user", dummy_client, logger: Rails.logger)
        .and_return(dummy_elector)
      MixinUser.new.wait_to_be_leader
    end
  end
end

(EDIT with update: If I use .and_yield, I can get past the problem of rspec hanging, but it's not a full solution yet, because I can't test an actual match of the block contents because .and_yield makes my block not be the end of the chain, more info here. If I move .and_yield before the block, then expect(block) is expect(nil). No idea why.)

The problems I've had with my approaches have been either that rspec hangs because is_leader = true is not executed, so sleep 1 until is_leader will run forever, or (with my latest implementation) I can get the original block to execute, but I can't make assertions that it did, I can only infer that it did because the spec ends. I have tried various suggestions from StackOverflow and GitHub threads that say I should be able to say expect(block).to be either the string that is the exact contents of the block code, or a Proc that contains the same code, but approaches don't work because the actual value is a Proc (so the suggestions to use strings don't work) and it's not the same Proc object (so be and eq don't work.) I have also tried attaching .and_wrap_original to various places, but that either gives me an error or gives me the same behavior as this example. The best I can do is match to at least check that the return value of the block is the same as the Proc I make. I am utterly lost. I know I can just switch to using an instance variable, but I don't want to expose that to classes including my mixin.

So to summarize, I:

  1. Definitely need the most inner block, is_leader = true, to actually execute, and would like to test that its contents are what I specify;
  2. Definitely need the middle block, elector.become_leader_for_life, to not call the original code (because it's a third party library and I don't want to mock details of its implementation;)
  3. Don't really care whether the outer layer, Thread.new, actually executes (the spec doesn't need the code to be multithreaded, that's only there for runtime efficiency, hence why I mocked out sleep for my specs.)

How can I achieve this "mocking only the middle block" behavior without resorting to tainting the code with mocking smells like using instance variables?


Solution

  • I'm not familiar with what this does:

    expect(block).to be("is_leader = true")
    

    but the code:

    expect(dummy_elector).to receive(:become_leader_for_life) do |&block|
      expect(block).to be("is_leader = true")
    end
    

    doesn't ever execute the block it's receiving, so is_leader will never be changed.

    Assuming you can get the source of a block, could you do something like:

    expect(dummy_elector).to receive(:become_leader_for_life) do |&block|
      block.call
    end
    

    potentially with the block.call conditional on the block source?