When debugging we sometimes place a break point in our method of interest just to find that it is called a bazillion times before the actual invocation you are interested in. We realise that we don't want to break into the method on every invocation, we just want to focus on a single call. What can we do? This article looks at a simple trick to help overcome this problem.

Introduction

Effective debugging of code is a skill that can only be learned the hard way. Whilst there may be prerequiste skills, and tools that can improve your experience, there is an undeniable element of artistry in being able to quickly zero-in on the precise code that is causing problems. This short post will not teach you how to debug code, but hopefully it will provide you with a simple tool to add to your utility belt.

The code under test

We'll start off with a simple User model, as shown below:

    
class User
  @@all_users = []

  attr_reader :name, :email, :friends

  def initialize(name: nil, email: nil)
    @name = name
    @email = email
    @friends = []
    @@all_users << self
  end

  def add_friend(friend_name)
    return unless friend = @@all_users.find{ |u| u.name==friend_name }
    return if is_friend?(friend_name)
    @friends << friend
    friend.add_friend(name)
  end

  def total_friends
    @friends.size
  end

  def is_friend?(friend_name)
    @friends.any?{ |u| u.name==friend_name }
  end

  def self.all_users
    @@all_users
  end
end
    
  

A User instance can be initialized with a name and email attributes. When we create an instance we will automatically add it to the class variable, @@all_users. This will act like our store for User instances. Each User instance will internally maintain an array of @friends, these will be other User instances who are friends with the current User.

The User model exposes an #add_friend method. This will take the name of another user, and set up a friend relationship between the two users. This is done by by mutually adding one another to their @friends arrays. Take a look at the GitHub repo if you want to see the full class definition.

Testing the User class

We now write some simple tests for our new User class as follows:

    
RSpec.describe User do
  before :all do
    @white = User.new(name: "Mr White", email: "white@example.com")
    @blue = User.new(name: "Mr Blue", email: "blue@example.com")
    @blonde = User.new(name: "Mr Blonde", email: "blonde@example.com")
    @pink= User.new(name: "Mr Pink", email: "pink@example.com")
  end

  …

  describe "instance method" do
    describe "#add_friend" do
      before :all do
        [@white, @blue, @blonde].each do |friend|
          @pink.add_friend(friend.name)
        end
      end

      …

      describe "where user exists for the name passed" do
        it "should create a friendship for the name passed" do
          expect(@blue.is_friend?("Mr Blonde")).to be_falsey
          @blue.add_friend("Mr Blonde")
          expect(@blue.is_friend?("Mr Blonde")).to be_truthy
        end

        it "should increase the total number of friends by 1" do
          orig_count = @blue.friends.size
          @blue.add_friend("Mr Blonde") 
          expect(@blue.friends.size).to eq orig_count+1
        end
      end
    end
  end
end
    
  

I have omitted some tests for brevity but, for those who prefer, you can see the full spec file on GitHub. When we run the tests we find that one of the tests is failing:

Failing test suite
Running user specs gives a single test failure

If we run the test in isolation we see that the test passes:

Passing individual test
Test will pass when run in isolation as it is an order-dependent test failure

So it appears that we have an example of an order-dependent test failure. This is the situation where there is some shared state between the tests in a suite, and an earlier test mutates (or corrupts) this state, causing a failure in a later test.

In this case, the shared state is the @@all_users class variable, which is populated by User instances created outside the individual tests. This array of instances is shared and/or mutated by the individual tests.

The conflict occurs within a single file in our case, but in a larger test suite the source of the problem can be in a completely different file making this a very difficult problem to solve. This is the main motivation for striving to design completely isolated tests, where each test is responsible for setting up the state that it requires to run. In practice this can be overkill, and shared state within a single test file can be an acceptable compromise, and this can be implemented using RSpec before :all and after :all blocks, as has been used in this example.

Back to the example in hand. We want to understand why this test has failed, so we place a break-point in the #add_friend method and re-run the tests. But the break-point triggers loads of times. Inspecting the test file we can see the #add_friend method is called directly many times in the preceding set-up and tests. And each of these call sites includes a hidden invocation of the same method, to set up the reciprocal friend relationship. Stepping thought each invocation to get to the one we are interested in is going to take a loong time and you could easily miss the relevant one by accident. This is infuriating and it's just not a practical approach. We have all tried it at least once, right :) And just to reiterate, we don't have the option of running the test in isolation because we need to run the full test suite in order to surface this order-dependent test failure. So what do we do?

Laser-focused debugging

We really want to break on the #add_user invocation that emanates from the last test only. Lets' start by adding the following lines to our spec file:

    
$debug_flag = false

def with_debug_flag
  $debug_flag = true
  yield
ensure
  $debug_flag = false
end

RSpec.describe User do
  before :all do
    @white = User.new(name: "Mr White", email: "white@example.com")

     …

      describe "where user exists for the name passed" do
        it "should create a friendship for the name passed" do
          expect(@blue.is_friend?("Mr Blonde")).to be_falsey
          @blue.add_friend("Mr Blonde")
          expect(@blue.is_friend?("Mr Blonde")).to be_truthy
        end

        it "should increase the total number of friends by 1" do
          orig_count = @blue.friends.size
          with_debug_flag{ @blue.add_friend("Mr Blonde") }     # <--- Apply our global flag here
          expect(@blue.friends.size).to eq orig_count+1
        end
      end
    end
  end
end
    
  

Here we have introduced a global variable $debug_flag at the top of our spec file, which will take an initial value of false. We also define a utility method, with_debug_flag, which takes a block and will set the $debug_flag variable to true whilst the block is being executed. We now use this global flag in our specific method that we want to break into, i.e. in the User class:

    
class User
  …

  def add_friend(friend_name)
    byebug if $debug_flag     # <--- We set our breakpoint conditioned on our global flag
    return unless friend = @@all_users.find{ |u| u.name==friend_name }
    return if is_friend?(friend_name)
    @friends << friend
    friend.add_friend(name)
  end
  …
end
    
  

In the above code, we can see that we can now set a conditional breakpoint in out #add_user method, based on the value of our global $debug_flag. Running the specs again will execute all tests as normal, breaking into the method for the final test only:

Failing test
With conditional breakpoint we can focus on the single invocation that we are interested in

When focussed on the correct invocation we can quickly understand that the test fails because "Mr Blonde" was previously added as a friend, meaning he won't be re-added and the friends array will not increase in size. We can resolve the failure by removing this user explicitly (or clearing all friends) before executing the test:

    
RSpec.describe User do
        …
        it "should increase the total number of friends by 1" do
          @blue.remove_friend("Mr Blonde")   # <-- First ensure that "Mr Blonde" is not a friend
          orig_count = @blue.friends.size
          @blue.add_friend("Mr Blonde")
          expect(@blue.friends.size).to eq orig_count+1
        end
        …
end
    
  

Summary

We have looked at a simple trick for debugging in ruby, involving a global variable that we use to toggle a conditional debug statement. This can be a useful trick when you want to break into a frequently called method, but only under certain conditions which originate outside the context of the method itself.

Comments

There are no existing comments

Got your own view or feedback? Share it with us below …

×

Subscribe

Join our mailing list to hear when new content is published to the VectorLogic blog.
We promise not to spam you, and you can unsubscribe at any time.