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.
describes andcontexts are represented by example groups, instances ofRSpec::Core::ExampleGroup, andits andspecifys are represented by examples, instances ofRSpec::Core::Example. - Most notably,
within tests themselves,
the
expectmethod — 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.configurationand 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 therspecexecutable — 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
rspecexecutable. - The
rspecexecutable callsRSpec::Core::Runner.invoke. - Skipping a few steps,
RSpec::Core::Runner#run_specsis called, which runs all tests by surrounding them in a call toRSpec::Core::Reporter#report. - Skipping a few more steps,
RSpec::Core::Example#runis 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
expectis called to build aRSpec::Expectations::ExpectationTarget, thetomethod callsRSpec::Expectations::PositiveExpectationHandler.handle_matcher. - The matcher is then used to know
whether the assertion passes or fails:
PositiveExpectationHandlercalls thematches?method on the matcher. - Assuming that
matches?returns false,PositiveExpectationHandlerthen 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
expectis called to build aRSpec::Expectations::ExpectationTarget, thenot_tomethod 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,NegativeExpectationHandlerthen [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_failurecallsRSpec::Expectations.fail_with.RSpec::Expectations.fail_withcreates a diff usingRSpec::Matchers::MultiMatcherDiff, wraps it in an exception, and feeds the exception toRSpec::Support.notify_failure.RSpec::Support.notify_failurecalls 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_failedon the reporter. RSpec::Core::Reporter#example_failedusesRSpec::Core::Notifications::ExampleNotification.forto construct a notification, which in this case is anRSpec::Core::Notifications::FailedExampleNotification.RSpec::Core::Notifications::FailedExampleNotificationin turn constructs anRSpec::Core::Formatters::ExceptionPresenter.RSpec::Core::Reporter#example_failedthen passes the notification object along with an event of:example_failedto thenotifymethod. BecauseRSpec::Core::Formatters::ProgressFormatteris a listener on the reporter, itsexample_failedmethod gets called, which prints a messageFailure:to the terminal.- Returning to
RSpec::Core::Reporter#report, it now callsfinishafter all tests are run. RSpec::Core::Reporter#finishnotifies listeners of the:dump_failuresevent, this time using an instance ofRSpec::Core::Notifications::ExamplesNotification. Again, becauseRSpec::Core::Formatters::ProgressFormatteris registered, itsdump_failuresmethod is called, which is actually defined inRSpec::Core::Formatters::BaseTextFormatter.RSpec::Core::Formatters::BaseTextFormatter#dump_failurescallsRSpec::Core::Notifications::ExamplesNotification#fully_formatted_failed_examples.RSpec::Core::Notifications::ExamplesNotification#fully_formatted_failed_examplesformats all of the failed examples by wrapping them inRSpec::Core::Notifications::FailedExampleNotifications and callingfully_formattedon them.RSpec::Core::Notifications::FailedExampleNotification#fully_formattedthen callsfully_formattedon itsRSpec::Core::Formatters::ExceptionPresenter.RSpec::Core::Formatters::ExceptionPresenter#fully_formattedthen 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. ↩