Fasterizing Specs
Motivators
Why?
Lifted from Jay Fields' Working Effectively with Unit Tests
Motivators
Validate the System
- Everything works as expected
- Prevents future regressions
Motivators
Document Behavior
Code doesn't lie.
(But test descriptions still can.)
Motivators
Enable Refactoring
"There is a level of design abstraction where it is almost impossible to safely make any change unless the code has tests. Tests are your record of the interface of every abstraction and as such they are the wall at your back." – POODR
Motivators
Drive Design
- Tests are the reuse of your objects
- Force decoupling and expose flaws
- Simplest Implementation
Code Coverage
Acceptance Criteria
...
Other Motivators...
Motivators
- It must have a reason to exist
- It must have a positive ROI
- Otherwise, delete it
What to Test
and what not to
BTW, this was stolen straight from Sandi Metz.
Origin of Messages
Types of Messages
Command
Query
No side effects
Result returned
Side effects
No result returned
Query
Command
Incoming
Outgoing
To Self
Sandi Metz's Magic Testing Matrix
Assert!
result
Assert!
direct & public side effects
Expect!
message sent
Example
class Pdfer
attr_reader :invoice
def initialize(invoice)
@invoice = invoice
end
def generate
pdf_as_string = generator.call
cacher.put(pdf_as_string)
invoice.cached_in_s3 = true
pdf_as_string
end
def cacher
@cacher ||= S3Cacher.new(filename)
end
def generator
@generator ||= Generator.new(invoice)
end
def filename
"invoice-#{ invoice.token }.pdf"
end
end
def filename
"invoice-#{ invoice.token }.pdf"
end
describe Pdfable do
let(:invoice) { double('Invoice') }
subject { described_class.new(invoice) }
describe '#filename' do
it 'creates an invoice filename with the invoice token' do
expect(invoice).to receive(:token) { 'a_token' }
expect(subject.filename).to eq 'invoice-a_token.pdf'
end
end
end
describe '#filename' do
let(:invoice) { instance_double(Invoice, token: 'a_token') }
its(:filename) { should eq 'invoice-a_token.pdf' }
end
def cacher
@cacher ||= S3Cacher.new(filename)
end
def generator
@generator ||= Generator.new(invoice)
end
describe '#cacher' do
it 'instantiates, caches, and returns an S3Cacher' do
expect(subject).to receive(:filename) { 'filename' }
expect(S3Cacher).to receive(:new).with('filename').once { :an_s3_cacher }
expect(subject.cacher).to be :an_s3_cacher
expect(subject.cacher).to be :an_s3_cacher
end
end
describe '#generator' do
it 'instantiates, caches, and returns a Generator' do
expect(Generator).to receive(:new).with(invoice).once { :a_generator }
expect(subject.generator).to be :a_generator
expect(subject.generator).to be :a_generator
end
end
def generate
pdf_as_string = generator.call
cacher.put(pdf_as_string)
invoice.cached_in_s3 = true
pdf_as_string
end
describe '#generate' do
let(:generator) { instance_double(Generator) }
let(:cacher) { instance_double(S3Cacher) }
let(:invoice) { instance_double(Invoice) }
before do
allow(subject).to receive(:cacher) { cacher }
allow(subject).to receive(:generator) { generator }
end
it 'generates pdf, caches it, sets bool on invoice, and returns pdf' do
expect(generator).to receive(:call) { 'pdf string' }
expect(cacher).to receive(:put).with('pdf string')
expect(invoice).to receive(:cached_in_s3=).with(true)
expect(subject.generate).to eq 'pdf string'
end
end
Refactored
class Pdfer
attr_reader :invoice, :generator, :cacher
def initialize(invoice, generator: nil, cacher: nil)
@invoice = invoice
@generator = generator || Generator.new(invoice)
@cacher = cacher || S3Cacher.new(filename)
end
def generate!
pdf = generator_pdf
cached_pdf(pdf)
pdf
end
def generate_pdf
generator.call
end
def cache_pdf(pdf_as_string)
cacher.put(pdf_as_string)
invoice.cached_in_s3 = true
end
def filename
"invoice-#{ invoice.token }.pdf"
end
end
describe Pdfer do
let(:invoice) { double(Invoice) }
let(:generator) { double(Generator) }
let(:cacher) { double(S3Cacher) }
subject { described_class.new(invoice, generator: generator, cacher: cacher) }
# ...
end
Mock out everything
Isolates the unit under test
attr_reader :invoice, :generator, :cacher
def initialize(invoice, generator: nil, cacher: nil)
@invoice = invoice
@generator = generator || Generator.new(invoice)
@cacher = cacher || S3Cacher.new(filename)
end
describe '.new and attr_readers' do
let(:invoice) { instance_double(Invoice, token: 'token') }
subject { described_class.new(invoice) }
before do
expect(Generator).to receive(:new).with(invoice) { :a_generator }
expect(S3Cacher).to receive(:new).with('invoice-token.pdf') { :a_cacher }
end
its(:invoice) { should be invoice }
its(:generator) { should be :a_generator }
its(:cacher) { should be :a_cacher }
end
def generate!
pdf = generator_pdf
cached_pdf(pdf)
pdf
end
describe '#generate!' do
it 'generates, caches, and returns pdf' do
expect(subject).to receive(:generate_pdf) { :a_pdf }
expect(subject).to receive(:cache_pdf).with(:a_pdf)
expect(subject.generate!).to be :a_pdf
end
end
def generate_pdf
generator.call
end
describe '#generate_pdf' do
it 'calls the generator and returns its result' do
expect(generator).to receive(:call) { :generator_result }
expect(subject.generate_pdf).to be :generator_result
end
end
def cache_pdf(pdf_as_string)
cacher.put(pdf_as_string)
invoice.cached_in_s3 = true
end
describe '#cache_pdf' do
it 'caches given pdf and sets invoice cache boolean' do
expect(cacher).to receive(:put).with(:given_pdf)
expect(invoice).to receive(:cached_in_s3=).with(true)
subject.cache_pdf(:given_pdf)
end
end
describe '#cache_pdf' do
let(:invoice) { Invoice.new }
it 'caches given pdf and sets invoice cache boolean' do
expect(cacher).to receive(:put).with(:given_pdf)
expect{ subject.cache_pdf(:given_pdf) }
.to change{ invoice.cached_in_s3 }.to(true)
end
end
Mocks and Doubles
Tools for Mocking
- Symbols
- Doubles
- Verifying Doubles
- Dummies
- Real Objects
Symbols
describe '#generate!' do
it 'generates, caches, and returns pdf' do
expect(subject).to receive(:generate_pdf) { :a_pdf }
expect(subject).to receive(:cache_pdf).with(:a_pdf)
expect(subject.generate!).to be :a_pdf
end
end
super light-weight
same object
Doubles
let(:invoice) { double(Invoice) }
let(:shopper) { double('Shopper').as_null_object } # returns itself
let(:account) { double('an authenticated account', id: '123') }
light-weight
named
can be null-object
Verifying Doubles
class Invoice
def call(args) end
end
describe Invoice do
let(:invoice) { instance_double(Invoice) }
it 'can be called' do
allow(invoice).to receive(:call)
subject.call
end
end
# Failures:
#
# 1) Invoice can be called
# Failure/Error: def call(args)
# ArgumentError:
# wrong number of arguments (0 for 1)
Dummy
class SimpleSerializer < ActiveModel::Serializer
attributes :id
end
SimpleSerializer.new(job_object)
SimpleSerializer.new(business_object)
SimpleSerializer.new(product_object)
Dummy
class DummySimpleSerializable
include ActiveModel::SerializerSupport
attr_reader :id
def initialize(id=nil)
@id = id
end
end
describe SimpleSerializer do
let(:serializable) { DummySimpleSerializable.new('123') }
subject { described_class.new(serializable) }
its(:'attributes.keys') { should contain_exactly(:id) }
end
Dummy
RSpec.shared_examples 'an UnnamedSimpleSerializable' do
it { should respond_to :id }
end
describe DummySimpleSerializable do
it_behaves_like 'an UnnamedSimpleSerializable'
end
describe Job do
it_behaves_like 'an UnnamedSimpleSerializable'
# ...
end
describe Business do
it_behaves_like 'an UnnamedSimpleSerializable'
# ...
end
# etc...
Real Objects
let(:invoice) { Invoice.new }
let(:shopper) { build :shopper }
let(:account) { create :account }
prefer blank objects
prefer non-persisted objects
rethink your tests otherwise
Case Studies
spec/adapters/api/v1/job_loader/job_loader_spec.rb
spec/adapters/api/v1/pitchset_loader_spec.rb
before(:all)
- use sparingly in unit tests
- use in request specs when setup is expensive
- imagine if tests failed and you're another dev
example
spec/requests/api/v1/pitchsets_spec.rb
Service Layer and Testing
Fasterizing Specs
By Tony Ta
Fasterizing Specs
- 423