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:
| Command | What it does |
|---|---|
| next | Run the current line and stop on the next one in this frame. |
| step | Enter the method called on this line. |
| finish | Run to the end of the current method and stop in the caller. |
| continue | Run until the next breakpoint or the end. |
| p expression | Print 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