рассмотрим следующий код:
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
@spickermann В целом я с вами согласен, но потом я подумал о замене «тестирования» на «пристегивание ремней безопасности». Использование ремней безопасности - это личный выбор, который является субъективным, но существует объективная дискуссия о том, снижает ли использование ремней безопасности риск смертельного исхода. Точно так же я полагаю, что еще предстоит обсудить обоснованность или тривиальность модульного тестирования относительно новой функции ruby. Мы заканчиваем тем, что просто тестируем язык, или есть смысл в этих типах тестов? Я думаю, что те, кто ответил, внесли свой вклад в содержательную дискуссию.
«Должен ли безопасный навигационный оператор подвергаться модульному тестированию?» – нет, это то, для чего в Ruby (языке) должны быть тесты. И вы не тестируете оператор безопасной навигации в своем примере. Вы проверяете, работает ли ваш код, когда some_parent
оказывается nil
. Я думаю, что тестирование этого аспекта совершенно нормально, потому что оно позволяет вам позже реорганизовать свой код. Или, с точки зрения TDD: неудачный тест будет причиной добавления &
в первую очередь.
@Stefan - обновлено, чтобы отразить основной вопрос. Спасибо.
Я думаю, что основной вопрос заключается в следующем: «Должен ли я проверить, работает ли мой метод, как ожидалось, если some_parent
равно нулю?». И я практически не вижу причин не тестировать это :-) Ваш тест будет таким же, если ваш метод будет some_parent.calculate_status if some_parent
или если у вас будет защитная оговорка или активная поддержка try
. Это вообще не относится к оператору безопасной навигации.
Это не обязательно вопрос фактического ответа на этот вопрос. Какое покрытие кода вам нужно, зависит от вас. Однако, если мы переформулируем ваш вопрос как меньше охвата кода, если не тестировать состояние 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).
Тестирование — хорошая привычка, но тестировать ничего не нужно. Это полностью зависит от вас и вашей команды, чтобы решить, что вы хотите протестировать и какое тестовое покрытие является приемлемым. Это может быть около 80%, или просто 0%, или всегда 100%. Я голосую за закрытие этого вопроса, потому что ответы будут в значительной степени основаны на мнениях.