activeexperiment 0.1.0.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +373 -0
  5. data/lib/active_experiment/base.rb +76 -0
  6. data/lib/active_experiment/cache/active_record_cache_store.rb +116 -0
  7. data/lib/active_experiment/cache/redis_hash_cache_store.rb +68 -0
  8. data/lib/active_experiment/cache.rb +28 -0
  9. data/lib/active_experiment/caching.rb +172 -0
  10. data/lib/active_experiment/callbacks.rb +111 -0
  11. data/lib/active_experiment/capturable.rb +117 -0
  12. data/lib/active_experiment/configured_experiment.rb +74 -0
  13. data/lib/active_experiment/core.rb +177 -0
  14. data/lib/active_experiment/executed.rb +86 -0
  15. data/lib/active_experiment/execution.rb +156 -0
  16. data/lib/active_experiment/gem_version.rb +18 -0
  17. data/lib/active_experiment/instrumentation.rb +45 -0
  18. data/lib/active_experiment/log_subscriber.rb +178 -0
  19. data/lib/active_experiment/logging.rb +62 -0
  20. data/lib/active_experiment/railtie.rb +69 -0
  21. data/lib/active_experiment/rollout.rb +106 -0
  22. data/lib/active_experiment/rollouts/inactive_rollout.rb +24 -0
  23. data/lib/active_experiment/rollouts/percent_rollout.rb +84 -0
  24. data/lib/active_experiment/rollouts/random_rollout.rb +46 -0
  25. data/lib/active_experiment/rollouts.rb +127 -0
  26. data/lib/active_experiment/rspec.rb +12 -0
  27. data/lib/active_experiment/run_key.rb +55 -0
  28. data/lib/active_experiment/segments.rb +69 -0
  29. data/lib/active_experiment/test_case.rb +11 -0
  30. data/lib/active_experiment/test_helper.rb +267 -0
  31. data/lib/active_experiment/variants.rb +145 -0
  32. data/lib/active_experiment/version.rb +11 -0
  33. data/lib/active_experiment.rb +27 -0
  34. data/lib/activeexperiment.rb +8 -0
  35. data/lib/rails/generators/experiment/USAGE +12 -0
  36. data/lib/rails/generators/experiment/experiment_generator.rb +53 -0
  37. data/lib/rails/generators/experiment/templates/application_experiment.rb.tt +4 -0
  38. data/lib/rails/generators/experiment/templates/experiment.rb.tt +35 -0
  39. data/lib/rails/generators/rspec/experiment/experiment_generator.rb +20 -0
  40. data/lib/rails/generators/rspec/experiment/templates/experiment_spec.rb.tt +7 -0
  41. data/lib/rails/generators/test_unit/experiment/experiment_generator.rb +21 -0
  42. data/lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt +9 -0
  43. metadata +118 -0
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/subclasses"
4
+ require "active_support/testing/assertions"
5
+
6
+ module ActiveExperiment
7
+ # Provides helper methods for testing Active Experiments.
8
+ module TestHelper
9
+ include ActiveSupport::Testing::Assertions
10
+
11
+ def setup
12
+ super
13
+ clear_executed_experiments
14
+ end
15
+
16
+ def teardown
17
+ super
18
+ clear_executed_experiments
19
+ end
20
+
21
+ # Returns all of the experiments that have been executed.
22
+ def executed_experiments
23
+ ActiveExperiment::Executed.as_array
24
+ end
25
+
26
+ # Clears the list of executed experiments.
27
+ def clear_executed_experiments
28
+ ActiveExperiment::Executed.clear_all
29
+ end
30
+
31
+ # Provides the ability to stub an experiment's variant assignment, by
32
+ # changing the experiment to use the MockRollout within the scope of the
33
+ # provided block.
34
+ #
35
+ # class SubjectExperiment < ActiveExperiment::Base
36
+ # variant(:red) { "red" }
37
+ # variant(:blue) { "blue" }
38
+ # variant(:green) { "green" }
39
+ # end
40
+ #
41
+ # Given the above is our experiment, the variant assignment can be stubbed
42
+ # to always assign the green variant.
43
+ #
44
+ # stub_experiment(SubjectExperiment, :green) do
45
+ # assert_equal "green", SubjectExperiment.run
46
+ # end
47
+ #
48
+ # Or an array can be used to have green be assigned for the first run, and
49
+ # blue for the second, green for the third, and so on.
50
+ #
51
+ # stub_experiment(SubjectExperiment, :green, :blue) do
52
+ # assert_equal "green", SubjectExperiment.run
53
+ # assert_equal "blue", SubjectExperiment.run
54
+ # assert_equal "green", SubjectExperiment.run
55
+ # end
56
+ #
57
+ # A lambda or proc can be used to provide custom variant assignment using
58
+ # whatever logic required.
59
+ #
60
+ # resolver = lambda do |experiment|
61
+ # experiment.context[:id] == 42 ? :green : :blue
62
+ # end
63
+ #
64
+ # stub_experiment(SubjectExperiment, resolver) do
65
+ # assert_equal "blue", SubjectExperiment.run(id: 1)
66
+ # assert_equal "blue", SubjectExperiment.run(id: 2)
67
+ # assert_equal "green", SubjectExperiment.run(id: 42)
68
+ # end
69
+ #
70
+ # Options can also be passed, for instance to simulate when an experiment
71
+ # should be skipped.
72
+ #
73
+ # stub_experiment(SubjectExperiment, skip: true) do |mock_rollout|
74
+ # assert_equal false, mock_rollout.skipped_for('_anything_')
75
+ # assert_equal :red, mock_rollout.variant_for('_anything_')
76
+ # assert_nil SubjectExperiment.run
77
+ # end
78
+ #
79
+ # By default the +MockRollout+ class will be used, which implements all of
80
+ # the functionality described above -- however, any rollout class can be
81
+ # used for the span of the provided block.
82
+ #
83
+ # stub_experiment(SubjectExperiment, rollout_class: MyCustomRollout) do
84
+ # # ...
85
+ # end
86
+ def stub_experiment(experiment_class, *variants, **options)
87
+ original_rollout = experiment_class.rollout
88
+
89
+ rollout_class = options.delete(:rollout_class) || MockRollout
90
+ rollout_options = { variant: variants }.merge(options)
91
+ experiment_class.rollout = rollout_class.new(experiment_class, **rollout_options)
92
+
93
+ _assert_nothing_raised_or_warn("stub_experiment") do
94
+ yield experiment_class.rollout
95
+ end
96
+ ensure
97
+ experiment_class.rollout = original_rollout
98
+ end
99
+
100
+ # Asserts that the number of experiments run matches the given number.
101
+ #
102
+ # def test_experiments
103
+ # assert_experiments 0
104
+ # MyExperiment.run
105
+ # assert_experiments 1
106
+ # MyExperiment.run
107
+ # assert_experiments 2
108
+ # end
109
+ #
110
+ # If a block is provided, that block should cause the specified number of
111
+ # experiments to be run.
112
+ #
113
+ # def test_experiments_again
114
+ # assert_experiments 1 do
115
+ # MyExperiment.run
116
+ # end
117
+ #
118
+ # assert_experiments 2 do
119
+ # MyExperiment.run
120
+ # MyExperiment.run
121
+ # end
122
+ # end
123
+ def assert_experiments(number, &block)
124
+ executed_count = executed_experiments.size
125
+ if block_given?
126
+ _assert_nothing_raised_or_warn("assert_experiments", &block)
127
+ executed_count = executed_experiments.size - executed_count
128
+ assert_equal number, executed_count, "#{number} experiment runs expected, but found #{executed_count}"
129
+ else
130
+ assert_equal number, executed_count
131
+ end
132
+ end
133
+
134
+ # Asserts that no experiments have been run.
135
+ #
136
+ # def test_experiments
137
+ # assert_no_experiments
138
+ # MyExperiment.run
139
+ # assert_experiments 1
140
+ # end
141
+ #
142
+ # If a block is passed, that block should not cause any emails to be sent.
143
+ #
144
+ # def test_experiments_again
145
+ # assert_no_experiments do
146
+ # # No experiments should be run from this block
147
+ # end
148
+ # end
149
+ #
150
+ # Note: This assertion is simply a shortcut for:
151
+ #
152
+ # assert_experiments(0, &block)
153
+ def assert_no_experiments(&block)
154
+ assert_experiments(0, &block)
155
+ end
156
+
157
+ # Asserts that a specific experiment has been run, optionally matching args
158
+ # and/or context.
159
+ #
160
+ # def test_experiment
161
+ # MyExperiment.run(id: 1)
162
+ # assert_experiment_with MyExperiment, context: { id: 1 }
163
+ # end
164
+ #
165
+ # def test_experiment_with_options
166
+ # MyExperiment.set(foo: :bar).run(id: 1)
167
+ # assert_experiment_with MyExperiment, options: { foo: "bar" }
168
+ # end
169
+ #
170
+ # def test_experiment_with_variant
171
+ # MyExperiment.set(variant: :red).run(id: 1)
172
+ # assert_experiment_with MyExperiment, variant: :red
173
+ # end
174
+ #
175
+ # If a block is passed, that block should cause the specified experiment to
176
+ # be run.
177
+ #
178
+ # def test_experiment_in_block
179
+ # assert_experiment_with MyExperiment, context: { id: 1 } do
180
+ # MyExperiment.run(id: 1)
181
+ # end
182
+ # end
183
+ def assert_experiment_with(experiment_class, context: nil, options: nil, variant: nil, &block)
184
+ expected = { context: context, options: options, variant: variant }.compact
185
+ experiments = executed_experiments
186
+ if block_given?
187
+ original_executed_experiments = experiments.dup
188
+ _assert_nothing_raised_or_warn("assert_experiment_with", &block)
189
+ experiments = executed_experiments - original_executed_experiments
190
+ end
191
+
192
+ match_potential = []
193
+ match_class = []
194
+ match_experiment = experiments.find do |experiment|
195
+ match_potential << experiment
196
+ if experiment.class == experiment_class
197
+ match_class << experiment
198
+ expected.all? do |key, value|
199
+ experiment.public_send(key) == value
200
+ end
201
+ end
202
+ end
203
+
204
+ message = +"No matching run found for #{experiment_class.name}"
205
+ message << " with #{expected.inspect}" if expected.any?
206
+ if match_potential.empty?
207
+ message << "\n\nNo experiment were run"
208
+ elsif match_class.empty?
209
+ message << "\n\nNo #{experiment_class.name} experiments were run, experiments run:"
210
+ message << "\n #{match_potential.map(&:class).join(", ")}"
211
+ else
212
+ message << "\n\nPotential matches:"
213
+ message << "\n #{match_class.join("\n ")}"
214
+ end
215
+
216
+ assert(match_experiment, message)
217
+ match_experiment
218
+ end
219
+
220
+ # Mock rollout class that can be used to stub out the variant assignment of
221
+ # an experiment.
222
+ #
223
+ # This is used by the ActiveExperiment::TestHelper in the +stub_experiment+
224
+ # method. It can be used directly, or through the helper when needing to
225
+ # stub out the rollout of an experiment in a test. It can also be inherited
226
+ # and customized.
227
+ class MockRollout < Rollouts::BaseRollout
228
+ def initialize(...)
229
+ @assigned = 0
230
+ super
231
+ end
232
+
233
+ def skipped_for(ex)
234
+ raise ArgumentError, "expecting a #{@experiment_class.name}" unless ex.is_a?(@experiment_class)
235
+
236
+ skip = opts[:skip]
237
+
238
+ # Accepts a callable in the :skip option.
239
+ return skip.call(ex) if skip.respond_to?(:call)
240
+
241
+ # Accepts a boolean in the :skip options, with a default of false.
242
+ !!skip
243
+ end
244
+
245
+ def variant_for(ex)
246
+ raise ArgumentError, "expecting a #{@experiment_class.name}" unless ex.is_a?(@experiment_class)
247
+
248
+ # Accepts an array in the :variant option.
249
+ variant = opts[:variant]
250
+ variant = variant[((@assigned += 1) - 1) % variant.size] if variant.is_a?(Array) && !variant.empty?
251
+
252
+ # Accepts a callable in the :variant option.
253
+ return variant.call(ex) if variant.respond_to?(:call)
254
+
255
+ # Accepts a symbol in the :variant option.
256
+ return variant if variant.is_a?(Symbol)
257
+
258
+ # Fall back to the default variant.
259
+ ex.try(:default_variant)
260
+ end
261
+
262
+ def opts
263
+ @rollout_options
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveExperiment
4
+ # == Variant Registration
5
+ #
6
+ # Variants are registered using the +control+ and +variant+ methods within an
7
+ # experiment. The +control+ method is a convenience for registering a variant
8
+ # with the name +:control+ -- a convention used to describe the default
9
+ # variant.
10
+ #
11
+ # Registering a variant can be done by providing a name, and a block or a
12
+ # symbol referencing a method.
13
+ #
14
+ # class MyExperiment < ActiveExperiment::Base
15
+ # control { "control" } # defines a variant named :control
16
+ # variant :treatment, :treatment_method
17
+ #
18
+ # private
19
+ #
20
+ # def treatment_method
21
+ # "treatment"
22
+ # end
23
+ # end
24
+ #
25
+ # Subclassing experiments will inherit variants. Existing variants can be
26
+ # overridden or added to, and new variants can registered.
27
+ #
28
+ # class NewExperiment < MyExperiment
29
+ # control(override: true) { "new control" }
30
+ # variant(:treatment, add: true, prepend: true) { "new treatment" }
31
+ # variant(:new_variant) { "new variant" }
32
+ # end
33
+ #
34
+ # In the above example, the control is overridden, a new variant is added,
35
+ # and a new step is added to the treatment variant.
36
+ #
37
+ # When running an experiment, variants can be overridden by using the +on+
38
+ # method. This allows utilizing the scope of where the experiment is run to
39
+ # change the experiment behavior.
40
+ #
41
+ # NewExperiment.run do |experiment|
42
+ # experiment.on(:treatment) { "overridden treatment" }
43
+ # end
44
+ #
45
+ # By default, the "control" variant is assigned as the default variant, but
46
+ # any variant can be specified as the default. The concept of the control
47
+ # variant is only a convention.
48
+ #
49
+ # To specify a different default variant:
50
+ #
51
+ # class MyExperiment < ActiveExperiment::Base
52
+ # variant(:red) { "red" }
53
+ # variant(:blue) { "blue" }
54
+ #
55
+ # use_default_variant :blue
56
+ # end
57
+ #
58
+ # The default variant is assigned if the experiment is skipped or if no other
59
+ # variant has been resolved after asking the rollout -- when the rollout may
60
+ # not be working properly.
61
+ module Variants
62
+ extend ActiveSupport::Concern
63
+ include ActiveSupport::Callbacks
64
+
65
+ VARIANT_CHAIN_SUFFIX = "_variant"
66
+ private_constant :VARIANT_CHAIN_SUFFIX
67
+
68
+ STEP_CHAIN_SUFFIX = "_steps"
69
+ private_constant :STEP_CHAIN_SUFFIX
70
+
71
+ included do
72
+ class_attribute :variants, instance_writer: false, instance_predicate: false, default: {}
73
+ class_attribute :default_variant, instance_writer: false, instance_predicate: false, default: :control
74
+ end
75
+
76
+ # These methods will be included into any Active Experiment object, adding
77
+ # the the ability to set the default variant, defining variants and their
78
+ # behaviors and callback helpers.
79
+ module ClassMethods
80
+ private
81
+ def use_default_variant(variant)
82
+ variant = variant.to_sym
83
+ raise ArgumentError, "Unknown #{variant.inspect} variant" unless variants[variant]
84
+
85
+ self.default_variant = variant
86
+ end
87
+
88
+ def register_variant_callback(variant, *filters, override: false, add: false, **options, &block)
89
+ raise ArgumentError, "Provide either `override: true` or `add: true` but not both" if override && add
90
+
91
+ variant = variant.to_sym
92
+ if variants[variant].present?
93
+ unless override || add
94
+ raise ArgumentError, "The #{variant.inspect} variant is already registered. " \
95
+ "Provide `override: true` or `add: true` to make changes to it."
96
+ end
97
+ elsif override || add
98
+ raise ArgumentError, "Unable to override or add to unknown #{variant.inspect} variant"
99
+ end
100
+
101
+ self.variants = variants.dup unless singleton_class.method_defined?(:variants, false)
102
+
103
+ variants[variant] = callback_chain = :"#{variant}#{VARIANT_CHAIN_SUFFIX}"
104
+
105
+ unless add
106
+ define_variant_callbacks(callback_chain) # variant callback chain
107
+ define_variant_callbacks("#{callback_chain}#{STEP_CHAIN_SUFFIX}") # variant step chain
108
+ end
109
+
110
+ filters.push(block) if block.present?
111
+ filters.unshift(callback_chain) if filters.empty?
112
+ set_callback_with_target("#{callback_chain}#{STEP_CHAIN_SUFFIX}", *filters, **options) do |target, callback|
113
+ target.instance_variable_set(:@results, callback.call(target, nil))
114
+ end
115
+ end
116
+
117
+ def define_variant_callbacks(callback_chain)
118
+ define_callbacks(callback_chain)
119
+ private :"_#{callback_chain}_callbacks", :"_run_#{callback_chain}_callbacks"
120
+ end
121
+
122
+ def set_variant_callback(variant, type, *filters, &block)
123
+ raise ArgumentError, "Unknown `#{variant}` variant" unless variants[variant.to_sym]
124
+
125
+ set_callback("#{variant}#{VARIANT_CHAIN_SUFFIX}", type, *filters, &block)
126
+ end
127
+ end
128
+
129
+ # The names for all registered variants.
130
+ #
131
+ # This is most commonly used by rollouts, where knowing the variant names
132
+ # is important for determining which variant to assign.
133
+ def variant_names
134
+ variants.keys
135
+ end
136
+
137
+ private
138
+ def variant_step_chains
139
+ @variant_step_chains ||= variants.transform_values do |callback_chain|
140
+ chain_name = "#{callback_chain}#{STEP_CHAIN_SUFFIX}"
141
+ -> { run_callbacks(chain_name, :process_variant_steps) { @results } }
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_version"
4
+
5
+ module ActiveExperiment
6
+ # Returns the currently loaded version of Active Experiment as a
7
+ # +Gem::Version+.
8
+ def self.version
9
+ gem_version
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "global_id"
4
+ require "active_support"
5
+ require "active_support/rails"
6
+ require "active_support/tagged_logging"
7
+
8
+ require "active_experiment/version"
9
+
10
+ module ActiveExperiment
11
+ Error = Class.new(StandardError)
12
+ ExecutionError = Class.new(Error)
13
+
14
+ extend ActiveSupport::Autoload
15
+
16
+ autoload :Base
17
+ autoload :Cache
18
+ autoload :ConfiguredExperiment
19
+ autoload :Executed
20
+ autoload :Rollouts
21
+ autoload :Capturable
22
+
23
+ autoload :TestCase
24
+ autoload :TestHelper
25
+
26
+ mattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
27
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is here to make the gem behave like a Rails core library...
4
+ require "active_experiment"
5
+
6
+ # But we'll also require the railtie if it looks like it's loading in a Rails
7
+ # app, to avoid having to require it manually in application.rb.
8
+ require "active_experiment/railtie" if defined?(Rails)
@@ -0,0 +1,12 @@
1
+ Description:
2
+ Generates a new experiment. Pass the experiment name, either CamelCased or
3
+ under_scored, with or without the experiment postfix. Optionally, provide a
4
+ list of variant names.
5
+
6
+ Examples:
7
+ `bin/rails generate experiment RedBlueExperiment red blue`
8
+
9
+ Creates the the following files:
10
+
11
+ Experiment: app/experiments/red_blue_experiment.rb
12
+ Test: test/experiments/red_blue_experiment_test.rb
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Rails # :nodoc:
6
+ module Generators # :nodoc:
7
+ class ExperimentGenerator < Rails::Generators::NamedBase # :nodoc:
8
+ class_option :parent, type: :string, default: "ApplicationExperiment", desc: "The parent class for the generated experiment"
9
+ class_option :skip_comments, type: :boolean, default: false, desc: "Omit helpful comments from generated files"
10
+
11
+ argument :variants, type: :array, default: %w[control treatment], banner: "variant variant"
12
+
13
+ check_class_collision suffix: "Experiment"
14
+
15
+ hook_for :test_framework
16
+
17
+ def self.default_generator_root
18
+ __dir__
19
+ end
20
+
21
+ def create_experiment_file
22
+ template "experiment.rb", File.join("app/experiments", class_path, "#{file_name}_experiment.rb")
23
+
24
+ in_root do
25
+ if behavior == :invoke && !File.exist?(application_experiment_file_name)
26
+ template "application_experiment.rb", application_experiment_file_name
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+ def parent_class_name
33
+ options[:parent]
34
+ end
35
+
36
+ def variant_names
37
+ @variant_names ||= variants.map { |variant| variant.to_s.underscore }
38
+ end
39
+
40
+ def file_name
41
+ @_file_name ||= super.sub(/_experiment\z/i, "")
42
+ end
43
+
44
+ def application_experiment_file_name
45
+ @application_experiment_file_name ||= if mountable_engine?
46
+ "app/experiments/#{namespaced_path}/application_experiment.rb"
47
+ else
48
+ "app/experiments/application_experiment.rb"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ <% module_namespacing do -%>
2
+ class ApplicationExperiment < ActiveExperiment::Base
3
+ end
4
+ <% end -%>
@@ -0,0 +1,35 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %>Experiment < <%= parent_class_name.classify %>
3
+ <% variant_names.each do |variant| -%>
4
+ <% if variant == "control" -%>
5
+ <%= variant -%> { }
6
+ <% else -%>
7
+ variant(:<%= variant -%>) { }
8
+ <% end -%>
9
+ <% end -%>
10
+
11
+ <% unless options[:skip_comments] -%>
12
+ <% unless variant_names.include?("control") -%>
13
+ # Specify a default variant to use when the experiment is skipped:
14
+ #use_default_variant :<%= variant_names.first %>
15
+ #
16
+ <% end -%>
17
+ # Run this experiment by providing a context (current_user in this example),
18
+ # and optionally override the variant behaviors:
19
+ #
20
+ # <%= class_name %>Experiment.run(current_user) do |experiment|
21
+ <% variant_names.each do |variant| -%>
22
+ # experiment.on(:<%= variant -%>) { "overridden <%= variant -%>" }
23
+ <% end -%>
24
+ # end
25
+ #
26
+ # Each context (user) will consistently have the same variant assigned. More
27
+ # advanced logic can be provided to segment contexts into specific variants:
28
+ #
29
+ #segment :old_accounts, into: :<%= variant_names.last %>
30
+ #def old_accounts
31
+ # context.created_at < 1.year.ago
32
+ #end
33
+ <% end -%>
34
+ end
35
+ <% end -%>
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require "generators/rspec"
4
+
5
+ module Rspec # :nodoc:
6
+ module Generators # :nodoc:
7
+ class ExperimentGenerator < Rails::Generators::NamedBase # :nodoc:
8
+ source_root(File.expand_path("templates", __dir__))
9
+
10
+ def create_spec_file
11
+ template "experiment_spec.rb", File.join("spec/experiments", class_path, "#{file_name}_experiment_spec.rb")
12
+ end
13
+
14
+ private
15
+ def file_name
16
+ @_file_name ||= super.sub(/_experiment\z/i, "")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ require "rails_helper"
2
+
3
+ <% module_namespacing do -%>
4
+ RSpec.describe <%= class_name -%>Experiment, type: :experiment do
5
+ pending "add some examples to (or delete) #{__FILE__}"
6
+ end
7
+ <% end -%>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/test_unit"
4
+
5
+ module TestUnit # :nodoc:
6
+ module Generators # :nodoc:
7
+ class ExperimentGenerator < Base # :nodoc:
8
+ check_class_collision suffix: "ExperimentTest"
9
+ source_root(File.expand_path("templates", __dir__))
10
+
11
+ def create_test_file
12
+ template "experiment_test.rb", File.join("test/experiments", class_path, "#{file_name}_experiment_test.rb")
13
+ end
14
+
15
+ private
16
+ def file_name
17
+ @_file_name ||= super.sub(/_experiment\z/i, "")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ require "test_helper"
2
+
3
+ <% module_namespacing do -%>
4
+ class <%= class_name %>ExperimentTest < ActiveJob::TestCase
5
+ # test "the truth" do
6
+ # assert true
7
+ # end
8
+ end
9
+ <% end -%>