Common RSpec Setup and Plug-ins

Maintainability
Gem Version

Cop Docs

Basic RSpec setup and plug-ins for use with Radius Networks Ruby / Rails
projects.

Installation

Add this line to your application’s Gemfile:

gem 'radius-spec'

And then execute:

$ bundle

Or install it yourself as:

$ gem install radius-spec

Usage

If you do not already have a project .rspec file we suggest creating one with
at least the following:

--require spec_helper

You should check this .rspec file into version control. See the RSpec
Configuration docs

and Relish examples
for more on loading configuration options.

To load the default suggested RSpec configuration, require this gem at the top
of your spec/spec_helper.rb file. After requiring the gem you can include any
custom RSpec configuration in a RSpec.configure block as usual:

# /spec/spec_helper.rb
# frozen_string_literal: true

require 'radius/spec'

RSpec.configure do |config|
  # Project's with noisy dependencies, and Rails app, include this line to
  # disable warnings.
  config.warnings = false

  # Your project specific custom settings here
end

NOTE: By default warnings are enabled by this gem. Enabling Ruby warnings
is generally recommended. However, for large projects, and including most Rails
apps, with lots of noisy dependencies this can be an issue. For these projects,
we suggest disabling warnings per the above method.

For Rails apps, we suggest a similar approach to your Rails helper:

# /spec/rails_helper.rb
# frozen_string_literal: true

require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'radius/spec/rails'
# Add additional requires below this line. Rails is not loaded until this point!

# Checks for pending migration and applies them before tests are run.
# If you are not using ActiveRecord, you can remove this line.
ActiveRecord::Migration.maintain_test_schema!

RSpec.configure do |config|
  # Your project specific custom settings here
end

Features

Common Rubocop Config

Projects can inherit from the base Rubocop config. This can be
accomplished by using either the remote raw URL or dependency gem formats. With
either method we also strongly suggest setting the inherit_mode to merge
for both Exclude and AllowedPatterns. This way you can append additional
exceptions without overwriting the defaults.

Inherit from Gem (Recommended Method)

inherit_mode:
  merge:
    - Exclude
    - AllowedPatterns

inherit_gem:
  radius-spec:
    - common_rubocop.yml
    # Use the following instead if it is a Rails project
    - common_rubocop_rails.yml

Inherit from URL

inherit_mode:
  merge:
    - Exclude
    - AllowedPatterns

# Available for projects which cannot include this gem (i.e. Ruby < 2.5)
inherit_from:
  - https://raw.githubusercontent.com/RadiusNetworks/radius-spec/main/common_rubocop.yml
  # Use the following instead if it is a Rails project
  - https://raw.githubusercontent.com/RadiusNetworks/radius-spec/main/common_rubocop_rails.yml

When using the raw URL you may need to add the following to the project’s
.gitignore file:

.rubocop-https---raw-githubusercontent-com-RadiusNetworks-radius-spec-main-common-rubocop-rails-yml
.rubocop-https---raw-githubusercontent-com-RadiusNetworks-radius-spec-main-common-rubocop-yml

General Inheritance Notes

Be sure to include the project’s local .rubocop_todo.yml after inheriting
the base configuration so that they take precedence. Also, use the directive
inherit_mode to specify which array configurations to merge together instead
of overriding the inherited value. This can be set both globally and for
specific cops:

inherit_gem:
  radius-spec:
    - .rubocop.yml
    # Use the following instead if it is a Rails project
    - .rubocop_rails.yml
inherit_from: .rubocop_todo.yml

inherit_mode:
  merge:
    - Exclude
    - AllowedPatterns

Style/For:
  inherit_mode:
    override:
      - Exclude
  Exclude:
    - bar.rb

Consult the Rubocop documentation
for the most up-to-date syntax for including the .rubocop.yml
config.

Basic Model Factory

This factory is not Rails specific. It works for any object type that
responds to new with a hash of attributes or keywords; including Struct
using the new Ruby 2.5 keyword_init flag.

Defining Factory Templates

You can use the model factory directly to define a factory template:

require 'radius/spec/model_factory'

Radius::Spec::ModelFactory.define_factory(
  "AnyClass",
  attr1: :any_value,
  attr2: :another_value,
)

Most projects end up needing to specify multiple factories. Having to reference
the full module every time you want to define a factory is tedious. When you
need to define multiple factories we recommended using the factory catalog:

require 'radius/spec/model_factory'

Radius::Spec::ModelFactory.catalog do |c|
  c.factory "AnyClass", attr1: :any_value, attr2: :another_value

  c.factory "AnotherClass",
            attr1: :any_value,
            attr2: :another_value,
            attr3: %i[any list of values]
end
Storing Factory Templates

Our convention is to store all of a project’s factory templates in the file
spec/support/model_factories.rb. As this is our convention, when the model
factory is required it will attempt to load this file automatically as a
convenience.

Lazy Class Loading

When testing in isolation we often don’t want to wait a long time for a lot of
unnecessary project/app code to load. With that in mind we want to keep loading
the model factory and all factory templates as fast as possible. This mean not
loading the associated project/app code at factory template definition time.
This way if you only need one or two factories your remaining domain model code
won’t be loaded.

To utilize this lazy loading define your template using either a string or
symbol class name:

Radius::Spec::ModelFactory.catalog do |c|
  c.factory :AnyClass, attr1: :any_value, attr2: :another_value

  c.factory "AnotherClass",
            attr1: :any_value,
            attr2: :another_value,
            attr3: %i[any list of values]

  c.factory "Nested::Module::SomeClass", attr1: :any_value
end

The only requirement for this feature is that the class must be loaded by the
project/app, or it uses an auto-loading mechanism, by the time the first
instance is built by the factory.

Also, this still supports defining the factory template using the class
constant so no changes need to be made if that’s your preference.

Template Attribute Keys

Attribute keys may be defined using either strings or symbols. However, they
will be stored internally as symbols. This means that when an object instance
is created using the factory the attribute hash will be provided to new with
symbol keys.

Dynamic Attribute Values (i.e. Generators)

We try to keep the special cases / rules to a minimum. To support dynamic
attributes we need to special case templates which define a Proc for an
attribute value. For any template attribute which has a Proc for a value
making an instance through the factory will send call to the proc with no
args.

> NOTE: This only applies to instances of Proc. If you define a template
> value with another object which responds to call that object will be set as
> the attribute value without receiving call.

You can use this to define generators in a number of ways:

Radius::Spec::ModelFactory.catalog do |c|
  # This is not thread safe.
  gid_counter = 0
  usually_gid_generator = -> { gid_counter += 1 }

  c.factory :AnyClass,
            gid: usually_gid_counter,
            temp: -> { rand(0..100) }

  c.factory "AnotherClass",
            gid: usually_gid_counter,
            uuid: -> { SecureRandom.uuid }
end

> NOTE: As of Ruby 2.5 -> {}, lambda {}, proc {}, and Proc.new are all
> instances of Proc.

While this is a powerful technique we suggest keeping it’s use to a minimum.
There’s a lot of benefit to generative, mutation, and fuzzy testing. We just
aren’t convinced it should be the default when you generate unit / general
integration test data.

Self Documenting Attributes

Factory templates may use the special symbols :optional and :required as a
means of self documenting attributes. These are meant as descriptive
placeholders for developers reading the factory definition. Any template
attribute with a value of :optional, which is not overwritten by a custom
value, will be removed just prior to building a new instance.

Those attributes marked as :required will not be removed. Instead the symbol
:required will be set as the attribute’s value if it isn’t overwritten by the
custom data. This way, if it’s considered an invalid, it will helpfully produce
a more descriptive error message. And if it’s considered a valid value, will
provide some contextual information when used else where.

For Rails projects, we suggest using :required for any association that is
necessary for the object to be valid. We do not recommend attempting to
generate default records within the factory as this can lead to unexpected
database state; and hide relevant information away from the specs which may
depend on it.

“Safe” Attribute Duplication

In an effort to help limit accidental state leak between instances the factory
will duplicate all non-frozen template values prior to building the instance.
Duplication is only applied to the values registered for the templates. Custom
values provided when building the instance are not duplicated.

Usage

There are multiple ways you can build object instances using the model factory.
Which method you choose depends on how much perceived magic/syntactic sugar you
want:

  • Call the model factory directly to instantiate instances:

    require 'radius/spec/model_factory'
    
    Radius::Spec::ModelFactory.define_factory "AnyClass", name: "Any Name"
    
    AnyClass = Struct.new(:name, keyword_init: true)
    
    default_instance = Radius::Spec::ModelFactory.build("AnyClass")
    # => #
    
    default_instance.name
    # => "Any Name"
    
    custom_instance = Radius::Spec::ModelFactory.build(
      :AnyClass,
      name: "Any Custom Name",
    )
    # => #
    
    custom_instance.name
    # => "Any Custom Name"
    
  • Include the factory helper methods explicitly:

    require 'radius/spec/model_factory'
    
    RSpec.describe AnyClass do
      include Radius::Spec::ModelFactory
    
      it "includes the factory helpers" do
        an_object = build(AnyClass)
        expect(an_object.name).to eq "Any Name"
      end
    end
    
  • Include the factory helpers via metadata:

    RSpec.describe AnyClass, :model_factory do
      it "includes the factory helpers" do
        an_object = build("AnyClass")
        expect(an_object.name).to eq "Any Name"
      end
    end
    

    When using this metadata option you do not need to explicitly require the
    model factory feature. This gem registers metadata with the RSpec
    configuration when it loads and RSpec is defined. When the metadata is
    first used it will automatically require the model factory feature and
    include the helpers.

    Any of following metadata will include the factory helpers:

    • :model_factory
    • :model_factories
    • type: :controller
    • type: :feature
    • type: :job
    • type: :model
    • type: :request
    • type: :system

There are a few behaviors to note for using the builder:

  • the class constant or fully qualified class name as a string (or symbol)
    may be provided to the builder

    This mirrors how defining the factory behaves.

  • custom attribute values provided to the builder will replace any of the
    registered defaults in the template

  • new attributes not defined in the template may be included in the custom
    attributes

    These new attributes will be included with the other attributes and passed
    to new.

  • unlike the registered template attributes, all custom attributes (even
    those that replace the registered attributes) are not modified or
    duplicated in any way

    This means if you provide an array or hash as an attribute value those
    exact instances will be sent to new. Additionally, if you provide a
    Proc as an attribute value it will be sent to new directly without
    receiving call.

Optional Block

Both build and build! support providing an optional block. This block is
passed directly to new when creating the object. This is to support the
common Ruby idiom of yielding self within initialize:

class AnyClass
  def initialize(attrs = {})
    # setup attrs
    yield self if block_given?
  end
end

RSpec.describe AnyClass, :model_factory do
  it "passes the block to the object initializer" do
    block_capture = nil
    an_object = build("AnyClass") { |instance| block_capture = instance }
    expect(block_capture).to be an_object
  end
end

Since Ruby always supports passing a block to a method, even if the method does
not use the block, it’s possible the block will not run if the class being
instantiated does not do anything with it.

Also, while the common idiom is to yield self classes are free to yield
anything. You need to be aware of how the class normally behaves when using
this feature.

“Creating” Instances

We suggest that you create instances using the following syntax:

let(:an_instance) { build("AnyClass") }

before do
  an_instance.save!
end

Or alternatively:

created_instance = build("AnyClass")
created_instance.save!

This way it is explicit what objects need to be persisted and in what order.

This can get tedious at times, especially for those who only need to create an
object to embed as an attribute of another object:

collaborator = build("AnotherClass")
collaborator.save!

# collaborator is not used against directly after this line
created_instance = build("AnyClass", collaborator: collaborator)
created_instance.save!

For these cases the build! helper is available. This is simply an alias for
build.tap(&:save!), but it supports omitting the save! call for objects
which do not support it. While it provides a safety guarantee that save! will
be called (instead of potentially save) it is less explicit.

created_instance = build("AnyClass", collaborator: build!("AnotherClass"))
created_instance.save!

We still discourage the use of build! directly in let blocks for all of the
above mentioned reasons.

Legacy “Creating” Instances

Many of our existing projects use a legacy create helper. This is simply an
alias for build!. It is provided only for backwards compatibility support and
will be removed in a future release. New code should not use this method.

created_instance = create("AnyClass")

Negated Matchers

This gem defines the following negated matchers
to allow for use composing matchers
and with compound expectations.

Matcher Inverse Of
exclude include
excluding including
not_eq eq
not_change change
not_raise_error raise_error
not_raise_exception raise_exception

Composing Matchers

There is no equivalent of not_to for composed matchers when only a subset of
the values needs to be negated. The negated matchers allow this type of fine
grain comparison:

x = [1, 2, :value]
expect(x).to contain_exactly(be_odd, be_even, not_eq(:target))

This also works for verifying / stubbing a message with argument constraints:

allow(obj).to receive(:meth).with(1, 2, not_eq(5))
obj.meth(1, 2, 3)
expect(obj).to have_received(:meth).with(not_eq(2), 2, 3)

This is great for verifying option hashes:

expect(obj).to have_received(:meth).with(
  some_value,
  excluding(:some_opt, :another_opt),
)

Compound Negated Matchers

Normally it’s not possible to chain to a negative match:

a = b = 0
expect {
  a = 1
}.not_to change {
  b
}.from(0).and change {
  a
}.to(1)

Fails with:

NotImplementedError:
expect(...).not_to matcher.and matcher is not supported, since it creates
a bit of an ambiguity. Instead, define negated versions of whatever
matchers you wish to negate with RSpec::Matchers.define_negated_matcher
and use expect(...).to matcher.and matcher.

Per the error the negated matcher allows for the following:

a = b = 0
expect {
  a = 1
}.to change {
  a
}.to(1).and not_change {
  b
}.from(0)

Similarly, complex expectations can be set on lists:

a = %i[red blue green]
expect(a).to include(:red).and exclude(:yellow)
expect(a).to exclude(:yellow).and include(:red)

Working with Temp Files

These helpers are meant to ease the creation of temporary files to either stub
the data out or provide a location for data to be saved then verified.

In the case of file stubs, using these helpers allows you to co-locate the file
data with the specs. This makes it easy for someone to read the spec and
understand the test case; instead of having to find a fixture file and look at
its data. This also makes it easy to change the data between specs, allowing
them to focus on just what they need.

Usage

There are multiple ways you can use these helpers. Which method you choose
depends on how much perceived magic/syntactic sugar you want:

  • Call the helpers directly on the module:

    require 'radius/spec/tempfile'
    
    def write_hello_world(filepath)
      File.write filepath, "Hello World"
    end
    
    Radius::Spec::Tempfile.using_tempfile do |pathname|
      write_hello_world pathname
      File.read(pathname)
      # => "Hello World"
    end
    
  • Include the helper methods explicitly:

    require 'radius/spec/tempfile'
    
    RSpec.describe AnyClass do
      include Radius::Spec::Tempfile
    
      it "includes the file helpers" do
        using_tempfile do |pathname|
          code_under_test pathname
          expect(pathname.read).to eq "Any written data"
        end
      end
    end
    
  • Include the helper methods via metadata:

    RSpec.describe AnyClass do
      it "includes the file helpers", :tempfile do
        using_tempfile do |pathname|
          code_under_test pathname
          expect(pathname.read).to eq "Any written data"
        end
      end
    end
    

    When using this metadata option you do not need to explicitly require the
    tempfile feature. This gem registers metadata with the RSpec configuration
    when it loads and RSpec is defined. When the metadata is first used it
    will automatically require the tempfile feature and include the helpers.

    Any of following metadata will include the factory helpers:

    • :tempfile
    • :tmpfile

There are a few additional behaviors to note:

  • Data can be stubbed by the helper through the data keyword arg:

    stub_data = "Any file stub data text."
    Radius::Spec::Tempfile.using_tempfile(data: stub_data) do |stubpath|
      File.read(stubpath)
      # => "Any file stub data text."
    end
    

    It can even be inlined using heredocs:

    Radius::Spec::Tempfile.using_tempfile(data: <<~TEXT) do |stubpath|
      Any file stub data text.
    TEXT
      # Yard formats heredoc args oddly
      File.read(stubpath)
      # => "Any file stub data text.\n"
    end
    

    > NOTE: That when inlining like this heredocs add an extra new line. To
    > remove it use .chomp on the kwarg:
    >
    > ruby
    > using_tempfile(data: <<~TEXT.chomp) do |pathname|
    > This has no newline.
    > TEXT
    > # ...
    > end
    >

  • Additional arguments and options are forwarded directly to
    Tempfile.create

    This allows you to set custom file extensions:

    Radius::Spec::Tempfile.using_tempfile(%w[custom_name .myext]) do |pathname|
      pathname.extname
      # => ".myext"
    end
    

    Or change the file encoding:

    Radius::Spec::Tempfile.using_tempfile(encoding: "ISO-8859-1", data: <<~DATA) do |pathname|
      Résumé
    DATA
      # Yard formats heredoc args oddly
      File.read(pathname)
      # => "R\xE9sum\xE9\n"
    end
    

Common VCR Configuration

A project must include both vcr and
webmock to use this configuration.
Neither of those gems will be installed as dependencies of this gem. This is
intended to give projects more flexibility in choosing which additional features
they will use.

The main radius/spec/rspec setup will load the common VCR configuration
automatically when a spec is tagged with the :vcr metadata. This will
configure VCR to:

  • save specs to /spec/cassettes

  • use record mode once when a single spec or spec file is run

    This helps ease the development of new specs without requiring any
    configuration / setting changes.

  • uses record mode none otherwise, along setting VCR to fail when unused
    interactions remain in a cassette

    This is intended to better alert developers to unexpected side effects of
    changes as any addition or removal of a request will cause a failure.

  • all Authorization HTTP headers are filtered by default

    This is a common oversight when recording specs. Often token based
    authentication is picked up by the other filtered environment settings, but
    basic authentication is not. Additionally, certain types of digest
    authentication may cause specs to leak state. This filtering guards all of
    these cases from accidental credential leak.

  • the following common sensitive, or often environment variable, settings are
    filtered

    Those settings which often change between developer machines, and even the
    CI server, can cause for flaky specs. It may also be frustrating for
    developers to have to adjust their local systems to match others just to
    get a few specs to pass. This is intended to help mitigate those issues:

    • AWS_ACCESS_KEY_ID
    • AWS_SECRET_ACCESS_KEY
    • GOOGLE_CLIENT_ID
    • GOOGLE_CLIENT_SECRET
    • RADIUS_OAUTH_PROVIDER_APP_ID
    • RADIUS_OAUTH_PROVIDER_APP_SECRET
    • RADIUS_OAUTH_PROVIDER_URL
  • a project’s local support/vcr.rb file will be loaded after the common
    VCR configuration loads; if it’s available

    This allows projects to overwrite common settings if they need to, as well,
    as add on addition settings or filtering of data.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run
rake spec to run the tests. You can also run bin/console for an interactive
prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

To release a new version:

  • Update the CHANGELOG file with auto-generated release notes from Github (create the next tag in Github releases)
  • Update the version number in version.rb
  • Commit these changes and push up a branch. Get it approved.
  • From the updated main branch, run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.
  • Create a Github release using the tag you created in the first step, and mark it as the latest.

Contributing

Bug reports and pull requests are welcome on GitHub at
https://github.com/RadiusNetworks/radius-spec. This project is intended to be a
safe, welcoming space for collaboration, and contributors are expected to
adhere to the Contributor Covenant code of
conduct.

Code of Conduct

Everyone interacting in the Radius::Spec project’s codebases, issue trackers,
chat rooms and mailing lists is expected to follow the code of
conduct
.