Share
Embed
Taming Chaotic Specs: RSpec Design Patterns
Presented at RailsRemoteConf.com 2015
Don’t you hate when testing takes 3x as long because your specs are hard to understand? Or when testing conditional permutation leads to a ton of duplication?
Following a few simple patterns, you can easily take a bloated spec and make it readable, DRY and simple to extend. This talk will step through a few solid patterns to tame the chaos. We will take a bloated sample spec and refactor it to something manageable, readable and concise.
Adam Cuppy (he/him)
November 05, 2015
Transcript
109 slides
-
1. Communicate expectations 2. Encourage consistency 3. Reduce the mental
load Design Patterns… -
class User < ActiveRecord::Base has_secure_password validates :firstname, presence: true, length:
4..20 validates :middlename, length: 4..20, allow_blank: true validates :lastname, presence: true, length: 4..20 validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i } validates :password, presence: true, confirmation: true, length: { minimum: 8 } # ... end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
class User < ActiveRecord::Base has_secure_password validates :firstname, presence: true, length:
4..20 validates :middlename, length: 4..20, allow_blank: true validates :lastname, presence: true, length: 4..20 validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i } validates :password, presence: true, confirmation: true, length: { minimum: 8 } # ... end -
class User < ActiveRecord::Base has_secure_password validates :firstname, presence: true, length:
4..20 validates :middlename, length: 4..20, allow_blank: true validates :lastname, presence: true, length: 4..20 validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i } validates :password, presence: true, confirmation: true, length: { minimum: 8 } # ... end -
class User < ActiveRecord::Base has_secure_password validates :firstname, presence: true, length:
4..20 validates :middlename, length: 4..20, allow_blank: true validates :lastname, presence: true, length: 4..20 validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i } validates :password, presence: true, confirmation: true, length: { minimum: 8 } # ... end Other validations failing!! -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
describe User do subject(:user) { described_class.new } context 'with a
firstname that is over 20 chars' do # ... end context 'with a firstname that is under 4 chars' do # ... end end -
describe User do subject(:user) { described_class.new firstname: firstname } context
'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } # ... end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } # ... end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
# spec/models/user_spec.rb describe User do it "should be invalid" do
user = User.new(firstname: "really really ... super long firstname") expect(user.valid?).to_not eq true user = User.new(firstname: "sht") expect(user.valid?).to_not eq true end end -
describe User do subject(:user) { described_class.new firstname: firstname } context
'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } # ... end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } # ... end end -
describe User do subject(:user) { described_class.new firstname: firstname } context
'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end Fix false positive ??? -
describe User do subject(:user) { described_class.new firstname: firstname } context
'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } context
'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end ?? -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end ?? ?? -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end Fix false positive ??? -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } specify { expect(user).to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } specify { expect(user).to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } specify { expect(user).to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname } let(:firstname)
{ 'Adam' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end context 'with a firstname that is under 4 chars' do let(:firstname) { 'srt' } it { is_expected.to be_invalid } end end -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } let(:email) { '[email protected]' } let(:password) { 'Passw0rd!' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end # ... end -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } let(:email) { '[email protected]' } let(:password) { 'Passw0rd!' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end # ... end #1: Build the subject -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } let(:email) { '[email protected]' } let(:password) { 'Passw0rd!' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end # ... end #1: Build the subject #2: Set the config values -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } let(:email) { '[email protected]' } let(:password) { 'Passw0rd!' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end # ... end #3 Assert a Valid state -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } let(:email) { '[email protected]' } let(:password) { 'Passw0rd!' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end # ... end #4 Mutate -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } let(:email) { '[email protected]' } let(:password) { 'Passw0rd!' } it { is_expected.to be_valid } context 'with a firstname that is over 20 chars' do let(:firstname) { 'really really ... super long firstname' } it { is_expected.to be_invalid } end # ... end -
class User < ActiveRecord::Base # ... def fullname [firstname, middlename,
lastname].compact.join ' ' end end -
class User < ActiveRecord::Base # ... def fullname [firstname, middlename,
lastname].compact.join ' ' end end -
class User < ActiveRecord::Base # ... def fullname [firstname, middlename,
lastname].compact.join ' ' end end -
class User < ActiveRecord::Base # ... def fullname [firstname, middlename,
lastname].compact.join ' ' end end -
describe User do subject(:user) { described_class.new firstname: firstname, middlename: middlename,
lastname: lastname, email: email, password: password } let(:firstname) { 'Adam' } let(:middlename) { nil } let(:lastname) { 'Cuppy' } # ... end -
describe User do # ... describe 'retrieving a fullname' do
subject(:fullname) { user.fullname } context 'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end context 'when lastname is blank' do let(:lastname) { nil } it { is_expected.to eq firstname } end context 'when first and lastname are blank' do let(:firstname) { nil } let(:lastname) { nil } it { is_expected.to be_blank } end context 'when there are all three names' do let(:middlename) { 'Jimmy' } it { is_expected.to eq "#{firstname} #{middlename} #{lastname}" } end end # ... end -
describe User do # ... describe 'retrieving a fullname' do
subject(:fullname) { user.fullname } context 'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end context 'when lastname is blank' do let(:lastname) { nil } it { is_expected.to eq firstname } end context 'when first and lastname are blank' do let(:firstname) { nil } let(:lastname) { nil } it { is_expected.to be_blank } end context 'when there are all three names' do let(:middlename) { 'Jimmy' } it { is_expected.to eq "#{firstname} #{middlename} #{lastname}" } end end # ... end -
describe User do # ... describe 'retrieving a fullname' do
subject(:fullname) { user.fullname } context 'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end context 'when lastname is blank' do let(:lastname) { nil } it { is_expected.to eq firstname } end context 'when first and lastname are blank' do let(:firstname) { nil } let(:lastname) { nil } it { is_expected.to be_blank } end context 'when there are all three names' do let(:middlename) { 'Jimmy' } it { is_expected.to eq "#{firstname} #{middlename} #{lastname}" } end end # ... end -
describe User do # ... describe 'retrieving a fullname' do
subject(:fullname) { user.fullname } context 'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end context 'when lastname is blank' do let(:lastname) { nil } it { is_expected.to eq firstname } end context 'when first and lastname are blank' do let(:firstname) { nil } let(:lastname) { nil } it { is_expected.to be_blank } end context 'when there are all three names' do let(:middlename) { 'Jimmy' } it { is_expected.to eq "#{firstname} #{middlename} #{lastname}" } end end # ... end -
describe User do # ... describe 'retrieving a fullname' do
subject(:fullname) { user.fullname } context 'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end context 'when lastname is blank' do let(:lastname) { nil } it { is_expected.to eq firstname } end context 'when first and lastname are blank' do let(:firstname) { nil } let(:lastname) { nil } it { is_expected.to be_blank } end context 'when there are all three names' do let(:middlename) { 'Jimmy' } it { is_expected.to eq "#{firstname} #{middlename} #{lastname}" } end end # ... end Anything missing ?? -
describe User do # ... describe 'retrieving a fullname' do
subject(:fullname) { user.fullname } context 'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end context 'when lastname is blank' do let(:lastname) { nil } it { is_expected.to eq firstname } end context 'when first and lastname are blank' do let(:firstname) { nil } let(:lastname) { nil } it { is_expected.to be_blank } end context 'when there are all three names' do let(:middlename) { 'Jimmy' } it { is_expected.to eq "#{firstname} #{middlename} #{lastname}" } end end # ... end -
describe 'retrieving a fullname' do subject(:fullname) { user.fullname } context
'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end #... end -
describe 'retrieving a fullname' do subject(:fullname) { user.fullname } context
'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end #... end -
describe 'retrieving a fullname' do subject(:fullname) { user.fullname } context
'when firstname is blank' do let(:firstname) { nil } it { is_expected.to eq lastname } end #... end -
describe 'retrieving a fullname' do # context 'when firstname is
blank' do # let(:firstname) { nil } # it { is_expected.to eq lastname } # end { [nil, 'James', 'Johnson'] => 'James Johnson', }.each do |name_set, output| # ... end end -
describe 'retrieving a fullname' do # ... { [nil, 'James',
'Johnson'] => 'James Johnson', [nil, 'James', nil] => 'James', ['Jimmy', 'James', nil] => 'Jimmy James', ['Jimmy', 'James', 'Johnson'] => 'Jimmy James Johnson' }.each do |name_set, output| # ... end end Anything missing ?? -
describe 'retrieving a fullname' do # ... { [nil, 'James',
'Johnson'] => 'James Johnson', [nil, 'James', nil] => 'James', ['Jimmy', 'James', nil] => 'Jimmy James', ['Jimmy', 'James', 'Johnson'] => 'Jimmy James Johnson' ['Jimmy', nil, nil] => 'Jimmy', [nil, nil, 'Johnson'] => 'Johnson', ['Jimmy', nil, 'Johnson'] => 'Jimmy Johnson' }.each do |name_set, output| # ... end end -
describe 'retrieving a fullname' do shared_examples_for 'a fullname' do |(first,
middle, last), output| subject(:fullname) { user.fullname } let(:firstname) { first } let(:middlename) { middle } let(:lastname) { last } it { is_expected.to eq output } end { [nil, 'James', 'Johnson'] => 'James Johnson', [nil, 'James', nil] => 'James', ['Jimmy', 'James', nil] => 'Jimmy James', ['Jimmy', 'James', 'Johnson'] => 'Jimmy James Johnson' ['Jimmy', nil, nil] => 'Jimmy', [nil, nil, 'Johnson'] => 'Johnson', ['Jimmy', nil, 'Johnson'] => 'Jimmy Johnson' }.each do |name_set, output| it_behaves_like 'a fullname', name_set, output end end -
describe 'retrieving a fullname' do shared_examples_for 'a fullname' do |(first,
middle, last), output| subject(:fullname) { user.fullname } let(:firstname) { first } let(:middlename) { middle } let(:lastname) { last } it { is_expected.to eq output } end { [nil, 'James', 'Johnson'] => 'James Johnson', [nil, 'James', nil] => 'James', ['Jimmy', 'James', nil] => 'Jimmy James', ['Jimmy', 'James', 'Johnson'] => 'Jimmy James Johnson' ['Jimmy', nil, nil] => 'Jimmy', [nil, nil, 'Johnson'] => 'Johnson', ['Jimmy', nil, 'Johnson'] => 'Jimmy Johnson' }.each do |name_set, output| it_behaves_like 'a fullname', name_set, output end end -
describe 'retrieving a fullname' do shared_examples_for 'a fullname' do |(first,
middle, last), output| subject(:fullname) { user.fullname } let(:firstname) { first } let(:middlename) { middle } let(:lastname) { last } it { is_expected.to eq output } end { [nil, 'James', 'Johnson'] => 'James Johnson', [nil, 'James', nil] => 'James', ['Jimmy', 'James', nil] => 'Jimmy James', ['Jimmy', 'James', 'Johnson'] => 'Jimmy James Johnson' ['Jimmy', nil, nil] => 'Jimmy', [nil, nil, 'Johnson'] => 'Johnson', ['Jimmy', nil, 'Johnson'] => 'Jimmy Johnson' }.each do |name_set, output| it_behaves_like 'a fullname', name_set, output end end -
1. Backfilling untested legacy code 2. Uncertain expectations require visual
confirmation 3. Code complexity significantly exceeds current domain knowledge Golden Master Testing -
require 'rails_helper' RSpec.describe User, type: :model do pending "add some
examples to (or delete) #{__FILE__}" end -
1. Take a snapshot of an object (to a file)
2. Verify the snapshot (manually) 3. Compare future versions to the verified “master” Golden Master Testing -
# spec/spec_helper.rb require ‘approvals/rspec' # path/to/spec.rb it 'works' do verify
format: :html do "<html><head></head><body><h1>ZOMG</h1></body></html>" end end # Manually verify snapshots $ cd /path/to/app $ approvals verify https://github.com/kytrinyx/approvals -
# spec/spec_helper.rb require ‘approvals/rspec' # path/to/spec.rb it 'works' do verify
format: :html do "<html><head></head><body><h1>ZOMG</h1></body></html>" end end # Manually verify snapshots $ cd /path/to/app $ approvals verify https://github.com/kytrinyx/approvals -
# spec/spec_helper.rb require ‘approvals/rspec' # path/to/spec.rb it 'works' do verify
format: :html do "<html><head></head><body><h1>ZOMG</h1></body></html>" end end # Manually verify snapshots $ cd /path/to/app $ approvals verify https://github.com/kytrinyx/approvals -
@fullname = 'Adam Cuppy' specify { expect(@fullnmae).to eq 'Adam Cuppy'
} # => expected: "Adam Cuppy", got: nil -
let(:fullname) { 'Adam Cuppy' } specify { expect(fullnmae).to eq 'Adam
Cuppy' } # => NameError: undefined local variable or method `fullnmae' -
describe 'secure zones' do context 'as a guest user' do
let(:user) { User.new } # ... end context 'as an admin user' do let(:user) { User.new admin: true } # ... end end -
require 'rspec/expectations' RSpec::Matchers.define :be_a_multiple_of do |expected| match do |actual| actual
% expected == 0 end end RSpec.describe 9 do it { is_expected.to be_a_multiple_of(3) } end https://www.relishapp.com/rspec/rspec-expectations/v/3-3/docs/custom-matchers/define-matcher -
require 'rspec/expectations' RSpec::Matchers.define :be_a_multiple_of do |expected| match do |actual| actual
% expected == 0 end end RSpec.describe 9 do it { is_expected.to be_a_multiple_of(3) } end https://www.relishapp.com/rspec/rspec-expectations/v/3-3/docs/custom-matchers/define-matcher -
describe User do # ... context 'when a new user
matches an existing user' do let!(:existing_user) { described_class.create! firstname: firstname, middlename: middlename lastname: lastname, email: email, password: password } let(:new_user) { described_class.new firstname: firstname, middlename: middlename lastname: lastname, email: email, password: password } it { expect(new_user).to be_invalid } end # ... end WHat’s invalid ?? -
describe User do # ... context 'when a new user
matches an existing user' do let!(:existing_user) { described_class.create! firstname: firstname, middlename: middlename lastname: lastname, email: email, password: password } let(:new_user) { described_class.new firstname: firstname, middlename: middlename lastname: lastname, email: email, password: password } it { expect(new_user).to be_invalid } end # ... end -
describe User do subject(:user) { ... } # ... describe
'validations' do it { is_expected.to validate_uniqueness_of(:email) } end end https://github.com/thoughtbot/shoulda-matchers -
describe User do subject(:user) { ... } # ... describe
'validations' do it { is_expected.to validate_uniqueness_of(:email) } end end https://github.com/thoughtbot/shoulda-matchers -
describe User do subject(:user) { ... } # ... describe
'validations' do it { is_expected.to validate_uniqueness_of(:email) } end end https://github.com/thoughtbot/shoulda-matchers -
describe User do subject(:user) { FactoryGirl.build :user } # ...
context 'validations' do it { is_expected.to validate_uniqueness_of(:email) } end end -
describe User do subject(:user) { FactoryGirl.build :user } # ...
context 'validations' do it { is_expected.to validate_uniqueness_of(:email) } end end