Debugging with the debug gem

Installing the gem

debug is the debugger that ships with modern Ruby. It is a bundled gem, so a full Ruby install usually has it, and a minimal one needs the install:

gem install debug

1 gem installed

The gem builds a native extension, so it needs a compiler. These commands were run on the host in the ruby:3.4 image, which has one; the transcript below is from that run.

A breakpoint

A breakpoint is one line: binding.break stops the program at that point and opens a prompt. The file below has a bug worth stopping for: the total ignores the discount.

def total(prices, discount) sum = prices.sum binding.break sum - discount end puts total([10, 20], 5)

ruby -r debug dbg.rb

[1, 7] in dbg.rb 1| def total(prices, discount) 2| sum = prices.sum => 3| binding.break 4| sum - discount 5| end 6| 7| puts total([10, 20], 5) =>#0 Object#total(prices=[10, 20], discount=5) at dbg.rb:3 #1 <main> at dbg.rb:7 (rdbg) p sum => 30 (rdbg) next [1, 7] in dbg.rb 1| def total(prices, discount) 2| sum = prices.sum 3| binding.break => 4| sum - discount 5| end 6| 7| puts total([10, 20], 5) =>#0 Object#total(prices=[10, 20], discount=5) at dbg.rb:4 #1 <main> at dbg.rb:7 (rdbg) p sum - discount => 25 (rdbg) continue 25

-r debug loads the gem before the script, which is what turns binding.break into a stop instead of a NoMethodError. The program runs to that line and waits, with the source, the frame, the file and the line number printed above the prompt. The => marks the current line and the current frame; the lines starting with (rdbg) are the typed commands.

The commands that matter

Five commands cover most sessions. Each one is a word at the prompt:

CommandWhat it does
nextRun the current line and stop on the next one in this frame.
stepEnter the method called on this line.
finishRun to the end of the current method and stop in the caller.
continueRun until the next breakpoint or the end.
p expressionPrint a value, the same p as in a script.

At the breakpoint, p sum answers 30 and p discount answers 5, so the subtraction line is not the problem: the value arrived wrong or the formula is. The debugger answers the question that a puts cannot, because it can read every local without editing the file.

A step that enters a method from the standard library is rarely useful; next over it and step only into your own code. list shows the source around the current line, and bt prints the backtrace.

A breakpoint in a test

The same line works inside a test, which is where a failure is easiest to reproduce. The test uses Minitest; lesson 149, later, explains the assertions. Run the one test that fails and let it stop:

require "minitest/autorun" def total(prices, discount) prices.sum - discount end class TotalTest < Minitest::Test def test_total_with_discount binding.break assert_equal 25, total([10, 20], 5) end end

Save that as test_total.rb in the same folder as the other files. The test defines the method it exercises, so no -I flag is needed: the file is the program.

ruby -r debug test_total.rb

Run options: --seed 61142 # Running: [4, 12] in test_total.rb 4| prices.sum - discount 5| end 6| 7| class TotalTest < Minitest::Test 8| def test_total_with_discount => 9| binding.break 10| assert_equal 25, total([10, 20], 5) 11| end 12| end =>#0 TotalTest#test_total_with_discount at test_total.rb:9 #1 block in Minitest::Test#run (2 levels) at /usr/local/lib/ruby/gems/3.4.0/gems/minitest-5.25.4/lib/minitest/test.rb:94 # and 17 frames (use "bt" command for all frames) (rdbg) p total([10, 20], 5) => 25 (rdbg) continue . Finished in 0.058691s, 17.0384 runs/s, 17.0384 assertions/s. 1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

The debugger stops before the assertion, so the arguments are still available and the failure has not happened yet. p total([10, 20], 5) answers 25, which says the method is right and the assertion is not; continue then finishes the run and the Minitest report still prints. Two habits make this useful: run a single test rather than the suite, and continue as soon as the value is what you expected.

A breakpoint that is left in a committed file blocks CI. Remove it before the commit, or guard it with an environment variable: binding.break if ENV["DEBUG"].

What to watch for

binding.break stops with no terminal attached — a CI job, a piped run — and waits forever. That is the usual reason a build hangs after a debugging session.

The gem is a development dependency, not a runtime one. Declare it in the :development group of the Gemfile so a production bundle does not carry a debugger, and the -r debug in a command is the explicit opt-in.

Next: TracePoint, benchmark and profilers

Article author: Arthur Isaev

Banner image