A workshop on Ruby Testing Craftsmanship
Testing has been a feature of the Ruby community for a long time. Why then are our spec files often so incomprehensible? In this workshop, I will share some ground rules for writing maintainable tests that will ensure that new teammates along with future-you can understand your test suite. We will use the RSpec testing framework to introduce several testing code-smells. For each smell, I will provide a demonstration on how to refactor the test along with time to practice for workshop participants. This workshop is geared towards anyone looking to hone their Ruby testing craft.
Fork this repository, clone your forked repository, change directories to the project's root, and bundle install.
> git clone https://github.com/jesse-spevack/clean_rspec.git
> cd clean_rspec
> bundle install
We'll be sharing our work, so create your own git branch.
> git checkout -b $firstName
Next, create a pull request to share your branch. I recommend the github cli, which can be installed with brew install gh
.
gh pr create --title "<First Name> Clean RSpec"
Complete the Welcome Survey.
Optionally, create your own Bingo board to help you follow along!
Run tests with the rspec
command. See documentation.
> rspec
Finished in 0.01692 seconds (files took 0.1944 seconds to load)
33 examples, 0 failures, 6 pending
This workshop is based off of the Gilded Rose Refactoring Kata.
We are using a Ruby translation following the style from the amazing Sandi Metz 2014 Railsconf talk, All the Little Things.
The name for this workshop is a reference to Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin, which describes the techniques and practices of writing code that is easy to read and change.
The goal of this workshop is to take some of the principles from Clean Code and apply them to writing tests with RSpec.
Participants will hone their understanding of writing clean tests by refactoring an example RSpec file. Through this exercise participants will be able to:
- Describe the purpose and benefits of testing
- Understand the concept of object under test
- Write tests that document code functionality
- Implement the three-phase test pattern
- Optimize for readability
- Use test doubles judiciously
Topic | Time |
---|---|
Introduction | 5 |
Learning Goals | 5 |
Why Testing | 5 |
Unit vs Integration Tests | 5 |
Query vs Command | 5 |
Object Under Test | 10 |
Describe, Context, It | 10 |
3 Phases: Arrange | 10 |
3 Phases: Act & Assert | 10 |
Shared Examples | 10 |
Test Doubles | 10 |
Integration tests touch one end of a system and measure an output to assert that all the layers between the input and output are working. Filling out a form, clicking submit, and asserting that a widget is created in the database is an integration test.
Unit tests test one object or one method. Objects are black boxes with limited information. Unit tests should test return values, side effects, critical interactions, but not implementation details.
Query - returns something, but changes nothing.
def workshop_count
@participants.count
end
Command - returns nothing, but changes something.
def enroll(participant)
@participants << participant
end
Query | Command | |
---|---|---|
Incoming | Test return value | Test side effect |
Private | Do not test | Do not test |
Outgoing | Do not test | Test message sent |
Object under test is the black box we are testing.
The subject keyword can be used to identify the system under test. Stackoverflow on subject
.
class Workshop
# ...
end
# Bad
describe Workshop do
it 'is instantiated by RSpec' do
expect(subject).to be_a Workshop
end
end
# Less Bad
describe Workshop do
subject { Workshop.new }
it 'is instantiated by RSpec' do
expect(subject).to be_a Workshop
end
end
# Good
describe Workshop do
subject(:workshop) { Workshop.new }
it 'is instantiated by RSpec' do
expect(workshop).to be_a Workshop
end
end
Open gilded_rose_spec.rb
. Improve the first test on line 7.
Commit your change.
git commit -m "object under test with subject keyword"
git push
If you have time, compare your work with other participants' pull requests.
Optimize for readability with RSpec documentation methods, describe
, context
, & it
.
The describe
method creates an example group. I recommend one describe block for each public method that the object under test implements.
# Bad
RSpec.describe Workshop do
# ...
end
# Good - use a `#` for instance methods
RSpec.describe Workshop do
describe '#enroll' do
# ...
end
end
# Good - use a `.` for class methods (thanks Silas!)
RSpec.describe Workshop do
describe '.create' do
# ...
end
end
Example groups can have examples.
# Bad
RSpec.describe Workshop do
describe '#enroll' do
it 'enrolls' do
# ...
end
end
end
# Good
RSpec.describe Workshop do
describe '#enrolls' do
it 'adds participant to workshop' do
# ...
end
end
end
Example groups can have contexts with specific examples.
# Bad
RSpec.describe Workshop do
describe '#enroll' do
it 'adds participant to workshop when there is room' do
# ...
end
it 'does not add participant to workshop when there is not room' do
# ...
end
end
end
# Good
RSpec.describe Workshop do
describe '#enroll' do
context 'when there is room' do
it 'adds participant to workshop' do
# ...
end
end
context 'when there is NOT room' do
it 'does not add participant to workshop' do
# ...
end
end
end
end
Open gilded_rose_spec.rb
. Improve the test on line 11.
Commit your change.
git commit -m "describe, context, it"
git push
If you have time, compare your work with other participants' pull requests.
Tests should have three phases: arrange, act, assert.
# Lazy-evaluated
let(:gilded_rose) { GildedRose.new(name: 'Normal Item', days_remaining: 5, quality: 10) }
# Invoked before each example
let!(:gilded_rose) { GildedRose.new(name: 'Normal Item', days_remaining: 5, quality: 10) }
# Invoked before each example
before { gilded_rose.tick }
Setup the objects necessary for the test.
# Bad
describe '#enroll' do
context 'when there is room' do
it 'adds participant to workshop' do
pr = Participant.new(name: 'Jesse')
pr2 = Participant.new(name: 'Sandi')
# ...
end
end
end
# Less Bad
describe '#enroll' do
context 'when there is room' do
it 'adds participant to workshop' do
pr = Participant.new(name: 'Jesse')
# ...
end
end
end
# Good
describe '#enroll' do
context 'when there is room' do
let(:workshop) { Workshop.new(capacity: 1) }
it 'adds participant to workshop' do
# ...
end
end
end
# Best
describe '#enroll' do
subject(:workshop) { Workshop.new(capacity: capacity) }
context 'when given a normal item' do
let(:capacity) { 1 }
it 'adds participant to workshop' do
# ...
end
end
end
Open gilded_rose_spec.rb
. Add the arrange step the test on line 11.
Commit your change.
git commit -m "arrange"
git push
If you have time, compare your work with other participants' pull requests.
Invoke the action that is being tested.
describe '#enroll' do
# ...
context 'when workshop has available seats' do
# ...
it 'adds participant' do
workshop.enroll(participant)
end
end
end
Check the result of the action.
# Bad
subject(:workshop) { Workshop.new(seats: 15) }
let(:participant) { Participant.new('Jesse') }
it 'enrolls when there is room for participant' do
expect(workshop).to be_instance_of(Workshop)
workshop.enroll(participant)
expect(workshop.participants.empty?).to eq false
expect(workshop.participants.count).to eq 1
end
# Good
it 'adds participant' do
workshop.enroll(participant)
expect(workshop.participants.empty?).to eq false
expect(workshop.participants.count).to eq 1
end
# Even Better
it 'adds participant' do
workshop.enroll(participant)
expect(workshop.participants.count).to eq 1
end
Open gilded_rose_spec.rb
. Add the act and assert steps to the test on line 11.
Commit your change.
git commit -m "act and assert"
git push
If you have time, compare your work with other participants' pull requests.
Shared examples are optimized for the test writer. We spend far more time reading code than writing it, therefore tools that help us write code at the expense of making the code we write harder to read should be used with caution.
# Bad
shared_examples :workshop do |seats, participant|
it 'enrolls' do
workshop = Workshop.new(seats: seats)
workshop.enroll(participant)
expect(workshop.headcount).to eq 1
end
end
it_behaves_like :workshop, 10, Participant.new('Jesse')
# Good
describe '#enroll' do
subject(:workshop) { Workshop.new(capacity: capacity) }
let(:participant) { Participant.new('Jesse') }
context 'when workshop has available seats' do
let(:capacity) { 1 }
it 'adds participant' do
workshop.enroll(participant)
end
end
end
Open gilded_rose_spec.rb
. Remove the shared example on line 20. Rewrite the tests starting on line 58, but optimize for readability.
Commit your change.
git commit -m "shared examples, hard pass"
git push
Justin Searls gave a talk called Breaking up (with) your test suite. Please watch this talk.
Test Doubles should be used for two reasons:
- Testing a critical message is sent (e.g. we call
notify
, or request from an external api) - Discovery Testing / Top Down Testing (see Breaking up with your test suite)
Test Doubles should never be used to:
- Mock / Stub the Object under test.
# Bad
it 'notifies' do
expect(workshop).to receive(:notify)
workshop.enroll(participant)
end
# Better
it 'calls messenger to notify' do
expect_any_instance_of(Messenger).to receive(:notify)
workshop.enroll(participant)
end
# Best
it 'calls messenger to notify' do
messenger = double
expect(Messenger).to receive(:new).with(participant).and_return(messenger)
expect(messenger).to receive(:notify)
workshop.enroll(participant)
end