You know the RSpec basics. This lesson digs deeper: better matchers, custom matchers, DRY specs, mocking, and request specs.
Important
Work on this lesson in
advisormode.
Learning goals
- You can explain why an expressive matcher beats asserting on a boolean: the failure message tells you what went wrong, and the spec reads like a requirement.
- You can write a custom matcher with a helpful failure message, and you know where such support code lives in a project.
- You can structure specs with nested example groups,
let,subjectandbefore/afterhooks, and share tests between similar classes with shared example groups. - You can explain the trade-off between DRY specs and self-contained examples, and when shared setup is worth its coupling.
- You can write end-to-end specs that read like the user's story, with repeated steps extracted into shared helpers instead of copied.
- You can explain what mocking is, use test doubles to take control of a collaborator, and name a case where a mocked test passes although the code is broken β and the reverse.
- You know that setting a message expectation replaces the original method: the real code no longer runs unless you ask for it (
and_call_original). - You can explain which spec types rspec-rails offers besides model and feature specs, and write a request spec or a helper spec where a Rails component deserves a closer look.
Resources
Read what's new to you, skim what's familiar, skip what you already master. Stop when you can meet the learning goals.
Your agent can also generate an overview, a tutorial or an explanation for anything here, tailored to what you already know. Just ask.
Matchers
- π Built-in matchers Show archive.org snapshot β get an overview and play with a few in your MovieDB specs
- π
Defining a custom matcher
Show archive.org snapshot
β the
RSpec::Matchers.defineDSL, failure messages, chaining - π RSpec: Where to put custom matchers and other support code β our card
- π RSpec: Composing a custom matcher from existing matchers β our card
Structuring and sharing specs
- π Everyday Rails Testing with RSpec Show archive.org snapshot (in our library Show archive.org snapshot ) β the chapter on keeping specs DRY (chapter numbers differ between editions; the current Leanpub edition targets Rails 8.1)
- π
rspec-core: shared examples
Show archive.org snapshot
β
shared_examplesandit_behaves_like - π rspec-core: user-defined metadata Show archive.org snapshot and our card on spec-type specific setup β hooks that run only for some specs
- π Testing shared traits or modules without repeating yourself β our card
- π
A quest for better specs: prefer self-contained examples
Show archive.org snapshot
β the argument against sharing setup with
let, even at the cost of some duplication (see "DRY vs. coupling" below) - π Better Specs Show archive.org snapshot β common RSpec conventions; read it as a menu of habits, not as rules (its "one expectation per example" advice is often overkill)
- π Rails Guide: Active Record Callbacks Show archive.org snapshot β background for the callback exercise
Mocking
Mocking takes control of a collaborator so you can test the code around it in isolation.
- π rspec-mocks: test doubles Show archive.org snapshot β the basics
- π Setting constraints Show archive.org snapshot and configuring responses Show archive.org snapshot β what a double should receive and what it returns
- π
Calling the original implementation
Show archive.org snapshot
β
expect(...).to receivereplaces the real method;and_call_originalruns it anyway - π Verifying doubles in RSpec 3 β our card: doubles that fail when the real object changes
- π RSpec: argument matchers and expect one of multiple matchers to match β our cards
- π Mocking the current time β our card on
travel_toand friends - π Any instance Show archive.org snapshot and message chains Show archive.org snapshot β for legacy code only; know that they exist and why they're a smell
Spec types
So far you wrote model specs (classic unit tests) and feature specs (end-to-end). rspec-rails offers more types that let you look closely at Rails components that are awkward to instantiate β routes, views, helpers, requests.
- π rspec-rails documentation Show archive.org snapshot β skim the list of spec types; read request specs Show archive.org snapshot and helper specs Show archive.org snapshot for the exercises, and directory structure Show archive.org snapshot for where each type lives
DRY vs. coupling
Sharing test setup can lead to DRY, but tightly coupled test code. Read Prefer self-contained examples Show archive.org snapshot for an argument for isolating tests instead, even if it means some duplication. In general it is more important for a test to be simple than to be DRY.
A sweet spot is often to prefer isolated tests where possible, but share test setup when it becomes excessively complicated or expensive. If we share setup, it is best to do within a shared context only. This way so we limit the setup's scope.
describe Klass do
describe '#foo' do
it 'does basic thing 1' do
# isolated test without shared setup here
end
it 'does basic thing 2' do
# isolated test without shared setup here
end
context 'on the night of DST change in Australia' do
before :each do
# complicated, shared setup here
end
it 'handles special case 3' do
# test using the shared setup
end
it 'handles special case 4' do
# test using the shared setup
end
end
end
end
Exercises
Better matchers
Which of the following two lines is better? Why?
expect(array).to include(5)
expect(array.include?(5)).to eq(true)
Custom matcher
Write a
custom matcher
Show archive.org snapshot
called have_same_attributes_as. It should compare the attributes of two ActiveRecord instances:
movie1 = create(:movie, title: 'Foo', year: 2007, description: 'Lorem ipsum')
movie2 = create(:movie, title: 'Foo', year: 2007, description: 'Lorem ipsum')
movie3 = create(:movie, title: 'Bar', year: 2008, description: 'Lorem ipsum')
expect(movie1).to have_same_attributes_as(movie2)
# passes
expect(movie1).to have_same_attributes_as(movie3)
# Fails with 'Expected movie #112 to have same attributes as movie #113, but the attributes #title and #year differed'
Now write a method Movie#copy in your MovieDB. It saves a duplicate of the movie, copying all the attributes. Test this method using your new have_same_attributes_as matcher.
Tip
ActiveSupport gives your arrays a
#to_sentencemethod that may help you build the error message:['foo', 'bar', 'baz'].to_sentence => "foo, bar and baz" ``` Also see [Where to put custom matchers and other support code](https://makandracards.com/makandra/17775-rspec-where-to-put-custom-matchers-and-other-support-code).
Shared examples: a change log
Make the following change to your MovieDB:
-
A new tab "Changes" shows a log of recent changes made to movies and actors.
-
E.g. when a movie was created there is a log entry saying
Movie "Sunshine" was created -
E.g. when a movie was updated there is a log entry saying
Movie "Sunshine" was updated -
E.g. when an actor was destroyed there is a log entry saying
Actor "Shohreh Aghdashloo" was destroyed -
Use callbacks Show archive.org snapshot to automatically write changelog entries to the database as a model record gets created, updated or destroyed.
-
Extract the logic into a
moduleso it can be re-used by bothActorandMoviemodels.Tip
The method producing the logged identifier may differ between models, e.g.
Actor#full_namevs.Movie#title. You can either use the same method name here (like#to_sor#name_for_log), or build a parametrized module using Modularity Show archive.org snapshot . -
In your RSpec tests, use a shared example group to share tests between
actor_spec.rbandmovie_spec.rb.
Mocking the change log
Earlier in this card you implemented a change log for MovieDB. Change your RSpec tests so they no longer write log entries to the database. Instead use mocks to test that log entries would have been written with the correct attributes.
Request spec
When your MoviesController#show cannot find a movie, it currently crashes with ActiveRecord::RecordNotFound. Change that so instead of crashing, it sets a flash "Movie not found" and redirects to the movie index.
Write a
request spec
Show archive.org snapshot
that takes a close look at MoviesController#show:
- If the given ID was found, the view
movies/showis rendered - If the given ID was not found, a redirect to
/moviesis returned. The HTTP status 307 is used for the redirect. The movies index shows a flash message.
Tip
There is a
render_template()matcher that helps with test above. To get this matcher, add a gemrails-controller-testing.
Tip
If you place your spec file in
spec/requestsyou don't need thetype: :requestoption Show archive.org snapshot .
Helper spec
In the validations card we added a helper to display an error message.
Test that helper with a helper spec Show archive.org snapshot .
Discuss with your mentor
Talk to your mentor about the pros and cons of mocking:
- Can you imagine a reason why the mocking test could pass, but the code is broken?
- Can you imagine a reason why the mocking test could fail, but the code is correct?