Skip to content

Commit 65f93cf

Browse files
committed
Merge pull request rspec#2175 from rspec/when-first-matching-example-defined
Add `config.when_first_matching_example_defined`.
2 parents 6b5fa93 + de5c5ed commit 65f93cf

File tree

11 files changed

+325
-47
lines changed

11 files changed

+325
-47
lines changed

Changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Enhancements:
99
(Myron Marston, #2189)
1010
* `RSpec::Core::Configuration#reporter` is now public API under semver.
1111
(Jon Rowe, #2193)
12+
* Add new `config.when_first_matching_example_defined` hook. (Myron
13+
Marston, #2175)
1214

1315
### 3.5.0.beta1 / 2016-02-06
1416
[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.4.3...v3.5.0.beta1)

features/.nav

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- before_and_after_hooks.feature
2121
- around_hooks.feature
2222
- filtering.feature
23+
- when_first_matching_example_defined.feature
2324
- subject:
2425
- implicit_subject.feature
2526
- explicit_subject.feature
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
Feature: `when_first_matching_example_defined` hook
2+
3+
In large projects that use RSpec, it's common to have some expensive setup logic
4+
that is only needed when certain kinds of specs have been loaded. If that kind of
5+
spec has not been loaded, you'd prefer to avoid the cost of doing the setup.
6+
7+
The `when_first_matching_example_defined` hook makes it easy to conditionally
8+
perform some logic when the first example is defined with matching metadata,
9+
allowing you to ensure the necessary setup is performed only when needed.
10+
11+
Background:
12+
Given a file named "spec/spec_helper.rb" with:
13+
"""ruby
14+
RSpec.configure do |config|
15+
config.when_first_matching_example_defined(:db) do
16+
require "support/db"
17+
end
18+
end
19+
"""
20+
And a file named "spec/support/db.rb" with:
21+
"""ruby
22+
RSpec.configure do |config|
23+
config.before(:suite) do
24+
puts "Bootstrapped the DB."
25+
end
26+
27+
config.around(:example, :db) do |example|
28+
puts "Starting a DB transaction."
29+
example.run
30+
puts "Rolling back a DB transaction."
31+
end
32+
end
33+
"""
34+
And a file named ".rspec" with:
35+
"""
36+
--require spec_helper
37+
"""
38+
And a file named "spec/unit_spec.rb" with:
39+
"""
40+
RSpec.describe "A unit spec" do
41+
it "does not require a database" do
42+
puts "in unit example"
43+
end
44+
end
45+
"""
46+
And a file named "spec/integration_spec.rb" with:
47+
"""
48+
RSpec.describe "An integration spec", :db do
49+
it "requires a database" do
50+
puts "in integration example"
51+
end
52+
end
53+
"""
54+
55+
Scenario: Running the entire suite loads the DB setup
56+
When I run `rspec`
57+
Then it should pass with:
58+
"""
59+
Bootstrapped the DB.
60+
Starting a DB transaction.
61+
in integration example
62+
Rolling back a DB transaction.
63+
.in unit example
64+
.
65+
"""
66+
67+
Scenario: Running just the unit spec does not load the DB setup
68+
When I run `rspec spec/unit_spec.rb`
69+
Then the examples should all pass
70+
And the output should not contain "DB"

lib/rspec/core/configuration.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,41 @@ def define_derived_metadata(*filters, &block)
15531553
@derived_metadata_blocks.append(block, meta)
15541554
end
15551555

1556+
# Defines a callback that runs after the first example with matching
1557+
# metadata is defined. If no examples are defined with matching metadata,
1558+
# it will not get called at all.
1559+
#
1560+
# This can be used to ensure some setup is performed (such as bootstrapping
1561+
# a DB or loading a specific file that adds significantly to the boot time)
1562+
# if needed (as indicated by the presence of an example with matching metadata)
1563+
# but avoided otherwise.
1564+
#
1565+
# @example
1566+
# RSpec.configure do |config|
1567+
# config.when_first_matching_example_defined(:db) do
1568+
# # Load a support file that does some heavyweight setup,
1569+
# # including bootstrapping the DB, but only if we have loaded
1570+
# # any examples tagged with `:db`.
1571+
# require 'support/db'
1572+
# end
1573+
# end
1574+
def when_first_matching_example_defined(*filters, &block)
1575+
specified_meta = Metadata.build_hash_from(filters, :warn_about_example_group_filtering)
1576+
1577+
callback = lambda do |example_or_group_meta|
1578+
# Example groups do not have `:example_group` metadata
1579+
# (instead they have `:parent_example_group` metadata).
1580+
return unless example_or_group_meta.key?(:example_group)
1581+
1582+
# Ensure the callback only fires once.
1583+
@derived_metadata_blocks.items_for(specified_meta).delete(callback)
1584+
1585+
block.call
1586+
end
1587+
1588+
@derived_metadata_blocks.append(callback, specified_meta)
1589+
end
1590+
15561591
# @private
15571592
def apply_derived_metadata_to(metadata)
15581593
@derived_metadata_blocks.items_for(metadata).each do |block|

lib/rspec/core/dsl.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def self.expose_example_group_alias(name)
4040
example_group_aliases << name
4141

4242
(class << RSpec; self; end).__send__(:define_method, name) do |*args, &example_group_block|
43-
RSpec.world.register RSpec::Core::ExampleGroup.__send__(name, *args, &example_group_block)
43+
group = RSpec::Core::ExampleGroup.__send__(name, *args, &example_group_block)
44+
RSpec.world.record(group)
45+
group
4446
end
4547

4648
expose_example_group_alias_globally(name) if exposed_globally?

lib/rspec/core/example.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ def initialize(example_group_class, description, user_metadata, example_block=ni
178178
@example_group_class = example_group_class
179179
@example_block = example_block
180180

181+
# Register the example with the group before creating the metadata hash.
182+
# This is necessary since creating the metadata hash triggers
183+
# `when_first_matching_example_defined` callbacks, in which users can
184+
# load RSpec support code which defines hooks. For that to work, the
185+
# examples and example groups must be registered at the time the
186+
# support code is called or be defined afterwards.
187+
# Begin defined beforehand but registered afterwards causes hooks to
188+
# not be applied where they should.
189+
example_group_class.examples << self
190+
181191
@metadata = Metadata::ExampleHash.create(
182192
@example_group_class.metadata, user_metadata,
183193
example_group_class.method(:next_runnable_index_for),

lib/rspec/core/example_group.rb

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,7 @@ def self.define_example_method(name, extra_options={})
143143
options.update(:skip => RSpec::Core::Pending::NOT_YET_IMPLEMENTED) unless block
144144
options.update(extra_options)
145145

146-
example = RSpec::Core::Example.new(self, desc, options, block)
147-
examples << example
148-
example
146+
RSpec::Core::Example.new(self, desc, options, block)
149147
end
150148
end
151149

@@ -235,27 +233,27 @@ def self.define_example_group_method(name, metadata={})
235233
thread_data = RSpec::Support.thread_local_data
236234
top_level = self == ExampleGroup
237235

238-
if top_level
239-
if thread_data[:in_example_group]
240-
raise "Creating an isolated context from within a context is " \
241-
"not allowed. Change `RSpec.#{name}` to `#{name}` or " \
242-
"move this to a top-level scope."
236+
registration_collection =
237+
if top_level
238+
if thread_data[:in_example_group]
239+
raise "Creating an isolated context from within a context is " \
240+
"not allowed. Change `RSpec.#{name}` to `#{name}` or " \
241+
"move this to a top-level scope."
242+
end
243+
244+
thread_data[:in_example_group] = true
245+
RSpec.world.example_groups
246+
else
247+
children
243248
end
244249

245-
thread_data[:in_example_group] = true
246-
end
247-
248250
begin
249-
250251
description = args.shift
251252
combined_metadata = metadata.dup
252253
combined_metadata.merge!(args.pop) if args.last.is_a? Hash
253254
args << combined_metadata
254255

255-
subclass(self, description, args, &example_group_block).tap do |child|
256-
children << child
257-
end
258-
256+
subclass(self, description, args, registration_collection, &example_group_block)
259257
ensure
260258
thread_data.delete(:in_example_group) if top_level
261259
end
@@ -379,9 +377,9 @@ def self.find_and_eval_shared(label, name, inclusion_location, *args, &customiza
379377
# @!endgroup
380378

381379
# @private
382-
def self.subclass(parent, description, args, &example_group_block)
380+
def self.subclass(parent, description, args, registration_collection, &example_group_block)
383381
subclass = Class.new(parent)
384-
subclass.set_it_up(description, *args, &example_group_block)
382+
subclass.set_it_up(description, args, registration_collection, &example_group_block)
385383
subclass.module_exec(&example_group_block) if example_group_block
386384

387385
# The LetDefinitions module must be included _after_ other modules
@@ -394,7 +392,7 @@ def self.subclass(parent, description, args, &example_group_block)
394392
end
395393

396394
# @private
397-
def self.set_it_up(description, *args, &example_group_block)
395+
def self.set_it_up(description, args, registration_collection, &example_group_block)
398396
# Ruby 1.9 has a bug that can lead to infinite recursion and a
399397
# SystemStackError if you include a module in a superclass after
400398
# including it in a subclass: https://gist.github.com/845896
@@ -405,6 +403,16 @@ def self.set_it_up(description, *args, &example_group_block)
405403
# here.
406404
ensure_example_groups_are_configured
407405

406+
# Register the example with the group before creating the metadata hash.
407+
# This is necessary since creating the metadata hash triggers
408+
# `when_first_matching_example_defined` callbacks, in which users can
409+
# load RSpec support code which defines hooks. For that to work, the
410+
# examples and example groups must be registered at the time the
411+
# support code is called or be defined afterwards.
412+
# Begin defined beforehand but registered afterwards causes hooks to
413+
# not be applied where they should.
414+
registration_collection << self
415+
408416
user_metadata = Metadata.build_hash_from(args)
409417

410418
@metadata = Metadata::ExampleGroupHash.create(
@@ -444,10 +452,19 @@ def self.children
444452
# @private
445453
def self.next_runnable_index_for(file)
446454
if self == ExampleGroup
447-
RSpec.world.num_example_groups_defined_in(file)
455+
# We add 1 so the ids start at 1 instead of 0. This is
456+
# necessary for this branch (but not for the other one)
457+
# because we register examples and groups with the
458+
# `childeren` and `examples` collection BEFORE this
459+
# method is called as part of metadata hash creation,
460+
# but the example group is recorded with
461+
# `RSpec.world.example_group_counts_by_spec_file` AFTER
462+
# the metadata hash is created and the group is returned
463+
# to the caller.
464+
RSpec.world.num_example_groups_defined_in(file) + 1
448465
else
449466
children.count + examples.count
450-
end + 1
467+
end
451468
end
452469

453470
# @private

lib/rspec/core/world.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,10 @@ def registered_example_group_files
4848

4949
# @api private
5050
#
51-
# Register an example group.
52-
def register(example_group)
51+
# Records an example group.
52+
def record(example_group)
5353
@configuration.on_example_group_definition_callbacks.each { |block| block.call(example_group) }
54-
example_groups << example_group
5554
@example_group_counts_by_spec_file[example_group.metadata[:absolute_file_path]] += 1
56-
example_group
5755
end
5856

5957
# @private

0 commit comments

Comments
 (0)