This Rails project is an RSpec design pattern and best practices for modularizing the /spec/support
folder when a project grows too big.
"Macro" is an umbrella term for RSpec helper constructs:
- Methods
- Matchers using either:
RSpec::Matchers.define do...end
extend RSpec::Matchers::DSL
andmatcher do...end
- Shared Examples and Contexts
rspec -fd
to run the/spec
tests on the design patternlib/scripts/prove.sh
to figure out how RSpec macros really workslib/scripts/type.sh
confirms that/spec
works independent of orderlib/scripts/subtype.sh
confirms thatsubtype
metadata isolationlib/scripts/all.sh
runs the previous 3 in succession
Here are the practices I came up with:
- Automatically
require
all helper files inspec/support
- No need to
require
manually
- No need to
- Use separate
spec/support/<folder>
to segment helpers for easy lookup:- One folder for each spec type (e.g.,
request
,model
) vendor
folder for gems, rails monkey-patches, etc. Forex:spec/support/vendor/capybara.rb
global
for project helpers that are global to all spec typesspec/support/global/translation_helper.rb
- One folder for each spec type (e.g.,
- Name helper files according to purpose. Forex:
vendor.rb
is inline and not wrapped in amodule
Forex:capybara.rb
hasRSpec.configure
statements
model_helper.rb
has all the common macros for models- "methods" and "matchers" wrapped in a
module
likeModelHelper
- "shared_examples" appear inline after the module definition
- Eventually
model_examples.rb
might contain just the shared examples
- Eventually
- "methods" and "matchers" wrapped in a
- Add new
:type
metadata for new types withdefine_derived_metadata
- This metadata can be used to
RSpec.configure include WorkerHelper, type: :worker
- This metadata can be used to
- Add
:subtype
metadata for nested folders / namespaces. Forex:subtype: :admin_controller
for ActiveAdminsubtype: :api_request
for your API
- Don't define global methods since they get mixed-in to
Object
- Scope all methods inside
module <Name>Helper
for proper encapsulation- Use
RSpec.configure #include Helper <metadata>
for selective inclusion - Allows consistently named methods like
sign_in
to behave differently by context
- Use
- Use
RSpec::Matchers.define
for global matchers - Scope overridable matchers inside
module <Name>Helper
for proper encapsulation- Use
RSpec.configure #include Helper <metadata>
for selective inclusion - Use
extend RSpec::Matchers::DSL
andmatch...do
to define matchers
- Use
- Prefer symbols over strings for shared_example names
:my_example
over"my example"
- Shared examples declared in a "module" or "include file" are ALWAYS global to RSpec
- Don't wrap these shared_examples in modules as it's misleading
- Place them inline below the
module
definition
- Place them inline below the
- Disambiguate global :names by prepending with :type or :subtype
:request_success
:controller_failure
:admin_page_available
- Don't wrap these shared_examples in modules as it's misleading
The same holds true for these additional RSpec aliases
:
alias shared_context shared_examples
alias shared_examples_for shared_examples
Each *_spec.rb
should
- Have a single top level
require
for eitherspec_helper
orrails_helper
- Begin with
RSpec.<method>
for forward compatibility
rails generate rspec:install
creates:
spec/spec_helper.rb
is the minimal needed for every specspec/rails_helper.rb
is the minimal needed for every spec that depends on rails- calls
require spec/spec_helper.rb
- calls
Auto-require all the support files like this to avoid having to do it manually:
Dir[Rails.root.join("spec/support/**/*.rb")].each {|file| require file }
Extend the built-in spec metadata for custom types:
%w(observer worker).each do |type|
config.define_derived_metadata(:file_path => Regexp.new("/spec/#{type.pluralize}/")) do |metadata|
metadata[:type] = type.to_sym
end
end
Introduce subtype
metadata for namespaces that are nested below traditional types:
%w(controller feature request).each do |type|
%w(admin api).each do |sub|
config.define_derived_metadata(:file_path => Regexp.new("/spec/#{type.pluralize}/#{sub}/")) do |metadata|
metadata[:subtype] = "#{sub}_#{type}".to_sym
end
end
end
Include all vendor related configuration in separate folder spec/support/vendors/<vendor>.rb
. capybara.rb
is the canonical example:
require "capybara/rspec"
Capybara.default_wait_time = 10
RSpec.configure do |config|
config.include Capybara::DSL, type: :feature
config.after type: :feature do
page.driver.reset!
Capybara.reset_sessions!
end
end
Vendor helpers should not be wrapped in modules. To configure a new gem, just add a new <vendor>.rb
file to this folder and it will be auto-required.
The general format for a project helper (one that is unique to your Rails project) is
module FooHelper
extend RSpec::Matchers::DSL
# METHODS
def foo...end
# MATCHERS
matcher do...end
end
# CONFIG
RSpec.config do...end
# SHARED EXAMPLES
shared_example :foo do...end
If you are adding methods and overridable matchers, you will need to add this to rails_helper
:
RSpec.configure do |config|
config.include FooHelper
end
As the number of shared examples increase, you should make them into a separate foo_examples.rb
Notice that CONFIG and SHARED_EXAMPLES are not inside the module
. This is because the are evaluated at load time and are not scoped in any way by the module
.
# global scope
def foo...end
RSpec.describe "My Spec" do
# example_group scope
def foo...end
context "My Context" do
# context scope
def foo...end
end
end
In "global" scope, #foo
is a private instance method on Object
.
- Which is inherited by
RSpec::ExampleGroup < Object
- This can lead to some unusual results.
- Forex
"string".send(:foo)
is allowed - Avoid this pattern where possible
- Forex
In "example_group" scope, #foo
is a public instance method on class RSpec::ExampleGroups::MySpec
public RSpec::ExampleGroups::MySpec#foo
overridesprivate Object#foo
#foo
is inherited by every nesteddescribe
orcontext
class
In "context" scope, #foo
is a public instance method that overrides example_scope
because RSpec::ExampleGroups::MySpec::MyContext < RSpec::ExampleGroups::MySpec
# global scope
RSpec::Matchers.define :foo do...end
matcher :bar do...end
RSpec.describe "Top" do
# example_group scope
RSpec::Matchers.define :foo do...end
matcher :bar do...end
context "My Context" do
# context scope
RSpec::Matchers.define :foo do...end
matcher :bar do...end
end
end
RSpec::Matchers.define do...end
globally defines a matcher.- Call it again (no matter how it's scoped) and the most recent declaration wins
- In this example, "context_scope"
:foo
is used
- To get predictable overrides, always use the
match..do
DSLmatch..do
declarations are only available in the scope in which they are declaredRSpec::Matchers::DSL
is already mixed in toexample_groups
- Manually
extend RSpec::Matchers::DSL
inmodules
Like this:
module FooHelper
extend RSpec::Matchers::DSL
matcher :foo do
match do...end
end
end
# global scope
shared_examples_for :foo do...end
RSpec.describe "My Spec" do
# example_group scope
shared_examples_for :foo do...end
context "My Context" do
# context scope
shared_examples_for :foo do...end
end
end
At load time, shared examples populate a global RSpec registry which can be examined with:
RSpec.world.shared_example_group_registry.send(:shared_example_groups)
After declaring "global" :foo
, the registry looks like this:
{
:main=>{
:foo=>#<RSpec::Core::SharedExampleGroupModule :foo>
}
}
After declaring "example_group" :foo
, the registry looks like this because the classes are cleverly used as the keys:
{
:main=>{
:foo=>#<RSpec::Core::SharedExampleGroupModule :foo>
},
RSpec::ExampleGroups::MySpec=>{
:foo=>#<RSpec::Core::SharedExampleGroupModule :foo>
}
}
And so on:
{
:main=>{
:foo=>#<RSpec::Core::SharedExampleGroupModule :foo>
},
RSpec::ExampleGroups::MySpec=>{
:foo=>#<RSpec::Core::SharedExampleGroupModule :foo>
},
RSpec::ExampleGroups::MySpec::MyContext=>{
:foo=>#<RSpec::Core::SharedExampleGroupModule :foo>
}
}
They are overriding each other based on the way RSpec looks up keys in the global registry. There is, however, an important consideration:
# global scope
shared_examples_for :foo do...end
module ScopedFoo
shared_examples_for :foo do...end
end
RSpec.describe "My Spec" do
# example_group scope
include ScopedFoo
end
You might think ScopedFoo shared_examples_for :foo
overrides global :foo
but it doesn't. The keys will collide and RSpec generates this warning:
WARNING: Shared example group 'foo' has been previously defined at:
/spec-test/includes/foo_spec.rb:13
...and you are now defining it at:
spec-test/includes/foo_spec.rb:24
The new definition will overwrite the original one.
This is because shared_examples
in a separate module
or include file
(whether wrapped in a module
or not) calls Module.shared_examples
which ALWAYS adds a registry entry using the key :main
:
module TopLevelDSL
def self.definitions
proc do
def shared_examples(name, *args, &block)
RSpec.world.shared_example_group_registry.add(:main, name, *args, &block)
end
end
end
end
It can be deferred until "include" time, but the effect is the same -- they are added to :main
.
module SelfIncludedFoo
def self.included(parent)
shared_examples_for :foo do...end
end
end
Whereas declaring shared_examples
inside ANY RSpec::ExampleGroup
class using describe
or context
adds a registry key using the parent class self
as the key:
module SharedExampleGroup
def shared_examples(name, *args, &block)
RSpec.world.shared_example_group_registry.add(self, name, *args, &block)
end
The bottom line is this: shared_examples declared outside of "example_groups" all exist in the same :main
namespace and must be unique.