Debugging is a valuable skill for any software engineer to have. Unfortunately, most software engineers are not trained in it. And that's not just specific to developers going through boot camps; even in universities, we are not often taught and trained to use a debugger.
My teachers and mentors were more interested in getting me to write programs rather than debugging them. If we are fortunate, debugging comes at the end of the semester, in a short, last session.
Luckily, we have tools that can help us with debugging. Since Ruby 3.1, Ruby ships with the debug gem, a powerful debugger.
In this article, we will go through a quick overview of the gem. We'll see how to use it for simple and more advanced cases.
Debugging Without A Debugger: What's the Issue?
Many of us rely on what you might call "printf debugging": we add puts
(or its equivalent in the language we're using) to the standard output (STDOUT). We include the current state of an object, variable, or just a string so we know if our program is going into specific branches of its logic tree.
While helpful, this isn't the most optimal way to debug a program. It often leads to many back-and-forth trips between your logs and the code, as you forget to add a puts
here and there, or leave in some debugging code.
That method also relies on your own preconceptions about how the code is running and what is going on that's different from what you might expect.
Using a debugger is a very different experience. You add one or more breakpoints in the code where you want to know what's happening. You then run the code and wait for it to hit the breakpoint.
Then, you get a debugging console to check a variable's values at the breakpoint location. You go back and forth in the execution steps.
As we will see later, we can even add conditional breakpoints directly from the debugging console. This makes it easier to avoid exiting the debugging console, so you can add breakpoints you've forgotten about.
Setup
Since Ruby 3.1, a version of the debug
gem ships with Ruby. We recommend adding it to your Gemfile
so you're using the latest version.
Add debug
to your Gemfile and then run bundle install
. I recommend adding it to development
and test
groups for debugging tests too.
Basic Debugging Techniques with Debug for Ruby
Now let's run through some simple debugging methods using debug
: using breakpoints, stepping, other commands, moving in the stack, and using a map. We'll then examine the more advanced method of adding breakpoints on the fly.
Breakpoints
Breakpoints are calls that tell the debugger to stop. You can do this in modern IDEs that are integrated into a debugger with a simple click in the sidebar. The standard way is to add binding.break
at the line we want to stop at.
By running this little program, we will get the following console output:
As you can see, we can access the instance variable from the breakpoint.
Stepping
Let's dig into a more complex example using stepping.
This example app manages books in a bookstore. But at the moment, we cannot be sure which book will be returned when we search titles containing 'Hobbit'. It might well be "The Hobbit", but it's not certain.
To help debug this, we'll jump into the find_by_title
method.
Let's add a breakpoint to one of the methods:
Then launch the program and get to the breakpoint:
The top part of the console tells us which line and file we are at. We can then query the value of the title
variable.
We can run the code right in that context to see what's happening:
Here might be a good time to reflect on how you want the program you are building and this piece of code to behave. Expressing the code through RSpec tests might be an excellent way to clarify what it should do.
Let's now continue to the next breakpoint by using the continue
command.
In this case, it goes on until the end of the program.
More Commands to Assist Debugging
Of course, we can add more breakpoints to our code to stop at another place. But we can also use commands to move within the stack of our program without restarting it.
Let's add one more breakpoint to the add_book
method, just after instantiating the bookstore.
Now, when we run the program, it will stop before the book1
variable is instantiated. The continue
command will run the program until the next breakpoint or exit.
Using next
Instead of continue
, we can use the next
command, which will only run the next code line, so we can debug our app in smaller steps. We will need to run next
twice to run the line where book1
is defined before we can inspect it.
Each next
call will run the next line. But it will not step into the code called by Book.new
.
Using step
In some cases, we may know that an issue lies within a specific call. The step
command is great for debugging this.
For example, when we are at line 37, we can use step
to follow the execution of the Book
object that fills the book2
variable.
The step
command brings us directly to the first line of the initialize
method in the Book
class. (If you are new to Ruby, the new
class method is called the initialize
method after it does some internal work). Now we can use the next step from within that method and follow the trail.
next
and step
are crucial to get familiar with, as they allow us to move forward at different levels and speeds.
Moving In the Stack
We can move up and down (or backward and forwards) in the stack by using the up
and down
commands. Calling up
twice will get us back to line 37:
We need to call it twice as we skipped over one step, thanks to the next
command: the call to the parent class of the Book
class: Class
itself (and the new
method).
Using a Map
When we start to use up
, down
, next
, and step
, it's handy to know two more commands:
list
: to show where we are in the codebt
(orbacktrace
): to show the trace of the steps we have followed
For example, when we are at line 37, the bt
command displays the following:
Calling down
twice brings us to step #0
. We can also pass an additional integer to both up
and down
to move through as many steps as we want to in one go.
Knowing What's Available
A very practical command to know is ls
. It will list the variables and methods available to you at your current point in the stack.
For example, on line 37, we see the following:
Using finish
We can go to our next breakpoint using continue
. However, the finish
or fin
command will also bring us to the next breakpoint, or to the end of our program.
You can exit more quickly with Ctrl-D
or quit
.
Adding Breakpoints On the Fly
A more advanced practice is to add breakpoints on the fly while running the debugger.
We have different ways to do that. Let's start with some more simple ways to add a breakpoint:
- To a specific line —
break <line number>
— in the current file. - To the start of a specific method in a specific class:
break ClassName#method_name
.
Called on its own, the break
command will list the existing breakpoints (the ones added through the debug console):
You can also remove breakpoints that are added this way using the del
or delete
command:
del
will remove all breakpoints in one go (confirmation is needed).del X
deletes breakpoints numbered X in the breakpoints list.
Adding Conditions
You can also add conditions when setting a breakpoint. Imagine a method that goes wrong when the book title is "Germinal", but that goes ok if it's "Notre Dame". In this case, we can add a breakpoint on the method, but only if the book title matches.
Integration with IDEs
Many of us rely on modern IDEs and text editors that have support for direct debugging. A good choice is rdbg
: it integrates well with many IDEs.
Check the debug README for more details on rdbg
.
Recap and Wrapping Up
In this post, we covered the following:
- Installing
debug
- Adding breakpoints from your favorite code editor with
binding.break
- Looking at the value of variables and objects from a debugger session
- Navigating within execution frames from the debugger console (with
up
,down
, andnext
) - Listing available variables and methods at any point in the console with
ls
- Adding breakpoints and conditional breakpoints on the fly from the debugger console
- Listing and removing breakpoints (with
break
,delete <number>
, anddelete
) - Ending a debugging session with
finish
,continue
, orquit
.
The help
command also provides plenty of details on the commands we have seen here and more. You can run help break
(for example) to learn more about the break
command and its subcommands.
In conclusion, the debug
tool will greatly help you with debugging over the years.
Most debuggers use similar commands, so don't hesitate to try others out too (check out our post on pry-byebug, for example).
Happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!