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.
require 'debug'
class Hornet
def initialize
@colors = [:yellow, :red, :black]
end
def show_up
binding.break # debugger will stop here
puts "bzzz"
end
end
Hornet.new.show_up
By running this little program, we will get the following console output:
[debug] ruby test.rb
[4, 13] in test.rb
4| def initialize
5| @colors = [:yellow, :red, :black]
6| end
7|
8| def show_up
=> 9| binding.break # debugger will stop here
10| puts "bzzz"
11| end
12| end
13|
=>#0 Hornet#show_up at test.rb:9
#1 <main> at test.rb:14
(ruby) @colors
[:yellow, :red, :black]
(rdgb)
As you can see, we can access the instance variable from the breakpoint.
Stepping
Let's dig into a more complex example using stepping.
class Book
attr_accessor :title, :author, :price
def initialize(title, author, price)
@title = title
@author = author
@price = price
end
end
class BookStore
def initialize
@books = []
end
def add_book(book)
@books << book
end
def remove_book(title)
@books.delete_if { |book| book.title == title }
end
def find_by_title(title)
@books.find { |book| book.title.include?(title) }
end
end
# Sample Usage:
store = BookStore.new
book1 = Book.new("Dune", "Frank Herbert", 20.0)
book2 = Book.new("The Hobbit", "J.R.R. Tolkien", 15.0)
book3 = Book.new("Hobbit's Journey", "Unknown", 10.0)
store.add_book(book1)
store.add_book(book2)
store.add_book(book3)
puts store.find_by_title("Hobbit").title
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:
def find_by_title(title)
binding.break
@books.find { |book| book.title.include?(title) }
end
Then launch the program and get to the breakpoint:
@box [debug] ruby library.rb 20:25:07
[22, 31] in library.rb
22| def remove_book(title)
23| @books.delete_if { |book| book.title == title }
24| end
25|
26| def find_by_title(title)
=> 27| binding.break
28| @books.find { |book| book.title.include?(title) }
29| end
30| end
31|
=>#0 BookStore#find_by_title(title="Hobbit") at library.rb:27
#1 <main> at library.rb:42
(rdbg)
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.
(rdbg) title
"Hobbit"
(rdbg)
We can run the code right in that context to see what's happening:
(ruby) @books.find { |book| book.title.include?(title) }
#<Book:0x00007fd05e4d59f0 @author="J.R.R. Tolkien", @price=15.0, @title="The Hobbit">
(rdbg)
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.
(rdbg) continue # command
The Hobbit
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.
def add_book(book)
binding.break
@books << book
end
# [ .. ]
store = BookStore.new
binding.break
book1 = Book.new("Dune", "Frank Herbert", 20.0)
book2 = Book.new("The Hobbit", "J.R.R. Tolkien", 15.0)
book3 = Book.new("Hobbit's Journey", "Unknown", 10.0)
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.
[30, 39] in library.rb
30| end
31| end
32|
33| # Sample Usage:
34| store = BookStore.new
=> 35| binding.break
36| book1 = Book.new("Dune", "Frank Herbert", 20.0)
37| book2 = Book.new("The Hobbit", "J.R.R. Tolkien", 15.0)
38| book3 = Book.new("Hobbit's Journey", "Unknown", 10.0)
39|
=>#0 <main> at library.rb:35
(ruby) book1
nil
(rdbg) next # command
[31, 40] in library.rb
31| end
32|
33| # Sample Usage:
34| store = BookStore.new
35| binding.break
=> 36| book1 = Book.new("Dune", "Frank Herbert", 20.0)
37| book2 = Book.new("The Hobbit", "J.R.R. Tolkien", 15.0)
38| book3 = Book.new("Hobbit's Journey", "Unknown", 10.0)
39|
40| store.add_book(book1)
=>#0 <main> at library.rb:36
(ruby) book1
nil
(rdbg) next # command
[32, 41] in library.rb
32|
33| # Sample Usage:
34| store = BookStore.new
35| binding.break
36| book1 = Book.new("Dune", "Frank Herbert", 20.0)
=> 37| book2 = Book.new("The Hobbit", "J.R.R. Tolkien", 15.0)
38| book3 = Book.new("Hobbit's Journey", "Unknown", 10.0)
39|
40| store.add_book(book1)
41| store.add_book(book2)
=>#0 <main> at library.rb:37
(ruby) book1
#<Book:0x00007f50fb5f9da0 @author="Frank Herbert", @price=20.0, @title="Dune">
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.
(rdbg) step # command
[2, 11] in library.rb
2|
3| class Book
4| attr_accessor :title, :author, :price
5|
6| def initialize(title, author, price)
=> 7| @title = title
8| @author = author
9| @price = price
10| end
11| end
=>#0 Book#initialize(title="The Hobbit", author="J.R.R. Tolkien", price=15.0) at library.rb:7
#1 [C] Class#new at library.rb:37
# and 1 frames (use `bt' command for all frames)
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:
[2, 11] in library.rb
2|
3| class Book
4| attr_accessor :title, :author, :price
5|
6| def initialize(title, author, price)
=> 7| @title = title
8| @author = author
9| @price = price
10| end
11| end
=>#0 Book#initialize(title="The Hobbit", author="J.R.R. Tolkien", price=15.0) at library.rb:7
#1 [C] Class#new at library.rb:37
# and 1 frames (use `bt' command for all frames)
(rdbg) up # command
# No sourcefile available for library.rb
=>#1 [C] Class#new at library.rb:37
(rdbg) up # command
=> 37| book2 = Book.new("The Hobbit", "J.R.R. Tolkien", 15.0)
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 code -
bt
(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:
(rdbg) bt # backtrace command
#0 Book#initialize(title="The Hobbit", author="J.R.R. Tolkien", price=15.0) at library.rb:7
#1 [C] Class#new at library.rb:37
=>#2 <main> at library.rb:37
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:
(rdbg) ls # outline command
Object.methods: inspect to_s
locals: book1 book2 book3 store
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
.
(rdbg) break 38 # command
#0 BP - Line /mnt/data/Code/clients/AppSignal/debug/library.rb:38 (line)
(rdbg) break BookStore#find_by_title # command
#1 BP - Method BookStore#find_by_title at library.rb:27
Called on its own, the break
command will list the existing breakpoints (the ones added through the debug console):
(rdbg) break # command
#0 BP - Line /mnt/data/Code/clients/AppSignal/debug/library.rb:38 (line)
#1 BP - Method BookStore#find_by_title at library.rb:27
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.
(rdbg) break BookStore#find_by_title if: book1.title == "Germinal" # command
#1 BP - Method BookStore#find_by_title at library.rb:27 if: book1.title == "Germinal"
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!
Top comments (0)