Search code examples
rubyrspecbdd

Should the use of safe navigation operator be unit tested with both scenarios (object exist + object nil)?


consider the following code:

class Child
  belongs_to :some_parent, optional: true
  def status
    some_parent&.calculate_status
  end
end

Is that trivial enough to not write a unit test for each scenario or is it better to write scenarios such as:

describe '#status' do 
  context 'when some_parent does not exist' do
    ## assume let/before block exist to properly set this up
    it 'returns nil' do
      expect(subject.status).to_be nil
    end
  end 

  context 'when some_parent exists' do
    ## assume let/before block exist to properly set this up
    it 'returns the value from some_parent' do
      expect(subject.status).to eql('expected status')
    end
  end
end

Solution

  • It's not necessarily a matter of there being a factual answer to this. How much code coverage you need is up to you. However if we reframe your question as Is there less code coverage when not testing the nil state? then the answer is yes because they are two different branches and each must be evaluated separately.

    When some_parent is nil:

    some_parent&.calculate_status
    #          ^ short circuits here and we branch to nil
    

    When some_parent exists:

    some_parent&.calculate_status
    #           ^ method evaluation occurs and we branch to the method
    

    A simple way to verify this is to test it using simplecov. Here's an example app that can verify it:

    # Gemfile
    
    source 'https://rubygems.org'
    
    gem 'rspec'
    gem 'simplecov'
    gem 'simplecov-console'
    
    # app.rb
    
    # We create the equivalent of your ++belongs_to++ call
    # with these classes
    class Parent
      def calculate_status
        "in parent"
      end
    end
    
    class Child
      attr_reader :some_parent
    
      def initialize(parent = nil)
        @some_parent = parent
      end
    
      def status
        @some_parent&.calculate_status
      end
    end
    
    # app_spec.rb
    
    require "simplecov"
    require "simplecov-console"
    
    SimpleCov.start { enable_coverage :branch }
    SimpleCov.formatter = SimpleCov::Formatter::Console
    
    require_relative "app"
    
    RSpec.describe "#status" do
      context "when some_parent does not exist" do
        it "returns nil" do
          expect(Child.new.status).to be nil
        end
      end
    
      context "when some_parent exists" do
        it "returns the value from some_parent" do
          expect(Child.new(Parent.new).status).to eq("in parent")
        end
      end
    end
    

    Then we run the tests:

    bundle exec rspec app_spec.rb
    ..
    
    Finished in 0.00401 seconds (files took 0.09368 seconds to load)
    2 examples, 0 failures
    
    
    COVERAGE: 100.00% -- 9/9 lines in 1 files
    BRANCH COVERAGE: 100.00% -- 2/2 branches in 1 branches
    

    We've tested both branches and have 100% coverage. If we tell it to run only one of the two specs we get a different result:

    bundle exec rspec app_spec.rb:14
    Run options: include {:locations=>{"./app_spec.rb"=>[14]}}
    .
    
    Finished in 0.00141 seconds (files took 0.08878 seconds to load)
    1 example, 0 failures
    
    
    COVERAGE:  88.89% -- 8/9 lines in 1 files
    BRANCH COVERAGE:  50.00% -- 1/2 branches in 1 branches
    
    +----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+
    | coverage | file   | lines | missed | missing | branch coverage | branches | branches missed | branches missing |
    +----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+
    |  88.89%  | app.rb | 9     | 1      | 3       |  50.00%         | 2        | 1               | 15[then]         |
    +----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+
    

    Or the other spec:

    bundle exec rspec app_spec.rb:20
    Run options: include {:locations=>{"./app_spec.rb"=>[20]}}
    .
    
    Finished in 0.00128 seconds (files took 0.08927 seconds to load)
    1 example, 0 failures
    
    
    COVERAGE: 100.00% -- 9/9 lines in 1 files
    BRANCH COVERAGE:  50.00% -- 1/2 branches in 1 branches
    
    +----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+
    | coverage | file   | lines | missed | missing | branch coverage | branches | branches missed | branches missing |
    +----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+
    | 100.00%  | app.rb | 9     | 0      |         |  50.00%         | 2        | 1               | 15[else]         |
    +----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+
    

    So if you want full branch coverage then write both specs.