DEV Community

Cover image for Debugging in Ruby with Debug
Thomas Riboulet for AppSignal

Posted on • Originally published at blog.appsignal.com

Debugging in Ruby with Debug

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 (or backtrace): 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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, and next)
  • 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>, and delete)
  • Ending a debugging session with finish, continue, or quit.

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)