Improve your specs quality with branch coverage

Posted on 2022-04-29 by Karol Bąk Comments
ruby
testing

Introduction

It’s a common practice to measure the quality of tests to make sure they cover all the code. Most ruby developers are familiar with SimpleCov. By default, it measures only line coverage, which sometimes can make false assurance that our code is fully tested. In Ruby we have many options to write conditional code in single lines:

return if something_happened?
some_var = argument.is_a?(SomeClass) ? argument : SomeClass.new(argument)

In such cases, line coverage will mark lines as covered no matter what’s the result of the condition.

Line coverage

Let’s take a look at a very simple example:

def read_file_if_exists(path)
  return '' unless File.exist?(path)
  
  File.read(path)
end

and spec for it:

require 'simplecov'

SimpleCov.start

require_relative './example.rb'

RSpec.describe 'read_file_if_exists' do
  subject { read_file_if_exists('example.txt') }

  it 'returns content of the file' do
    expect(subject).to eq('abc')
  end
end

As you’ve probably noticed I haven’t tested the case when a file doesn’t exist. Yet my line coverage is 100%.

$ rspec example_spec.rb
.

Finished in 0.0014 seconds (files took 0.07227 seconds to load)
1 example, 0 failures

Coverage report generated for RSpec to /Users/kukicola/Desktop/example/coverage. 3 / 3 LOC (100.0%) covered.

My test isn’t perfect but I think we can agree that in this case, it’s not the end of the world.

Let’s move on to the second example:

def number_to_dollar(number)
    dollar_part = "#{number.to_i} dollar(s)"
    cent_part = number % 1 != 0 ? " #{((number - number.to_i) * 100).to_i} cent(s)" : nil
    dollar_part + cent_part
end

and spec for it:

require 'simplecov'

SimpleCov.start

require_relative './example2.rb'

RSpec.describe 'number_to_dollar' do
    subject { number_to_dollar(2.56) }

    it 'returns dollar and cents' do
        expect(subject).to eq('2 dollar(s) 56 cent(s)')
    end
end

My test will pass and line coverage is 100%. If you look carefully you’ll see that my code has an obvious bug. For values without a decimal part it will raise an error because it won’t be able to add nil to the string:

irb(main):002:0> number_to_dollar(2)
/Users/kukicola/Desktop/example/example2.rb:4:in `+': no implicit conversion of nil into String (TypeError)

What is branch coverage and why it’s important?

Branch coverage is a measure of how many branches/results of the conditions have been executed during testing. You can turn it on by simply adding enable_coverage :branch into SimpleCov.start block. Let’s enable it for my spec:

require 'simplecov'

SimpleCov.start do
  enable_coverage :branch
end

require_relative './example2.rb'

RSpec.describe 'number_to_dollar' do
  subject { number_to_dollar(2.56) }

  it 'returns dollar and cents' do
    expect(subject).to eq('2 dollar(s) 56 cent(s)')
  end
end

In my coverage/index.html file I can see that my test doesn’t cover all the branches:

1 files in total.
5 relevant lines, 5 lines covered and 0 lines missed. ( 100.0% )
2 total branches, 1 branches covered and 1 branches missed. ( 50.0% )

Thanks to branch coverage it’s easy to identify that I missed a case when the cent part is zero. Having that information I can add additional test for that case, see that it fails and fix my code.

Conclusion

As you can see even 100% line coverage is not enough to be sure that my code is correct and fully tested. Branch coverage can help to identify missed cases and prevent potential bugs in the app.