/shoegaze

Create mocks of modules (especially clients) with easily-defined scenarios (success, invalid, etc)

Primary LanguageRubyMIT LicenseMIT

Shoegaze

Status

As of 2023-04-04 this has been archived due to Compose being End of Life and so no one is maintaining this repository. It may become publicly open-sourced and supported under IBM Cloud at some point in the future.


Build status

Create mocks of modules (especially clients) with easily-defined scenarios (success, invalid, etc) and an optional in-memory persistence layer.

Documentation

The problem

When unit testing, libraries that constitute dependencies become cumbersome to stub as complexity increases. Most of the time you want to simulate various high-level scenarios that can be produced in your library. Stubbing those high-level scenarios in your tests with low-level tools can be cumbersome and requires a lot of logic about your dependencies to be sprinked throughout your tests. Outright mocking the libraries in various ways (Webmock, VCR, DIY fake classes) can be tricky.

How Shoegaze solves the problem

When you mock a library using Shoegaze, Shoegaze creates an Rspec double of the library. Using a simple DSL you can specify test implementations and their scenarios for the mocked library's methods. Swap out your real library implementation for the mock, then drive the high-level behavior in your tests by specifying the scenarios to run. This provides a consistent interface for creating mocks and forces you to mock your library's API, which is the right place to separate the concerns of the library from your tests.

Mock Twitter Client Example

class FakeTwitterClient < Shoegaze::Mock
  # optional. provides in-memory persisted ActiveModel objects so that
  # your mock can 'remember' its state
  extend Shoegaze::Datastore

  mock "Twitter::Client"

  # creates both a FakeTwitterClient::Update 'model' and a FactoryBot
  # factory for the model
  datastore :Update do
    id{ BSON::ObjectId.new }
    date{ Time.new }
    body{ Faker::Lorem.sentence }
    location{ [Faker::Address.latitude, Faker::Address.longitude] }
  end

  implement :update do
    scenario :success do
      # optional. you can provide transforms for your 'models' to
      # represent the data returned by the implementation in various
      # ways
      representer do
        include Representable::JSON

        property :id
        property :date
        property :body

        # notice we omit location in this representation
      end

      # this method will call :as_json on the representer, meaning it
      # will return a hash. you can also call to_json, for example, to
      # return stringified JSON instead
      represent_method :as_json

      datasource do
        # generate an update and store it in the memory store (you can
        # grab it with FakerTwitterClient::Update.find(update.id)
        FactoryBot.create(Update)
      end
    end

    scenario :unavailable do
      datasource do |id|
        raise Twitter::ConnectionFailed.new(Struct.new(:status).new(status: "504"))
      end
    end
  end

  # you can mock chained methods by nesting implementations, too
  #
  # example: ProductMaker.accounts.create({name: "Jack Dorsey"})
  implement :accounts do
    scenario :success do
      datasource do
        implement :create do
          # default scenarios can be specified
          default do |params|
            FactoryBot.create(Account, params)
          end
        end
      end
    end
  end
end
class ProductMaker
  class << self
    def create(name)
      # ...
      twitter_client.update("We have a new product: #{product.name}!")

      product
    end

    private

    def twitter_client
      @twitter_client ||= Twitter::Client.new
    end
  end
end
RSpec.configure do |config|
  config.before :each do
    # swap out the twitter client for the mock in all tests
    stub_const("Twitter::Client", FakeTwitterClient.proxy)
  end
end
describe ProductMaker do
  describe "#create" do
    describe "tweeting" do
      describe "is successful" do
        before :each do
          FakeTwitterClient.calling(:update).with(update.body).yields(:success)
        end

        it "posts a twitter update" do
          product = ProductMaker.create("some_product")

          update = FakeTwitterClient::Update.find_by_body("We have a new product: #{product.name}!")
          expect(update).to exist
        end
      end

      describe "is unavailable" do
        before :each do
          FakeTwitterClient.calling(:update).with(update.body).yields(:unavailable)
        end

        it "raises a Twitter connection failed error" do
          expect{ ProductMaker.create("some_product") }.to raise_exception(Twitter::ConnectionFailed)
        end
      end
    end
  end
end

Unit Test Manifesto

Inject mock dependencies

If a component calls a Twitter client library, create a mock of the Twitter client library's API and swap the real implementation for the mock.

Steer injected dependencies at the highest practical level (success, failure, etc)

If you want to test how the component handles Twitter being unavailable, create a scenario in the mock implementation of the Twitter client library that simulates how the real implementation behaves when Twitter is unavailable.

Test the wiring & the I/O, not the code

Prove that your dependencies were called with the input you expect. In a unit test that should be good enough most of the time. If those dependency calls would have produced side-effects and it matters to your code, your unit may be too complex. Consider refactoring. If the side-effects the core intent of the the implementation, simulate the side-effects rather than actually producing the side-effects.

Use the dumbest possible test subjects that can prove the I/O works

Much of the time arguments to the API you're testing are not inspected in any meaningful way. If all you need to do is prove the argument was passed-along correctly, a double can do the job rather than the real argument type.

Generate test data randomly and dynamically rather than use fixtures

Use Faker. Generate test data in the most flexible format. Generally that's a ruby object with accessors produced by Factory Bot, since it can easily be turned into a hash, JSON, or left as-is.

Test the immediate layer of the component you're testing

If a component calls Net:HTTP, don't serve HTTP, create a mock Net:HTTP object and prove it was called with the anticipated I/O.