How RSpec works
In order to understand how the RSpec integration in SuperDiff works, it’s important to study the pieces in play within RSpec itself.
Context
Imagine a file such as the following:
# spec/some_spec.rb
describe "Some tests" do
it "does something" do
expect([1, 2, 3]).to eq([1, 6, 3])
end
end
Then, imagine that the user runs:
rspec
Without SuperDiff activated, this will produce the following output:
Some tests
does something (FAILED - 1)
Failures:
1) Some tests does something
Failure/Error: expect([1, 2, 3]).to eq([1, 6, 3])
expected: [1, 6, 3]
got: [1, 2, 3]
(compared using ==)
# ./spec/some_spec.rb:3:in `block (2 levels) in <top (required)>'
Finished in 0.01186 seconds (files took 0.07765 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/some_spec.rb:2 # Some tests does something
Now imagine that we want to modify this output to replace the “expected:”/”actual:” lines with a diff. How would we do this?
RSpec’s cast of characters
First, we will review several concepts in RSpec: 1
- Since RSpec tests are “just Ruby”,
parts of tests map to objects
which are created when those tests are loaded.
describe
s andcontext
s are represented by example groups, instances ofRSpec::Core::ExampleGroup
, andit
s andspecify
s are represented by examples, instances ofRSpec::Core::Example
. - Most notably,
within tests themselves,
the
expect
method — mixed into tests via the syntax layer — returns an instance ofRSpec::Expectations::ExpectationTarget
, and may raise an error if the check it is performing fails. - Configuration is kept in an instance of
RSpec::Core::Configuration
, which is accessible viaRSpec.configuration
and is initialized the first time it’s used - The runner,
an instance of
RSpec::Core::Runner
, is the entrypoint to all of RSpec — it’s called directly by therspec
executable — and executes the tests the user has specified. - Formatters change RSpec’s output after running tests.
Since the user can specify one formatter when running
rspec
, the collection of registered formatters is managed by the formatter loader, an instance ofRSpec::Core::Formatters::Loader
. The default formatter is “progress”, set in the configuration object, which maps to an instance ofRSpec::Core::Formatters::ProgressFormatter
. - Notifications represent events that occur while running tests, such as “these tests failed” or “this test was skipped”.
- The reporter,
an instance of
RSpec::Core::Reporter
, acts as sort of the brain of the whole operation. Implementing a publish/subscribe model, it tracks the state of tests as they are run, including errors captured during the process, packaging key moments into notifications and delegating them to all registered formatters (or anything else listening to the reporter). Like the configuration object, it is also global, accessible via the configuration object, and is initialized the first time it’s used - The exception presenter,
an instance of
RSpec::Core::Formatters::ExceptionPresenter
, is a special type of formatter which does not respond to events, but is rather responsible for managing all of the logic involved in building all of the output that appears when a test fails.
What RSpec does
Given the above, RSpec performs the following sequence of events:
- The developer adds an failing assertion to a test using the following forms
(filling in
<actual value>
,<matcher>
,<block>
, and<args...>
appropriately):expect(<actual value>).to <matcher>(<args...>)
expect { <block> }.to <matcher>(<args...>)
expect(<actual value>).not_to <matcher>(<args...>)
expect { <block> }.not_to <matcher>(<args...>)
- The developer runs the test using the
rspec
executable. - The
rspec
executable callsRSpec::Core::Runner.invoke
. - Skipping a few steps,
RSpec::Core::Runner#run_specs
is called, which runs all tests by surrounding them in a call toRSpec::Core::Reporter#report
. - Skipping a few more steps,
RSpec::Core::Example#run
is called to run the current example. - From here one of two paths is followed
depending on whether the assertion is positive (
.to
) or negative (.not_to
).- If the assertion is positive:
- Within the test,
after
expect
is called to build aRSpec::Expectations::ExpectationTarget
, theto
method callsRSpec::Expectations::PositiveExpectationHandler.handle_matcher
. - The matcher is then used to know
whether the assertion passes or fails:
PositiveExpectationHandler
calls thematches?
method on the matcher. - Assuming that
matches?
returns false,PositiveExpectationHandler
then callsRSpec::Expectations::ExpectationHelper.handle_failure
, telling it to get the positive failure message from the matcher by callingfailure_message
.
- Within the test,
after
- If the assertion is negative:
- Within the test,
after
expect
is called to build aRSpec::Expectations::ExpectationTarget
, thenot_to
method callsRSpec::Expectations::NegativeExpectationHandler.handle_matcher
. - The matcher is then used to know
whether the assertion passes or fails:
NegativeExpectationHandler
, calls thedoes_not_match?
method on the matcher. - Assuming that
does_not_match?
returns false,NegativeExpectationHandler
then [callsRSpec::Expectations::ExpectationHelper.handle_failure
][viaNegativeExpectationHandler
]rspec-expectation-helper-handle-failure-call-negative, telling it to get the negative failure message from the matcher by callingfailure_message_when_negated
.
- If the assertion is positive:
RSpec::Expectations::ExpectationHelper.handle_failure
callsRSpec::Expectations.fail_with
.RSpec::Expectations.fail_with
creates a diff usingRSpec::Matchers::MultiMatcherDiff
, wraps it in an exception, and feeds the exception toRSpec::Support.notify_failure
.RSpec::Support.notify_failure
calls the currently set failure notifier, which by default raises the given exception.- Returning to
RSpec::Core::Example#run
, this method rescues the exception and then callsfinish
, which callsexample_failed
on the reporter. RSpec::Core::Reporter#example_failed
usesRSpec::Core::Notifications::ExampleNotification.for
to construct a notification, which in this case is anRSpec::Core::Notifications::FailedExampleNotification
.RSpec::Core::Notifications::FailedExampleNotification
in turn constructs anRSpec::Core::Formatters::ExceptionPresenter
.RSpec::Core::Reporter#example_failed
then passes the notification object along with an event of:example_failed
to thenotify
method. BecauseRSpec::Core::Formatters::ProgressFormatter
is a listener on the reporter, itsexample_failed
method gets called, which prints a messageFailure:
to the terminal.- Returning to
RSpec::Core::Reporter#report
, it now callsfinish
after all tests are run. RSpec::Core::Reporter#finish
notifies listeners of the:dump_failures
event, this time using an instance ofRSpec::Core::Notifications::ExamplesNotification
. Again, becauseRSpec::Core::Formatters::ProgressFormatter
is registered, itsdump_failures
method is called, which is actually defined inRSpec::Core::Formatters::BaseTextFormatter
.RSpec::Core::Formatters::BaseTextFormatter#dump_failures
callsRSpec::Core::Notifications::ExamplesNotification#fully_formatted_failed_examples
.RSpec::Core::Notifications::ExamplesNotification#fully_formatted_failed_examples
formats all of the failed examples by wrapping them inRSpec::Core::Notifications::FailedExampleNotification
s and callingfully_formatted
on them.RSpec::Core::Notifications::FailedExampleNotification#fully_formatted
then callsfully_formatted
on itsRSpec::Core::Formatters::ExceptionPresenter
.RSpec::Core::Formatters::ExceptionPresenter#fully_formatted
then constructs various pieces of what will eventually be printed to the terminal, including the name of the test, the line that failed, the error and backtrace, and other pertinent details.
-
Note that the analysis of the RSpec source code in this document is accurate as of RSpec v3.13.0, released February 4, 2024. ↩