From bfd850a16ea23400dfb8fe5c7b0ebc0d1d516a65 Mon Sep 17 00:00:00 2001 From: Bob McKinven Date: Sat, 7 Sep 2024 17:36:47 +0100 Subject: [PATCH 1/2] Context helpers for callbacks: - :stategy - :defined_attributes - :user_defined_attributes - :factory_defined_attributes --- docs/src/SUMMARY.md | 1 + docs/src/callbacks/callback-parameters.md | 112 ++++++++++++++++++ lib/factory_bot.rb | 1 + lib/factory_bot/evaluator.rb | 19 +++ lib/factory_bot/inquiry.rb | 26 ++++ .../evaluator/provided_attributes_spec.rb | 63 ++++++++++ .../evaluator/strategy_used_spec.rb | 111 +++++++++++++++++ spec/factory_bot/inquiry_spec.rb | 42 +++++++ spec/support/macros/std_out_helpers.rb | 14 +++ 9 files changed, 389 insertions(+) create mode 100644 docs/src/callbacks/callback-parameters.md create mode 100644 lib/factory_bot/inquiry.rb create mode 100644 spec/factory_bot/evaluator/provided_attributes_spec.rb create mode 100644 spec/factory_bot/evaluator/strategy_used_spec.rb create mode 100644 spec/factory_bot/inquiry_spec.rb create mode 100644 spec/support/macros/std_out_helpers.rb diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bed30d12..57ee195e 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -81,6 +81,7 @@ - [Multiple callbacks](callbacks/multiple-callbacks.md) - [Global callbacks](callbacks/global-callbacks.md) - [Symbol#to_proc](callbacks/symbol-to_proc.md) + - [Callback parameters](callbacks/callback-parameters.md) - [Modifying factories](modifying-factories/summary.md) - [Linting Factories](linting-factories/summary.md) - [Custom Construction](custom-construction/summary.md) diff --git a/docs/src/callbacks/callback-parameters.md b/docs/src/callbacks/callback-parameters.md new file mode 100644 index 00000000..50f40b86 --- /dev/null +++ b/docs/src/callbacks/callback-parameters.md @@ -0,0 +1,112 @@ +# Callback parameters + +Callbacks can receive zero, one or two parameters. + +## With zero parameters + +Callbacks with zero parameters simply execute the provided block of code: +```ruby +factory :user do + after(:stub) { do_something() } +end +``` + +## With one parameter + +Callbacks with a single parameter receive the factory instance being constructed: +```ruby +factory :user do + after(:build) { |user| do_something_to(user) } +end +``` + +## With two parameters + +Callbacks with two parameters receive both the factory instance +and the context in which the instance is constructed: +```ruby +factory :user do + transient { article {
} } + + after(:create) { |user, context| user.post_first_article(context.article) } +end +``` + +## Callback context + +The `context` parameter provides access to the environment in which the +instance is constructed. + +### Transient settings + +Transient settings are accessed directly from the `context`: +```ruby +factory :car do + transient { doors { 4 } } + + after(:create) do |car, context| + car.update(style: :sedan) if context.doors == 4 + car.update(style: :coupe) if context.doors == 2 + end +end + +car = FactoryBot.create(:car, doors: 2) +car.style #=> :coupe +``` + +### Strategy used + +Sometimes you have a factory that you both `build` and `create` in different tests, +but always want the same code to run at the end. + +It's not as simple as adding both `after(:build)` and `after(:create)` callbacks because `after(:create)` also triggers the `after(:build)` callback, so the code would be run twice. + +Checking for the strategy used can help skip the `after(:build)` code when the strategy used is `create`. +```ruby +factory :user do + after(:build) { |user, context| run_this_code() if context.strategy.build?} + after(:create) { |user, context| run_this_code() } +end +``` + +### Defined attributes +Sometimes you need to know if an attribute was provided by the user or defined by the factory. `context` provides a list of the attributes which have been defined: in total; by the user; or by the factory. The list may also be queried for a simgle entry: +```ruby +# based on FactoryBot.build(:car, doors: 2) +factory :car do + transient { transmission { :manual } } + + doors { 4 } + seats { 5 } + wheels { 6 } + + after(:build) do |car, context| + context.defined_attributes #=> [:doors, :seats, :wheels] + context.user_defined_attributes #=> [:doors] + context.factory_defined_attributes #=> [:seats, :wheels] + + context.defined_attributes.wheels? #=> true + context.user_defined_attributes.wheels? #=> false + context.factory_defined_attributes.wheels? #=> true + end +end +``` + +**Note**: Transient attributes are not included in the list unless they have been provided +by the user. +```ruby +# based on FactoryBot.build(:car, doors: 2, transmission: :automatic) +factory :car do + transient { transmission { :manual } } + + doors { 4 } + seats { 5 } + wheels { 6 } + + after(:build) do |car, context| + context.defined_attributes #=> [:doors, :seats, :transmission :wheels] + context.user_defined_attributes #=> [:doors, :transmission] + context.factory_defined_attributes #=> [:seats, :wheels] + end +end +``` \ No newline at end of file diff --git a/lib/factory_bot.rb b/lib/factory_bot.rb index b5c75145..bde70bed 100644 --- a/lib/factory_bot.rb +++ b/lib/factory_bot.rb @@ -46,6 +46,7 @@ require "factory_bot/decorator/disallows_duplicates_registry" require "factory_bot/decorator/invocation_tracker" require "factory_bot/decorator/new_constructor" +require "factory_bot/inquiry" require "factory_bot/linter" require "factory_bot/version" diff --git a/lib/factory_bot/evaluator.rb b/lib/factory_bot/evaluator.rb index 7a4b82e7..a8c84b31 100644 --- a/lib/factory_bot/evaluator.rb +++ b/lib/factory_bot/evaluator.rb @@ -13,6 +13,7 @@ class Evaluator def initialize(build_strategy, overrides = {}) @build_strategy = build_strategy @overrides = overrides + @user_overrides = overrides.keys @cached_attributes = overrides @instance = nil @@ -35,6 +36,24 @@ def association(factory_name, *traits_and_overrides) attr_accessor :instance + def strategy + @build_strategy.to_sym.to_s.extend(FactoryBot::Inquiry) + rescue NoMethodError # for custom strategies without :to_sym + "unknown".extend(FactoryBot::Inquiry) + end + + def defined_attributes + __override_names__.sort.extend(FactoryBot::Inquiry) + end + + def user_defined_attributes + @user_overrides.sort.extend(FactoryBot::Inquiry) + end + + def factory_defined_attributes + (__override_names__ - @user_overrides).sort.extend(FactoryBot::Inquiry) + end + def method_missing(method_name, ...) if @instance.respond_to?(method_name) @instance.send(method_name, ...) diff --git a/lib/factory_bot/inquiry.rb b/lib/factory_bot/inquiry.rb new file mode 100644 index 00000000..022d7a7a --- /dev/null +++ b/lib/factory_bot/inquiry.rb @@ -0,0 +1,26 @@ +module FactoryBot + module Inquiry + def respond_to_missing?(name, include_private = false) + name.end_with?("?") || super + end + + def method_missing(name, ...) + if name.end_with?("?") + fb_inquire(name[0..-2]) + else + super + end + end + + def fb_inquire(test_value) + case self + when String + self == test_value + when Array + include?(test_value) || include?(test_value.to_sym) + else + false + end + end + end +end diff --git a/spec/factory_bot/evaluator/provided_attributes_spec.rb b/spec/factory_bot/evaluator/provided_attributes_spec.rb new file mode 100644 index 00000000..2cceb39e --- /dev/null +++ b/spec/factory_bot/evaluator/provided_attributes_spec.rb @@ -0,0 +1,63 @@ +describe FactoryBot::Evaluator do + context :methods do + before(:all) { + unless defined?(ContextAttributeTest) + class ContextAttributeTest + attr_accessor :name, :age, :admin + end + end + } + + after(:all) { + if defined?(ContextAttributeTest) + Object.send(:remove_const, :ContextAttributeTest) + end + } + + before(:each) { + FactoryBot.define do + factory :context_attribute_test do + transient do + trans_attr { false } + end + + name { "John Doh" } + age { 23 } + admin { false } + + after(:build) do |object, context| + puts "defined_attributes: #{context.defined_attributes}" + puts "user_defined_attributes: #{context.user_defined_attributes}" + puts "factory_defined_attributes: #{context.factory_defined_attributes}" + end + end + end + } + + context ":defined_attributes" do + it "lists all provided attributes" do + output = capture_stdout do + FactoryBot.build :context_attribute_test, admin: true, trans_attr: true + end + + expect(output).to include "defined_attributes: [:admin, :age, :name, :trans_attr]" + end + + it "lists the user provided attributes" do + output = capture_stdout do + FactoryBot.build :context_attribute_test, admin: true, trans_attr: true + end + + expect(output).to include "user_defined_attributes: [:admin, :trans_attr]" + end + + it "lists the factory provided attributes" do + output = capture_stdout do + FactoryBot.build :context_attribute_test, admin: true, trans_attr: true + end + + expect(output).to include "factory_defined_attributes: [:age, :name]" + end + end + end +end diff --git a/spec/factory_bot/evaluator/strategy_used_spec.rb b/spec/factory_bot/evaluator/strategy_used_spec.rb new file mode 100644 index 00000000..30377263 --- /dev/null +++ b/spec/factory_bot/evaluator/strategy_used_spec.rb @@ -0,0 +1,111 @@ +describe FactoryBot::Evaluator do + context :methods do + context ":stragtegy" do + context "on success" do + context "with FactoryBot::Strategy::Null" do + let(:evaluator) { define_evaluator(FactoryBot::Strategy::Null) } + + it "returns the string 'null'" do + expect(evaluator.strategy).to eq "null" + end + + it "returns true with the correct inquiry :null?" do + expect(evaluator.strategy).to be_null + end + + it "returns false with the incorrect inquiry :build?" do + expect(evaluator.strategy).not_to be_build + end + end + + context "with FactoryBot::Strategy::Build" do + let(:evaluator) { define_evaluator(FactoryBot::Strategy::Build) } + + it "returns the string 'build'" do + expect(evaluator.strategy).to eq "build" + end + + it "returns true with the correct inquiry :build?" do + expect(evaluator.strategy).to be_build + end + + it "returns false with an incorrect inquiry :create" do + expect(evaluator.strategy).not_to be_create + end + end + + context "with FactoryBot::Strategy::Stub" do + let(:evaluator) { define_evaluator(FactoryBot::Strategy::Stub) } + + it "returns the string 'stub'" do + expect(evaluator.strategy).to eq "stub" + end + + it "returns true with the correct inquiry :stub?" do + expect(evaluator.strategy).to be_stub + end + + it "returns false with the incorrect inquiry :build?" do + expect(evaluator.strategy).not_to be_build + end + end + + context "with FactoryBot::Strategy::Create" do + let(:evaluator) { define_evaluator(FactoryBot::Strategy::Create) } + + it "returns the string 'create'" do + expect(evaluator.strategy).to eq "create" + end + + it "returns true with the correct inquiry :create?" do + expect(evaluator.strategy).to be_create + end + + it "returns false with the incorrect inquiry :build?" do + expect(evaluator.strategy).not_to be_build + end + end + + context "with FactoryBot::Strategy::AttributesFor" do + let(:evaluator) { define_evaluator(FactoryBot::Strategy::AttributesFor) } + + it "returns the string 'attributes_for'" do + expect(evaluator.strategy).to eq "attributes_for" + end + + it "returns true with the correct inquiry :attributes_for?" do + expect(evaluator.strategy).to be_attributes_for + end + + it "returns false with the incorrect inquiry :build?" do + expect(evaluator.strategy).not_to be_build + end + end + end # on success + + context "on failure" do + let(:evaluator) do + strategy = FactoryBot::Strategy::Null.new + allow(strategy).to receive(:to_sym).and_raise(NoMethodError) + FactoryBot::Evaluator.new(strategy) + end + + it "returns 'unknown' when strategy does not implement :to_sym" do + expect(evaluator.strategy).to eq "unknown" + end + + it "returns true with the correct inquiry :unknown?" do + expect(evaluator.strategy).to be_unknown + end + + it "returns false with the incorrect inquiry :build?" do + expect(evaluator.strategy).not_to be_build + end + end # on failure + end + end + + def define_evaluator(build_strategy = FactoryBot::Strategy::Null) + FactoryBot::Evaluator.new(build_strategy.new) + end +end diff --git a/spec/factory_bot/inquiry_spec.rb b/spec/factory_bot/inquiry_spec.rb new file mode 100644 index 00000000..fc683142 --- /dev/null +++ b/spec/factory_bot/inquiry_spec.rb @@ -0,0 +1,42 @@ +describe FactoryBot::Inquiry do + context "extends a String with inquiry methods" do + let(:str) { "test".extend(FactoryBot::Inquiry) } + + it "returns true with a valid inquiry" do + expect(str).to be_test + end + + it "returns false with an invalid inquiry" do + expect(str).not_to be_good + end + + it "ignores an inquiry that dosen't end with '?'" do + expect { str.bad }.to raise_error NoMethodError + end + end + + context "extends an Array with inquiry methods" do + let(:ary) { ["test", :fact, "Bot"].extend(FactoryBot::Inquiry) } + + it "return true if the array includes a string version of the inquiry" do + expect(ary).to be_test + end + + it "return true if the array includes a symbol version of the inquiry" do + expect(ary).to be_fact + end + + it "returns false if the array does not include the inquiry" do + expect(ary).not_to be_good + end + + it "ignores an inquiry that dosen't end with '?'" do + expect { ary.bad }.to raise_error NoMethodError + end + end + + it "always returns false with a non-String or non-Array object" do + obj = Time.now.extend(FactoryBot::Inquiry) + expect(obj).not_to be_time? + end +end diff --git a/spec/support/macros/std_out_helpers.rb b/spec/support/macros/std_out_helpers.rb new file mode 100644 index 00000000..e4bccc29 --- /dev/null +++ b/spec/support/macros/std_out_helpers.rb @@ -0,0 +1,14 @@ +module StdOutHelpers + def capture_stdout + original_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original_stdout + end +end + +RSpec.configure do |config| + config.include StdOutHelpers +end From e78e0847d2551f099a09bfb5cee20a46231a8781 Mon Sep 17 00:00:00 2001 From: CodeMeister Date: Tue, 5 Nov 2024 12:45:46 +0000 Subject: [PATCH 2/2] ProvidedAttributesSpec refactored to simplify results. Test no longer require capturing :std_out but now record the results within a Hash in the object. --- .../evaluator/provided_attributes_spec.rb | 33 ++++++++----------- spec/support/macros/std_out_helpers.rb | 14 -------- 2 files changed, 14 insertions(+), 33 deletions(-) delete mode 100644 spec/support/macros/std_out_helpers.rb diff --git a/spec/factory_bot/evaluator/provided_attributes_spec.rb b/spec/factory_bot/evaluator/provided_attributes_spec.rb index 2cceb39e..5ab67d99 100644 --- a/spec/factory_bot/evaluator/provided_attributes_spec.rb +++ b/spec/factory_bot/evaluator/provided_attributes_spec.rb @@ -3,7 +3,11 @@ before(:all) { unless defined?(ContextAttributeTest) class ContextAttributeTest - attr_accessor :name, :age, :admin + attr_accessor :name, :age, :admin, :results + + def initialize + self.results = {} + end end end } @@ -26,9 +30,9 @@ class ContextAttributeTest admin { false } after(:build) do |object, context| - puts "defined_attributes: #{context.defined_attributes}" - puts "user_defined_attributes: #{context.user_defined_attributes}" - puts "factory_defined_attributes: #{context.factory_defined_attributes}" + object.results[:defined_attributes] = context.defined_attributes + object.results[:user_defined_attributes] = context.user_defined_attributes + object.results[:factory_defined_attributes] = context.factory_defined_attributes end end end @@ -36,27 +40,18 @@ class ContextAttributeTest context ":defined_attributes" do it "lists all provided attributes" do - output = capture_stdout do - FactoryBot.build :context_attribute_test, admin: true, trans_attr: true - end - - expect(output).to include "defined_attributes: [:admin, :age, :name, :trans_attr]" + obj = FactoryBot.build :context_attribute_test, admin: true, trans_attr: true + expect(obj.results[:defined_attributes]).to eq [:admin, :age, :name, :trans_attr] end it "lists the user provided attributes" do - output = capture_stdout do - FactoryBot.build :context_attribute_test, admin: true, trans_attr: true - end - - expect(output).to include "user_defined_attributes: [:admin, :trans_attr]" + obj = FactoryBot.build :context_attribute_test, admin: true, trans_attr: true + expect(obj.results[:user_defined_attributes]).to eq [:admin, :trans_attr] end it "lists the factory provided attributes" do - output = capture_stdout do - FactoryBot.build :context_attribute_test, admin: true, trans_attr: true - end - - expect(output).to include "factory_defined_attributes: [:age, :name]" + obj = FactoryBot.build :context_attribute_test, admin: true, trans_attr: true + expect(obj.results[:factory_defined_attributes]).to eq [:age, :name] end end end diff --git a/spec/support/macros/std_out_helpers.rb b/spec/support/macros/std_out_helpers.rb deleted file mode 100644 index e4bccc29..00000000 --- a/spec/support/macros/std_out_helpers.rb +++ /dev/null @@ -1,14 +0,0 @@ -module StdOutHelpers - def capture_stdout - original_stdout = $stdout - $stdout = StringIO.new - yield - $stdout.string - ensure - $stdout = original_stdout - end -end - -RSpec.configure do |config| - config.include StdOutHelpers -end