Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Apply module/shared context inclusion to individual examples #1749

Merged
merged 14 commits into from
Jan 20, 2015

Conversation

myronmarston
Copy link
Member

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 an around 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 a describe or context 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

@myronmarston myronmarston force-pushed the more-powerful-include branch 4 times, most recently from ccc524d to a58a8ed Compare October 29, 2014 21:05
@myronmarston
Copy link
Member Author

Any thoughts on this, @rspec/rspec?

@JonRowe
Copy link
Member

JonRowe commented Nov 5, 2014

I'd like to see the benchmarks on this, and I'd ideally want the :context hooks to run like normal...

@myronmarston
Copy link
Member Author

I'd ideally want the :context hooks to run like normal...

I've been leaning that way as well. That leads to another question, as well....should we do something to make this work?

RSpec.configure do |config|
  config.before(:context, :some_tag) do
  end
end

RSpec.describe "Group" do
  it "does stuff", :some_tag do
  end
end

@JonRowe
Copy link
Member

JonRowe commented Nov 5, 2014

Hmm. I'm leaning towards yes, because I'd be surprised it didn't work...

@myronmarston myronmarston force-pushed the more-powerful-include branch 2 times, most recently from e1ef028 to 331ca53 Compare November 11, 2014 06:59
@myronmarston myronmarston force-pushed the more-powerful-include branch 2 times, most recently from 76a1414 to 5c0b6d2 Compare November 28, 2014 21:26
@myronmarston
Copy link
Member Author

OK, I've made some more progress on this. I feel like it's become a giant yak shaving exercise, unfortunately :(. A few things to note:

  • Getting :context hooks from shared contexts to apply to individual examples was pretty trivial (except for some 1.8.7 weirdness I had to work around).
  • Getting :context hooks from config to apply to individual examples was more complicated. Updating the hooks module to support this seemed complicated and I opted instead to refactor things so that the config hooks no longer use the Hook module but instead just delegate to module inclusion.
  • This has the side effect of allowing us to more easily keep separated the slightly different semantics of config vs example group hooks. For example, :suite hooks -- config supports these, example groups don't. This allows us to more explicitly warn/raise when users do unsupported things. There's more to do here, though, such as :suite hooks should not support metadata #1741.
  • Eventually, we might consider removing the metadata filtering logic from hooks.rb entirely if we go this route. Using metadata filtering for config hooks is super useful and natural; using it for hooks defined in example groups is pretty weird and, IMO, will nearly always be overly complicated. Simplifying hooks.rb and dropping support for that appeals to me, but we have to be careful with SemVer compatibility, of course.
  • Module inclusion uses any_apply? when deciding whether or not to include a module based on metadata, where as in hooks.rb it uses all_apply? when deciding if the hook should be applied. I think this means that I've broken something by this refactoring, although all the specs pass. I need to dig into it some more. Honestly, I don't understand why any_apply? was chosen for one and all_apply? for the other. Really, it only comes into play when you pass multiple metadata options for these things.
  • I haven't yet benchmarked this; still a TODO.
  • I noticed that JRuby in 2.0 mode is having trouble with some changes here. I'm not sure we fully support that, given that it's not a full 2.0 mode, so maybe it's OK to ignore. Not sure yet.
  • The last couple commits are pretty rough (particularly the last one) as I was spiking this out. There's probably some refactoring in there that is valuable to keep regardless of the rest of this PR, though.

I'll keep chipping away on this but someone else can feel free to look it over and give feedback.

@myronmarston myronmarston mentioned this pull request Nov 29, 2014
@myronmarston
Copy link
Member Author

So after implementing #1796 I realized that there's no way to keep the spec I added there green with how I've changed the implementation of filtered hooks in this PR. Specifically, if we make config.before(:example, :some => :metadata) { } delegate to an including a module with :some => :metadata, then the before hooks is applied to all examples in matching groups, with no way to un-apply it to examples that opt out by overriding parent metadata.

I think I'm going to have to roll back many of my later commits in this PR and take a different approach.

@myronmarston myronmarston force-pushed the more-powerful-include branch 3 times, most recently from c1947ba to 31ed2c2 Compare December 9, 2014 00:04
@myronmarston
Copy link
Member Author

OK, I added benchmarks. It's a bit of a mixed bag. The benchmarks define and run 8400 examples at various nesting levels with metadata, and then there various benchmarks for:

  • Having no config at all
  • Having 10 before(:context) and 10 after(:context) hooks defined in config, all with metadata.
  • Having 10 config.include modules with metadata.
  • Having 10 shared contexts defined with auto-inclusion metadata.

The latter 3 were run with all 8400 examples having matching metadata and then with none of the examples having matching metadata.

Takeaways:

  • Things are slower across the board with my changes, even in the case with no config, since this adds extra logic that runs as part of each example.
  • The module inclusions don't affect things nearly as much as the shared groups or config hooks do.
  • The shared groups affecting things a ton...it's crazy how much slower it makes it.
  • The config hooks also have a surprisingly large affect (although it's much smaller than the shared groups!). Bear in mind there are a total of 20 hooks defined compared to 10 modules and 10 shared example groups.
  • In all cases, the cost was noticeably smaller when there are no examples with matching metadata. For the config hooks and especially the shared group inclusions, it makes a huge difference. IOW, the worst of the perf degradation is when this feature is actually being used. When you're not using this feature it doesn't have nearly the same affect. That's a good thing.

As things currently stand, I'm not comfortable even with the perf to merge this as-is. But maybe we can find some low hanging fruit to optimize? In particular, I care the most about trying to get the perf of the case where examples don't match (i.e. test suites that don't leverage this feature) as close to the perf we have w/o these changes as possible. I'm far less concerned about the perf when this feature is being used, although it'd be nice to optimize that, too.

@xaviershay, would you want to pair on some perf optimizations in these areas some time?

I'm curious what others think about the perf cost vs the benefit of this change, too.

/cc @rspec/rspec

@JonRowe
Copy link
Member

JonRowe commented Dec 9, 2014

As a heavy user of these features I really don't want a big performance hit here... This will also have a knock on effect for users of rspec-rails because they have these features forced upon them by us.

@xaviershay
Copy link
Member

I'm travelling in AU without a schedule for the next month, so might be hard to find a good time to pair. Will try to hit you up though, and if not I'll just have a poke at it anyways.

@JonRowe
Copy link
Member

JonRowe commented Dec 9, 2014

Gonna be in Sydney at all? Or just Melbs?

@xaviershay
Copy link
Member

Just Melbourne.

@myronmarston
Copy link
Member Author

@JonRowe, by "these features" are you referring to the config.include module inclusion API used by rspec-rails?

@myronmarston
Copy link
Member Author

BTW, I've got an idea for how we can covert the linear "find matching modules/hooks/etc" checks into (practically) constant lookups, which I think should help considerably. I'm going to give that a shot.

@JonRowe
Copy link
Member

JonRowe commented Dec 9, 2014

@JonRowe, by "these features" are you referring to the config.include module inclusion API used by rspec-rails?

I am :)

@myronmarston
Copy link
Member Author

So I just pushed a WIP commit for my idea. See cb245d3. It shows a modest improvement over previous perf. I plan to keep playing with it but wanted to put it out there to get some early feedback if anyone wants to look at it.

@myronmarston
Copy link
Member Author

I extracted some of the refactoring changes (and other minor fixes) I had done as part of this PR (either in the original version of the PR or in what there is now) into #1805. The perf improvement I started playing with in the most recent commit is something that I think I'll work into its own PR as it should improve perf when applied to master, w/o the changes here (although, I think the perf diff is more dramatic with the changes here).

I am still interested in other perf improvements offered by others...

They are optimized for different cases.
- Config should use the QueryOptimized one because config
  usually sets up global hooks, inclusions, etc once at the
  start of the process (e.g. in `spec_helper`) and then is
  repeatedly queried as each example or group is defined.
- Examples and groups should use the UpdateOptimized one as
  they are not queried by other examples or groups the way
  config is; instead, they can be updated multiple times
  based on having multiple global config hooks to process.
  Cutting out the additional update processing done by
  the QueryOptimized implementation greatly improves
  the performance of the `with_config_hooks` benchmark.
The call to `RSpec::CallerFilter.first_non_rspec_line`
took most of the time. I originally planned to leverage
rspec/rspec-support#155 but realized we could avoid
calling that entirely by re-using the location from
the group’s metadata.

I re-ran all the benchmarks and updated them. They
look much, much better.
@myronmarston myronmarston force-pushed the more-powerful-include branch from 293dda7 to a1a3dc4 Compare January 15, 2015 03:49
@myronmarston
Copy link
Member Author

I rebased against master now that #1848 has been merged so hopefully this will pass now.

@myronmarston
Copy link
Member Author

And it is indeed now green (including on AppVeyor).

@JonRowe, please let me know if what's here is insufficient. If so, I can put some more time into profiling today, and I may choose to cherry-pick the last 4 commits to a new PR targeting master since they are general perf improvements that will help outside of this PR.

@JonRowe
Copy link
Member

JonRowe commented Jan 18, 2015

I'll rerun these later today to find out!

@myronmarston
Copy link
Member Author

I'll rerun these later today to find out!

Did you get a chance to do this?

@JonRowe
Copy link
Member

JonRowe commented Jan 19, 2015

Doing it now

@JonRowe
Copy link
Member

JonRowe commented Jan 20, 2015

with_config_hooks.rb

No match -- without singleton group support
                        647.222  (±34.6%) i/s -      2.530k
No match -- with singleton group support
                        565.663  (±31.8%) i/s -      2.009k
Example match -- without singleton group support
                        637.552  (±34.8%) i/s -      2.303k in   5.473734s
Example match -- with singleton group support
                        475.289  (±32.0%) i/s -      1.560k in   5.302093s
Group match -- without singleton group support
                        622.463  (±32.0%) i/s -      2.162k
Group match -- with singleton group support
                        497.005  (±30.2%) i/s -      1.591k in   5.644785s
Both match -- without singleton group support
                        652.425  (±31.1%) i/s -      1.974k
Both match -- with singleton group support
                        494.419  (±27.9%) i/s -      1.470k

with_config_hooks_module_inclusions_and_shared_context_inclusions.rb

No match -- without singleton group support
                        648.157  (±34.4%) i/s -      2.438k
No match -- with singleton group support
                        569.331  (±32.8%) i/s -      2.050k
Example match -- without singleton group support
                        635.033  (±36.8%) i/s -      2.295k
Example match -- with singleton group support
                        411.128  (±31.4%) i/s -      1.248k
Group match -- without singleton group support
                        530.298  (±31.7%) i/s -      1.850k
Group match -- with singleton group support
                        440.181  (±31.1%) i/s -      1.470k in   5.744591s
Both match -- without singleton group support
                        587.389  (±30.0%) i/s -      1.845k
Both match -- with singleton group support
                        441.388  (±30.6%) i/s -      1.320k

with_module_inclusions.rb

No match -- without singleton group support
                        676.603  (±34.7%) i/s -      2.494k
No match -- with singleton group support
                        629.130  (±31.3%) i/s -      2.193k
Example match -- without singleton group support
                        652.903  (±37.1%) i/s -      2.300k in   5.450937s
Example match -- with singleton group support
                        564.873  (±32.4%) i/s -      1.764k
Group match -- without singleton group support
                        640.222  (±31.2%) i/s -      2.205k
Group match -- with singleton group support
                        597.270  (±32.8%) i/s -      2.024k in   5.974166s
Both match -- without singleton group support
                        663.392  (±33.2%) i/s -      2.050k
Both match -- with singleton group support
                        625.442  (±28.8%) i/s -      1.892k

with_no_config_hooks_or_inclusions.rb

No match -- without singleton group support
                        692.283  (±34.5%) i/s -      2.537k
No match -- with singleton group support
                        580.327  (±35.0%) i/s -      2.184k in   5.498542s
Example match -- without singleton group support
                        636.292  (±34.7%) i/s -      2.310k in   5.596156s
Example match -- with singleton group support
                        595.478  (±32.4%) i/s -      2.107k
Group match -- without singleton group support
                        628.846  (±35.5%) i/s -      2.052k
Group match -- with singleton group support
                        605.565  (±29.4%) i/s -      1.950k
Both match -- without singleton group support
                        668.727  (±33.6%) i/s -      2.040k
Both match -- with singleton group support
                        648.712  (±30.2%) i/s -      1.880k

with_shared_context_inclusions.rb

No match -- without singleton group support
                        687.075  (±34.3%) i/s -      2.520k
No match -- with singleton group support
                        641.615  (±32.9%) i/s -      2.184k
Example match -- without singleton group support
                        653.823  (±36.2%) i/s -      2.365k in   5.644462s
Example match -- with singleton group support
                        528.077  (±32.8%) i/s -      1.786k in   5.469866s
Group match -- without singleton group support
                        631.137  (±36.9%) i/s -      2.173k in   5.834991s
Group match -- with singleton group support
                        596.201  (±27.8%) i/s -      1.974k
Both match -- without singleton group support
                        648.851  (±33.0%) i/s -      1.968k
Both match -- with singleton group support
                        608.591  (±29.1%) i/s -      1.806k

@JonRowe
Copy link
Member

JonRowe commented Jan 20, 2015

Much better, it's still slower but I guess I'm ok with it if you are.

myronmarston added a commit that referenced this pull request Jan 20, 2015
Apply module/shared context inclusion to individual examples
@myronmarston myronmarston merged commit 229bbaa into master Jan 20, 2015
@myronmarston myronmarston deleted the more-powerful-include branch January 20, 2015 05:51
myronmarston added a commit that referenced this pull request Mar 20, 2015
Somehow I forgot to add this before...

[ci skip]
myronmarston added a commit that referenced this pull request Mar 20, 2015
Somehow I forgot to add this before...

[ci skip]
myronmarston added a commit that referenced this pull request Apr 30, 2016
This regressed in #1749. The changes there affected code like this:

``` ruby
RSpec.configure do |c|
  c.include Mod, :example_group => { :file_path => /./ }
end
```

In RSpec 2, and in RSpec 3 before #1749, this snippet would cause `Mod`
to be included in every example group (since `/./` matches all file
paths). After #1749, it would _not_ be included in any example groups.
Instead, it was included in the singleton class of every example.
In practice, this usually worked OK, as the methods from `Mod` were
available to be called very every example, but it had subtle problems:

* If the same method was defined in multiple included modules,
  the version of the method defined in a module included in this
  fashion would always get precedence due to the singleton class
  getting "first dibs" during method dispatch over the example group
  class. (This is the broken example given in #2232).
* I believe this broke `c.extend Mod, :example_group => {...}` since
  we don't extend onto singleton classes.

The reason is that the optimizations included in #1749 considered
only metadata keys that the groups and examples have in their metadata
hashes. In a group hash, `:example_group` is not a "real" metadata
key--instead it is created lazily on first access using a
`default_proc`. But example hashes still have it (it is a reference to
the metadata of their group), so it allowed example filtering to work
correctly, which in turn hid the bug.

Fixes #2232.
myronmarston added a commit that referenced this pull request Apr 30, 2016
This regressed in #1749. The changes there affected code like this:

``` ruby
RSpec.configure do |c|
  c.include Mod, :example_group => { :file_path => /./ }
end
```

In RSpec 2, and in RSpec 3 before #1749, this snippet would cause `Mod`
to be included in every example group (since `/./` matches all file
paths). After #1749, it would _not_ be included in any example groups.
Instead, it was included in the singleton class of every example.
In practice, this usually worked OK, as the methods from `Mod` were
available to be called very every example, but it had subtle problems:

* If the same method was defined in multiple included modules,
  the version of the method defined in a module included in this
  fashion would always get precedence due to the singleton class
  getting "first dibs" during method dispatch over the example group
  class. (This is the broken example given in #2232).
* I believe this broke `c.extend Mod, :example_group => {...}` since
  we don't extend onto singleton classes.

The reason is that the optimizations included in #1749 considered
only metadata keys that the groups and examples have in their metadata
hashes. In a group hash, `:example_group` is not a "real" metadata
key--instead it is created lazily on first access using a
`default_proc`. But example hashes still have it (it is a reference to
the metadata of their group), so it allowed example filtering to work
correctly, which in turn hid the bug.

Fixes #2232.
MatheusRich pushed a commit to MatheusRich/rspec-core that referenced this pull request Oct 30, 2020
Apply module/shared context inclusion to individual examples
MatheusRich pushed a commit to MatheusRich/rspec-core that referenced this pull request Oct 30, 2020
Somehow I forgot to add this before...

[ci skip]
MatheusRich pushed a commit to MatheusRich/rspec-core that referenced this pull request Oct 30, 2020
This regressed in rspec#1749. The changes there affected code like this:

``` ruby
RSpec.configure do |c|
  c.include Mod, :example_group => { :file_path => /./ }
end
```

In RSpec 2, and in RSpec 3 before rspec#1749, this snippet would cause `Mod`
to be included in every example group (since `/./` matches all file
paths). After rspec#1749, it would _not_ be included in any example groups.
Instead, it was included in the singleton class of every example.
In practice, this usually worked OK, as the methods from `Mod` were
available to be called very every example, but it had subtle problems:

* If the same method was defined in multiple included modules,
  the version of the method defined in a module included in this
  fashion would always get precedence due to the singleton class
  getting "first dibs" during method dispatch over the example group
  class. (This is the broken example given in rspec#2232).
* I believe this broke `c.extend Mod, :example_group => {...}` since
  we don't extend onto singleton classes.

The reason is that the optimizations included in rspec#1749 considered
only metadata keys that the groups and examples have in their metadata
hashes. In a group hash, `:example_group` is not a "real" metadata
key--instead it is created lazily on first access using a
`default_proc`. But example hashes still have it (it is a reference to
the metadata of their group), so it allowed example filtering to work
correctly, which in turn hid the bug.

Fixes rspec#2232.
yujinakayama pushed a commit to yujinakayama/rspec-monorepo that referenced this pull request Oct 6, 2021
Somehow I forgot to add this before...

[ci skip]

---
This commit was imported from rspec/rspec-core@8bba91a.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants