Evaluating More Coverage in Ruby 3.2

Kevin Murphy - Jan 14 '23 - - Dev Community

Measuring Coverage of Eval

As I mentioned in my prior post, Ruby 3.2.0 has some changes to the Coverage module. Now the module can measure the coverage of a Ruby expression in a string passed to the eval method.

This is important because of templates. ERB, when we ask for the template through the result method, calls eval. When Rails is rendering a view, that also calls eval. More specifically, Rails calls the module_eval method.

Have you wondered how much of the logic in your views is exercised in your test suite? Thanks to this change, now you can see that in tools like SimpleCov.

Feature Introduction

Let's walk through an example demonstrating this functionality.

require "coverage"
Coverage.start(eval: true, lines: true)
eval("1 > 2 ? 'not reached' : 'covered'", nil, "filename.rb", 1)
Coverage.result
=> {"filename.rb"=>{:lines=>[1]}, "(irb)"=>{:lines=>[nil, nil, 1, 1]}}
Enter fullscreen mode Exit fullscreen mode

We need to require the Coverage module first. After that, we ask coverage to start measuring with the start method. Here we explicitly ask it to measure eval. We're using lines coverage to answer how many times each line is run.

We call eval, passing a string with a ternary statement. We also pass in the optional filename and line number parameters as well. We check our measurement with the result method.

The keys of that hash are file names, or places, where Ruby measures coverage. Because we passed in the filename parameter in our eval call, the eval coverage has a key of the filename passed to eval.

Lines coverage provides an array of numbers. Each number tells us how many times each line was executed. The first item in the array, at index 0, is how many times the first line was executed. Here we see our first line of our single-line eval statement was executed once, as we'd expect.

Opting in to eval

In Ruby 3.2.0, measuring coverage of eval statements is optional. By default,
coverage will not measure eval coverage. You must explicitly tell it to by passing eval: true to Coverage.start. Notice how we have no coverage results without passing eval: true. That's because otherwise, Coverage is looking to measure loaded files.

require "coverage"
Coverage.start(lines: true)
eval("1 > 2 ? 'not reached' : 'covered'", nil, "filename.rb", 1)
Coverage.result
=> {}
Enter fullscreen mode Exit fullscreen mode

Using the :all option will also measure the coverage of eval.

require "coverage"
irb(main):002:0> Coverage.start(:all)
irb(main):003:0> eval("1 > 2 ? 'not reached' : 'covered'", nil, "filename.rb", 1)
irb(main):004:0> Coverage.result
=>
{"(irb)"=>{:lines=>[nil, nil, 1], :branches=>{}, :methods=>{}},
 "filename.rb"=>
  {:lines=>[1], :branches=>{[:if, 0, 1, 0, 1, 33]=>{[:then, 1, 1, 8, 1, 21]=>0, [:else, 2, 1, 24, 1, 33]=>1}}, :methods=>{}}}
Enter fullscreen mode Exit fullscreen mode

Setting the Mode

I have unintentionally demonstrated this when showing the :all option, but you can measure coverage of eval statements with the different modes available in Coverage.

Our eval statement has two code paths on a single line because it's a ternary. Let's measure the branches coverage of our statement.

require "coverage"
Coverage.start(eval: true, branches: true)
eval("1 > 2 ? 'not reached' : 'covered'", nil, "filename.rb", 1)
Coverage.result
=> {"filename.rb"=>{:branches=>{[:if, 0, 1, 0, 1, 33]=>{[:then, 1, 1, 8, 1, 21]=>0, [:else, 2, 1, 24, 1, 33]=>1}}}, "(irb)"=>{:branches=>{}}}
Enter fullscreen mode Exit fullscreen mode

Here we see that we've executed the else statement of our if test, but not the other side (the then) of our conditional. A more in-depth explanation of this output is available here.

Mode Required

Generally, Coverage will start in lines mode when provided no options in start. However, I've noticed that when you ask Coverage to start and measure eval coverage, you must also specify the mode(s) you want to measure.

As you can see, starting Coverage with eval on and no mode gives us no coverage results.

require "coverage"
Coverage.start(eval: true)
eval("1 > 2 ? 'not reached' : 'covered'", nil, "filename.rb", 1)
Coverage.result
=> {"filename.rb"=>{}, "(irb)"=>{}}
Enter fullscreen mode Exit fullscreen mode

Credit

Thank you to Samuel Williams for introducing this functionality into the Ruby codebase. Thank you to Yusuke Endoh for adding it to SimpleCov, and also for writing and maintaining most of the Coverage functionality available in Ruby's standard library.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .