Time-resilient expectations in RSpec
Timing is hard.
Timing problems and race conditions can plague your test suite. As your test suite slowly becomes less reliable, development speed and quality suffer.
RSpec::Wait strives to make it easier to test asynchronous or slow interactions.
RSpec::Wait allows you to wait for an assertion to pass, using the RSpec syntactic sugar that you already know and love.
RSpec::Wait will keep trying until your assertion passes or times out.
RSpec::Wait's wait_for
assertions are nearly drop-in replacements for RSpec's
expect
assertions. The major difference is that the wait_for
method
requires a block because it may need to evaluate the content of that block
multiple times while it's waiting.
RSpec.describe Ticker do
subject(:ticker) { Ticker.new("foo") }
describe "#start" do
before do
ticker.start
end
it "starts with a blank tape" do
expect(ticker.tape).to eq("")
end
it "sends the message in Morse code one letter at a time" do
wait_for { ticker.tape }.to eq("··-·")
wait_for { ticker.tape }.to eq("··-· ---")
wait_for { ticker.tape }.to eq("··-· --- ---")
end
end
end
RSpec::Wait can be especially useful for testing user interfaces with tricky timing elements like JavaScript interactions or remote requests.
feature "User Login" do
let!(:user) { create(:user, email: "john@example.com", password: "secret") }
scenario "A user can log in successfully" do
visit new_session_path
fill_in "Email", with: "john@example.com"
fill_in "Password", with: "secret"
click_button "Log In"
wait_for { current_path }.to eq(account_path)
expect(page).to have_content("Welcome back!")
end
end
RSpec::Wait is tested against all non-EOL Ruby versions, which as of this writing are versions 3.1, 3.2, and 3.3. If you find that RSpec::Wait does not work or is not tested for a maintained Ruby version, please open an issue or pull request to add support.
Additionally, RSpec::Wait is tested against Ruby head to surface future compatibility issues, but no guarantees are made that RSpec::Wait will function as expected on Ruby head. Proceed with caution!
RSpec::Wait is tested against several versions of RSpec, which as of this writing are versions 3.4 through 3.13. If you find that RSpec::Wait does not work or is not tested for a newer RSpec version, please open an issue or pull request to add support.
Additionally, RSpec::Wait is tested against unbounded RSpec to surface future compatibility issues, but no guarantees are made that RSpec::Wait will function as expected on any RSpec version that's not explicitly tested. Proceed with caution!
RSpec::Wait ties into RSpec's internals so it can take full advantage of any
matcher that you would use with RSpec's own expect
method.
If you discover a matcher that works with expect
but not with wait_for
,
please open an issue
and I'd be happy to take a look!
To get started with RSpec::Wait, simply add the dependency to your Gemfile
and bundle install
:
gem "rspec-wait", "~> 1.0"
If your codebase calls Bundler.require
at boot time, you're all set and the
wait_for
method is already available in your RSpec suite.
If you encounter the following error:
NoMethodError:
undefined method `wait_for'
You will need to explicitly require RSpec::Wait at boot time in your test environment:
require "rspec/wait"
RSpec::Wait v1 is very similar in syntax to v0 but does have a few breaking changes that you should be aware of when upgrading from any 0.x version:
- RSpec::Wait v1 requires Ruby 3.0 or greater and RSpec 3.4 or greater.
- The
wait_for
andwait.for
methods no longer accept arguments, only blocks. - RSpec::Wait no longer uses Ruby's problematic
Timeout.timeout
method, which means it will no longer raise aRSpec::Wait::TimeoutError
. RSpec::Wait v1 never interrupts the block given towait_for
mid-call so make every effort to reasonably limit the block's individual call time.
RSpec::Wait has three available configuration values:
wait_timeout
- The maximum amount of time (in seconds) that RSpec::Wait will continue to retry a failing assertion. Default:10.0
wait_delay
- How long (in seconds) RSpec::Wait will pause between retries. Default:0.1
clone_wait_matcher
- Whether each retry willclone
the given RSpec matcher instance for each evaluation. Set totrue
if you have trouble with a matcher holding onto stale state. Default:false
RSpec::Wait configurations can be set in three ways:
- Globally via
RSpec.configure
- Per example or context via RSpec metadata
- Per assertion via the
wait
method
RSpec.configure do |config|
config.wait_timeout = 3 # seconds
config.wait_delay = 0.5 # seconds
config.clone_wait_matcher = true
end
Any of RSpec::Wait's three configurations can be set on a per-example or
per-context basis using wait
metadata. Provide a hash containing any
number of shorthand keys and values for RSpec::Wait's configurations.
scenario "A user can log in successfully", wait: { timeout: 3, delay: 0.5, clone_matcher: true } do
visit new_session_path
fill_in "Email", with: "john@example.com"
fill_in "Password", with: "secret"
click_button "Log In"
wait_for { current_path }.to eq(account_path)
expect(page).to have_content("Welcome back!")
end
And on a per-assertion basis, the wait
method accepts a hash of shorthand
keys and values for RSpec::Wait's configurations. The wait
method must be
chained to the for
method and aside from the ability to set RSpec::Wait
configuration for the single assertion, it behaves identically to wait_for
.
scenario "A user can log in successfully" do
visit new_session_path
fill_in "Email", with: "john@example.com"
fill_in "Password", with: "secret"
click_button "Log In"
wait(timeout: 3).for { current_path }.to eq(account_path)
expect(page).to have_content("Welcome back!")
end
The wait
method will also accept timeout
as a positional argument for
improved readability:
wait(3.seconds).for { current_path }.to eq(account_path)
If you use rubocop
and rubocop-rspec
in your codebase, an RSpec example
with a single wait_for
assertion may cause RuboCop to complain:
RSpec/NoExpectationExample: No expectation found in this example.
By default, RuboCop sees only expect*
and assert*
methods as expectations.
You can configure RuboCop to recognize wait_for
and wait.for
as
expectations (in addition to the defaults) in your RuboCop configuration:
RSpec/NoExpectationExample:
AllowedPatterns:
- ^assert_
- ^expect_
- ^wait(_for)?$
Of course, you can always disable this cop entirely:
RSpec/NoExpectationExample:
Enabled: false
To enable RSpec::Wait in your Cucumber step definitions, add the following to
features/support/env.rb
:
require "rspec/wait"
World(RSpec::Wait)
My name is Steve Richert and I wrote RSpec::Wait in April, 2014 with the support of my employer, Collective Idea. RSpec::Wait owes its current and future success entirely to inspiration and contribution from the Ruby community, especially the authors and maintainers of RSpec.
Thank you! 💛
RSpec::Wait is open source and contributions from the community are encouraged! No contribution is too small.
See RSpec::Wait's contribution guidelines for more information.
If you're enjoying RSpec::Wait, please consider sponsoring my open source work! 💚