🌞 🌚

Testing railway-oriented business transactions with Rspec

🗓 - ⏱ 7 minute read -

Railway-oriented business transactions are a great way to unclutter your Rails controllers. We’ve already seen how to write ‘em. Now let’s see how we can test ‘em. I’ll be using dry-transaction as a business transaction DSL and RSpec for testing. I’m assuming that you already have RSpec set up for your app. If not, check Aaron Sumner’s blog and his book Everyday Rails Testing With RSpec to saddle up.

Starting point: a basic transaction

Here’s a basic transaction. I’ll fetch some params and create a new lead. Based on the database response, I’ll return either a Success or a Failure.

  # app/transactions/leads/create.rb

  class Leads::Create < BaseTransaction
    map :params
    step :create_lead

    def params(input)
      input.fetch(:params)
    end

    def create_lead(params)
      lead = Lead.create(params)

      if lead.errors.any?
        Failure(error: lead.errors.full_messages.join(' | '))
      else
        Success(lead)
      end
    end
  end

Now, I want to test it. So where do I start?

Testing transactions: the basics

First, I’ll create a test file.

  touch app/transactions/leads/create_spec.rb

Then, I’ll write our test bare bones.

  # app/transactions/leads/create_spec.rb

  require 'rails_helper'

  RSpec.describe Leads::Create, type: :transaction do
    subject { described_class.call(params: params) }

    let(:params) do
      {
        first_name:        'Steven',
        last_name:         'Universe',
        email:             'steven@crystalgems.com',
        opt_in_newsletter: true
      }
    end
  end

What I’m doing here:

Covering positive scenarios

What should I test then? The first thing I want to test is the transaction returning a Success when called with valid params. From this starting point, I can check if the transaction does create a Lead or if this lead has the proper attributes filled in.

  require 'rails_helper'

  RSpec.describe Leads::Create, type: :transaction do
    subject { described_class.call(params: params) }

    let(:params) do
      {
        first_name:        'Steven',
        last_name:         'Universe',
        email:             'steven@crystalgems.com',
        phone_number:      '+33660606060',
        opt_in_newsletter: false
      }
    end

    context 'a new lead signed up with valid params' do
      it { is_expected.to be_success }

      it 'creates a new lead' do
        expect { subject }.to(change { Lead.count }.by(1))
      end

      it 'return a new lead' do
        expect(subject.success).to be_a(Lead)
      end

      it 'fills in the ad hoc fields' do
        expect(subject.success).to have_attributes(
          first_name:        a_string_ending_with('n'),
          last_name:         a_string_starting_with('u'),
          email:             be_truthy,
          phone_number:      be_truthy,
          opt_in_newsletter: be_falsey
        )
      end
    end
  end

Here I’m using various RSpec built-in matchers to check several things:

Testing for successes and failures

Say I want to send a SMS to my new lead through an external service. Let’s add a step to my transaction.

  class Leads::Create < BaseTransaction
    map :params
    step :create_lead
    step :send_welcome_sms

    def params(input)
      input.fetch(:params)
    end

    def create_lead(params)
      lead = Lead.create(params)

      if lead.errors.any?
        Failure(error: lead.errors.full_messages.join(' | '))
      else
        Success(lead)
      end
    end

    def send_welcome_sms(lead)
      MySmsProvider.welcome_sms(lead)

      Success(lead)
    rescue StandardError => exception
      Failure(error: exception)
    end
  end

Easy right? But MySmsProvider only provides production-ready credentials. So how can I test MySmsProvider’s different responses? RSpec allows me to mock responses.

Let’s start with mocking a positive response. Our basic assumptions shouldn’t change because whatever steps we’re adding to the transaction, it should always return a Success or a Failure.

  require 'rails_helper'

  RSpec.describe Leads::Create, type: :transaction do
    subject { described_class.call(params: params) }

    let(:params) do
      {
        first_name:        'Steven',
        last_name:         'Universe',
        email:             'steven@crystalgems.com',
        phone_number:      '+33660606060',
        opt_in_newsletter: false
      }
    end

    context 'a new lead signed up with valid params'
      before do
        response = {
          message_id:     01234,
          body:           'Hello Steven!',
          message_status: 'sent'
        }

        allow_any_instance_of(MySmsProvider).to receive(:deliver_now) { response }
      end

      it { is_expected.to be_success }

      it 'creates a new lead' do
        expect { subject }.to(change { Lead.count }.by(1))
      end

      it 'return a new lead' do
        expect(subject.success).to be_a(Lead)
      end

      it 'fills in the ad hoc fields' do
        expect(subject.success).to have_attributes(
          first_name:        a_string_ending_with('n'),
          last_name:         a_string_starting_with('u'),
          email:             be_truthy,
          phone_number:      be_truthy,
          opt_in_newsletter: be_falsey
        )
      end
    end
  end

But what if we want to test a negative response (i.e: Steven’s phone number is invalid)?

I can either add a context block with specific params or mock a error message from MySmsProvider. I’ll do the former. context behaves like a sub-folder where I can inherit my test’s top-level information yet change it if need be.

  require 'rails_helper'

  RSpec.describe Leads::Create, type: :transaction do
    subject { described_class.call(params: params) }

    let(:params) do
      {
        first_name:        'Steven',
        last_name:         'Universe',
        email:             'steven@crystalgems.com',
        phone_number:      '+33660606060',
        opt_in_newsletter: false
      }
    end

    context 'a new lead signed up with valid params' do
      before do
        response = {
          message_id:     01234,
          body:           'Hello Steven!',
          message_status: 'sent'
        }

        allow_any_instance_of(MySmsProvider).to receive(:welcome_sms) { response }
      end

      it { is_expected.to be_success }

      it 'creates a new lead' do
        expect { subject }.to(change { Lead.count }.by(1))
      end

      it 'return a new lead' do
        expect(subject.success).to be_a(Lead)
      end

      it 'fills in the ad hoc fields' do
        expect(subject.success).to have_attributes(
          first_name:        a_string_ending_with('n'),
          last_name:         a_string_starting_with('u'),
          email:             be_truthy,
          phone_number:      be_truthy,
          opt_in_newsletter: be_falsey
        )
      end
    end

    context 'a new lead signed up with invalid params' do
      # I can redefine my params here to include a nil value that'll trigger an error with my SMS provider
      let(:params) do
      {
        first_name:        'Steven',
        last_name:         'Universe',
        email:             'steven@crystalgems.com',
        phone_number:      nil,
        opt_in_newsletter: false
      }
    end

      it { is_expected.to be_failure }

      it 'returns an error from MySmsProvider' do
        expect(subject.deliver_sms_now).to be_kind_of(MySmsProvider::REST::RestError)
      end
    end
  end

In the last context 'a new lead signed up with invalid params', the before do block defined in the first context does not apply (it’s context dependent). I’ve decided to trigger an error based on my input rather than mocking an error with allow_any_instance_of. This way, I can decide to enforce validations at the transaction level to return a Failure if some data are missing.

And voilà!

I hope this boilerplate will help you start testing your transactions. Noticed something? Ping me on Twitter or create an issue on GitHub.

Cheers,

Rémi