Skip to content

jesse-spevack/clean_rspec

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Clean RSpec

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.

Getting Started

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!

Running Tests

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

References

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.

Workshop

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.

Learning Outcomes

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

Agenda

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

Unit vs. Integration Test

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

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

You Do

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.

Describe, Context, It

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

You Do

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.

Phases of test

Tests should have three phases: arrange, act, assert.

Arrange

Let, Let!, Before

# 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

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

You Do

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.

Act

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

Assert

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

You Do

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

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

You Do

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

Test Doubles

Justin Searls gave a talk called Breaking up (with) your test suite. Please watch this talk.

Test Doubles should be used for two reasons:

  1. Testing a critical message is sent (e.g. we call notify, or request from an external api)
  2. Discovery Testing / Top Down Testing (see Breaking up with your test suite)

Test Doubles should never be used to:

  1. 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

About

A workshop on Ruby Testing Craftsmanship

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages