Следует ли использовать оператор безопасной навигации для модульного тестирования с обоими сценариями (объект существует + объект ноль)?

рассмотрим следующий код:

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

Достаточно ли это тривиально, чтобы не писать модульный тест для каждого сценария, или лучше писать такие сценарии, как:

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

Тестирование — хорошая привычка, но тестировать ничего не нужно. Это полностью зависит от вас и вашей команды, чтобы решить, что вы хотите протестировать и какое тестовое покрытие является приемлемым. Это может быть около 80%, или просто 0%, или всегда 100%. Я голосую за закрытие этого вопроса, потому что ответы будут в значительной степени основаны на мнениях.

spickermann 22.04.2023 07:45

@spickermann В целом я с вами согласен, но потом я подумал о замене «тестирования» на «пристегивание ремней безопасности». Использование ремней безопасности - это личный выбор, который является субъективным, но существует объективная дискуссия о том, снижает ли использование ремней безопасности риск смертельного исхода. Точно так же я полагаю, что еще предстоит обсудить обоснованность или тривиальность модульного тестирования относительно новой функции ruby. Мы заканчиваем тем, что просто тестируем язык, или есть смысл в этих типах тестов? Я думаю, что те, кто ответил, внесли свой вклад в содержательную дискуссию.

PressingOnAlways 23.04.2023 04:34

«Должен ли безопасный навигационный оператор подвергаться модульному тестированию?» – нет, это то, для чего в Ruby (языке) должны быть тесты. И вы не тестируете оператор безопасной навигации в своем примере. Вы проверяете, работает ли ваш код, когда some_parent оказывается nil. Я думаю, что тестирование этого аспекта совершенно нормально, потому что оно позволяет вам позже реорганизовать свой код. Или, с точки зрения TDD: неудачный тест будет причиной добавления & в первую очередь.

Stefan 24.04.2023 10:58

@Stefan - обновлено, чтобы отразить основной вопрос. Спасибо.

PressingOnAlways 24.04.2023 18:12

Я думаю, что основной вопрос заключается в следующем: «Должен ли я проверить, работает ли мой метод, как ожидалось, если some_parent равно нулю?». И я практически не вижу причин не тестировать это :-) Ваш тест будет таким же, если ваш метод будет some_parent.calculate_status if some_parent или если у вас будет защитная оговорка или активная поддержка try. Это вообще не относится к оператору безопасной навигации.

Stefan 24.04.2023 19:11
Пошаговое руководство по созданию собственного Slackbot: От установки до развертывания
Пошаговое руководство по созданию собственного Slackbot: От установки до развертывания
Шаг 1: Создание приложения Slack Чтобы создать Slackbot, вам необходимо создать приложение Slack. Войдите в свою учетную запись Slack и перейдите на...
2
5
80
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Это не обязательно вопрос фактического ответа на этот вопрос. Какое покрытие кода вам нужно, зависит от вас. Однако, если мы переформулируем ваш вопрос как меньше охвата кода, если не тестировать состояние nil? тогда ответ положительный, потому что это две разные ветви, и каждая из них должна оцениваться отдельно.

Когда some_parent есть nil:

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

Когда some_parent существует:

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

Простой способ проверить это — протестировать с помощью simplecov. Вот пример приложения, которое может это проверить:

# 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

Затем запускаем тесты:

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

Мы протестировали обе ветки и имеем 100% покрытие. Если мы скажем ему запустить только одну из двух спецификаций, мы получим другой результат:

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]         |
+----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+

Или другая спецификация:

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]         |
+----------+--------+-------+--------+---------+-----------------+----------+-----------------+------------------+

Поэтому, если вам нужен полный охват ветки, напишите обе спецификации.

Как упоминалось в другом месте, стандарты покрытия кода для вашей команды должны определяться вами. Вам нужно понять как аргумент убывающей отдачи, так и аргумент «что бы вы хотели, чтобы я не тестировал», а затем сделать звонок.

Однако есть другой аспект вашего вопроса, заслуживающий внимания: является ли код, использующий оператор безопасной навигации, более или менее сложным, чем код, который его не использует?

Оператор безопасной навигации был добавлен в Ruby, чтобы уменьшить количество ошибок undefined method 'x' for nil:NilClass (NoMethodError), вызванных нарушением Закона Деметры. Эти нарушения распространены в приложениях Rails из-за того, как вы добавляете запросы и предложения вместе в ActiveRecord. В каком-то смысле оператор делает меньше кода. И вообще говоря, чем меньше кода, тем меньше сложность. Но давайте взглянем на этот и два других подхода и сравним их сложность.

Если мы сравним безопасную навигацию со старым способом использования && с третьим способом (с использованием шаблона NullObject):

# safe_navigation.rb

class Parent
  def calculate_status
    "in parent"
  end
end

class Child
  attr_reader :some_parent

  def initialize(parent = nil)
    @parent = parent
  end

  def status_with_safe_navigation
    parent&.calculate_status
  end

  def status_with_conditional
    parent && parent.calculate_status
  end

  def status_with_null_object
    return "" unless parent

    parent.calculate_status
  end
end

Из флога получаем следующее:

~/so > flog -am safe_navigation.rb
    12.3: flog total
     3.1: flog/method average

     4.0: Child#status_with_conditional      safe_navigation.rb:22-23
     3.7: Child#status_with_null_object      safe_navigation.rb:26-29
     2.5: Child#status_with_safe_navigation  safe_navigation.rb:18-19
     2.2: Child#initialize                   safe_navigation.rb:14-15

Так что да, оператор безопасной навигации сам по себе является наименее сложным решением. Однако, если учесть, что клиент класса Child теперь также должен обрабатывать nil, общий сценарий сложности меняется. Добавим класс ClientOfChild и посмотрим на сложность там:

Вот класс:

class ClientOfChild
  def call_status_with_safe_navigation
    result = Client.new.status_with_safe_navigation

    result.nil? ? "" : result
  end

  def call_status_with_conditional
    result = Client.new.status_with_conditional

    result.nil? ? "" : result
  end

  def call_status_with_null_object
    Client.new.status_with_null_object
  end
end

В этом коде код с оператором безопасной навигации и без него должен обрабатывать nil, а код на основе NullObject — нет.

Вот флог:

~/so > flog -am safe_navigation.rb
    22.4: flog total
     3.2: flog/method average

     4.0: Child#status_with_conditional                   safe_navigation.rb:22-23
     3.8: ClientOfChild#call_status_with_safe_navigation  safe_navigation.rb:34-37
     3.8: ClientOfChild#call_status_with_conditional      safe_navigation.rb:40-43
     3.7: Child#status_with_null_object                   safe_navigation.rb:26-29
     2.5: Child#status_with_safe_navigation               safe_navigation.rb:18-19
     2.4: ClientOfChild#call_status_with_null_object      safe_navigation.rb:46-47
     2.2: Child#initialize                                safe_navigation.rb:14-15

Теперь, глядя на общее количество логов для каждого пути, мы получаем:

 * With safe navigation = 2.5 + 3.8 = 6.3
 * With conditional     = 4.0 + 3.8 = 7.8
 * With NullObject      = 3.7 + 2.4 = 6.1

Поскольку это чрезмерное упрощение реального кода, различия в flog невелики. Но абсолютным победителем является шаблон NullObject. Вы обнаружите, что чем сложнее метод, тем дальше друг от друга будут расти эти числа.

По этой причине и чтобы я не нарушал Закон Деметры в своем коде, я выбираю шаблон NullObject вместо использования оператора безопасной навигации (за исключением запросов ActiveReccord, потому что, ну... Rails).

Другие вопросы по теме

Похожие вопросы