From c9d234a87b9898d28e741101e68d596ffe29e3ab Mon Sep 17 00:00:00 2001 From: Matheus Sales Date: Fri, 26 Apr 2024 15:32:02 -0300 Subject: [PATCH] feat: Add `default` qualifier to `define_enum_for` matcher (#1627) On this commit we're adding a new qualifier to the `define_enum_for` matcher called `with_default`. This qualifier is used to test that the enum is defined with a default value. A proc can also be passed, and will be called once each time a new value is needed. It's nice to note that using Time or Date as the return of Procs as default value can lead to flaky tests, so it's recommended to freeze time or date to avoid this. --- .../active_record/define_enum_for_matcher.rb | 75 ++++++++++- .../define_enum_for_matcher_spec.rb | 126 +++++++++++++++++- 2 files changed, 197 insertions(+), 4 deletions(-) diff --git a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb index 81539614a..e74997900 100644 --- a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb +++ b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb @@ -187,6 +187,31 @@ module ActiveRecord # without_scopes # end # + # ##### with_default + # + # Use `with_default` to test that the enum is defined with a + # default value. A proc can also be passed, and will be called once each + # time a new value is needed. (If using Time or Date, it's recommended to + # freeze time or date to avoid flaky tests): + # + # class Issue < ActiveRecord::Base + # enum status: [:open, :closed], default: :closed + # end + # + # # RSpec + # RSpec.describe Issue, type: :model do + # it do + # should define_enum_for(:status). + # with_default(:closed) + # end + # end + # + # # Minitest (Shoulda) + # class ProcessTest < ActiveSupport::TestCase + # should define_enum_for(:status). + # with_default(:closed) + # end + # # @return [DefineEnumForMatcher] # def define_enum_for(attribute_name) @@ -247,6 +272,11 @@ def without_scopes self end + def with_default(default_value) + options[:default] = default_value + self + end + def matches?(subject) @record = subject @@ -254,7 +284,8 @@ def matches?(subject) enum_values_match? && column_type_matches? && enum_value_methods_exist? && - scope_presence_matches? + scope_presence_matches? && + default_value_matches? end def failure_message @@ -292,6 +323,11 @@ def expectation # rubocop:disable Metrics/MethodLength ) end + if options[:default].present? + expectation << ', with a default value of ' + expectation << Shoulda::Matchers::Util.inspect_value(expected_default_value) + end + if expected_prefix expectation << if expected_suffix @@ -476,6 +512,43 @@ def missing_methods_message end end + def default_value_matches? + return true if options[:default].blank? + + if actual_default_value.nil? + @failure_message_continuation = 'However, no default value was set' + return false + end + + if actual_default_value == expected_default_value + true + else + String.new.tap do |message| + message << 'However, the default value is ' + message << Shoulda::Matchers::Util.inspect_value(actual_default_value) + @failure_message_continuation = message + end + false + end + end + + def expected_default_value + options[:default].respond_to?(:call) ? options[:default].call : options[:default] + end + + def actual_default_value + attribute_schema = model.attributes_to_define_after_schema_loads[attribute_name.to_s] + + value = case attribute_schema + in [_, { default: default_value } ] + default_value + in [_, default_value] + default_value + end + + value.respond_to?(:call) ? value.call : value + end + def singleton_methods_exist? expected_singleton_methods.all? do |method| model.singleton_methods.include?(method) diff --git a/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb index 509d2b0e8..e6d5300ed 100644 --- a/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb @@ -789,6 +789,122 @@ def self.statuses end end + describe 'qualified with #with_default' do + context 'if default are defined on the enum' do + context 'but with_default is not used' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + expect(record).to define_enum_for(:attr).with_values(['published', 'unpublished', 'draft']) + end + end + + context 'with_default is used and default is the same' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default('published') + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, + and ‹"draft"› to ‹2›, with a default value of ‹"published"›, but it did. + MESSAGE + end + end + + context 'with_default is used but default is different' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default('unpublished') + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, with a default value of ‹"unpublished"›. However, the default value + is ‹"published"›. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'when a Proc is used as the default value' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr, default: 'draft') + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default(-> { 'published' }) + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, with a default value of ‹"published"›. However, the default + value is ‹"draft"›. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'matches when the default value is the same' do + record = build_record_with_array_values(attribute_name: :attr, default: 'draft') + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default(-> { 'draft' }) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, + and ‹"draft"› to ‹2›, with a default value of ‹"draft"›, but it did. + MESSAGE + end + end + end + + context 'if default is not defined on the enum' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr) + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default('published') + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, with a default value of ‹"published"›. However, no default + value was set. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + end + if rails_version =~ '~> 6.0' context 'qualified with #without_scopes' do context 'if scopes are set to false on the enum but without_scopes is not used' do @@ -869,7 +985,8 @@ def build_record_with_array_values( prefix: false, suffix: false, attribute_alias: nil, - scopes: true + scopes: true, + default: nil ) build_record_with_enum_attribute( model_name: model_name, @@ -880,6 +997,7 @@ def build_record_with_array_values( suffix: suffix, attribute_alias: attribute_alias, scopes: scopes, + default: default, ) end @@ -911,7 +1029,8 @@ def build_record_with_enum_attribute( attribute_alias:, scopes: true, prefix: false, - suffix: false + suffix: false, + default: nil ) enum_name = attribute_alias || attribute_name model = define_model( @@ -925,10 +1044,11 @@ def build_record_with_enum_attribute( enum_name => values, _prefix: prefix, _suffix: suffix, + _default: default, } if rails_version >= 7.0 - model.enum(enum_name, values, prefix: prefix, suffix: suffix) + model.enum(enum_name, values, prefix: prefix, suffix: suffix, default: default) else params.merge!(_scopes: scopes) model.enum(params)