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

Made with Slides.com