Apply module/shared context inclusion to individual examples
Created by: myronmarston
This is an idea I'm toying with. I lean towards including it but I'm not totally sold on the idea yet.
### The problem
We have two different mechanisms for defining metadata-filtered shared context. On the configuration object:
RSpec.configure do |config|
config.around(:example, :db_1) { |ex| DB.transaction(:always_rollback => true, &ex) }
end
...and as a shared context:
RSpec.shared_context "with a DB transaction", :db_2 do
around(:example) { |ex| DB.transaction(:always_rollback => true, &ex) }
end
The latter is generally more useful because shared_context
supports all rspec-core constructs, including let
, helper methods, etc...where as the config object only provides the means to define hooks that way. So I'd consider the latter form to be generally "better" but it has a downside:
RSpec.describe "Group with both approaches applied", :db_1, :db_2 do
# examples
end
RSpec.describe "Group with an example that has both approaches applied" do
it "does stuff", :db_1, :db_2 do
end
end
Both forms work fine in the first example (where the metadata has been applied to the group), but only the config-based approach (:db_1
) works in the second example. The problem is that the shared_context
is included (or not) in example groups, not individual examples, whereas the config can apply just fine. A user might reasonably expect the 2nd example to work, but as things currently stand, it won't. I myself have run into this a few times, where I had something that I had defined as a shared_context
but thought I had defined on config
, and I tagged an example and it didn't work.
Solution
I realized recently that ruby's object model (particularly the singleton_class
) provides a means to very simply address this issue: we can include modules/shared contexts directly in the singleton class of the example group instance owned by an individual example. This allows the contents of the module or shared context to apply to one example, but not others in the group. I'm pleased with how simple the implementation was.
However, I'm not 100% sold on this, for a few reasons:
- It's potentially surprising behavior for people that have a good mental model of example groups vs examples...those users probably wouldn't expect this to work.
- It feels a little bit "magical" (even though in practice, it's actually really simple and is just leveraging ruby's object model).
- It adds some additional processing to each example when it runs, potentially making RSpec a bit slower.
- Including a module in an object's singleton class at run time busts the method cache, potentially having an even bigger affect on perf. (However, every time rspec-mocks features are used, the method cache gets busted since it has to do similar things all the time).
- There's some slightly odd semantics with
before(:context)
/after(:context)
hooks that are defined in the shared context. As currently implemented, when the context is applied to one example, these do not run. However, they potentially setup and teardown some state that other parts of the context (such as anaround
hook) use, so not running them could cause problems. I'm on the fence about whether or not they should be run. - This could be a breaking change for some folks. Although, I would not consider it a SemVer violation.
- It's potentially confusing that
config.extend
modules aren't treated the same way. I didn't think it made sense to apply those modules the same, since they generally define class-level helper methods (e.g. macros) that are called from within adescribe
orcontext
block. (But can anyone thing of a need or use to treat them the same way?)
TODO:
-
Get the build to pass on all rubies (I want to discuss this before putting effort into that). -
Benchmark the perf difference this has. -
Decide how to handle :context
hooks. -
Figure out how this should be documented.
Feedback wanted.
/cc @rspec/rspec