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:
If we run the test in isolation we see that the test passes:
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:
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 …