Testing with Rails Event Store - Practical Tips and Custom Solutions

Explore a personalized approach to testing event-driven applications using Rails Event Store

Testing is critical in any system, especially asynchronous applications. It’s important to test each component in isolation, while carefully managing the communication between them. It is essential to ensure that everything works together seamlessly. For this reason, I would like to share with you the approach we take to testing in our Event-Driven system.

This article is part three of the Rails Event Store series. If you want to learn more, feel free to check out the previous articles. They also explain the application convetions we use, which may help you better understand our setup and what we want to test. Keep in mind that this is not a testing tutorial, but rather a case study of our experience.

What do we use directly from the Rails Event Store?

The Rails Event Store provides its own matchers for RSpec, some of which we use in our project. The most commonly used ones are:

  • have_published
  • have_subscribed_to_events - we build our own matchers on top of these
  • publish

Below is an example of how to use the publish method:

require 'rails_helper'

RSpec.describe Booking do
  describe 'lifecycle events' do
    context 'when reservation group code changed' do
      context 'and group code is not empty' do
        it 'emits reservations__group_code_identified' do
          booking = create(:booking, group_code: 'GROUP')

          expect {
            booking.update(group_code: 'NEW_GROUP')
          }.to publish(
            an_event(Reservations::GroupCodeIdentifiedEvent).with_data(
              booking_id: booking.id,
              group_code: 'NEW_GROUP'
            )
          ).in(event_store).in_stream('reservations__group_code_identified')
        end
      end

      context 'and group code is empty' do
        it 'does not emit reservations__group_code_identified' do
          booking = create(:booking, group_code: 'GROUP')

          expect {
            booking.update(group_code: '')
          }.not_to publish(
            an_event(Reservations::GroupCodeIdentifiedEvent)
          ).in(event_store)
        end
      end
    end
  end
end

As you can see, we also use additional methods like:

  • an_event
  • in_stream
  • with_data
  • in

For more details on these methods, I recommend reading the Rails Event Store documentation.

Our custom matchers

To make our tests more powerful, we like to create our own custom matchers. Some of these custom matchers use Rails Event Store methods internally. Let’s take a look at some of them.

have_nothing_published

This is a very useful matcher. It helps us ensure that nothing has been published after executing certain logic, such as when invalid data is provided.

RSpec::Matchers.define :have_nothing_published do
  match do |block|
    events_count_before = event_store.read.count
    block.call
    events_count_after = event_store.read.count

    (events_count_after - events_count_before).zero?
  end

  def supports_block_expectations?
    true
  end
end

In this matcher, we check the contents of event_store both before and after executing the test. If nothing has changed, we can confirm that no event has been emitted. As you might have guessed, event_store is our shorthand for Rails.configuration.event_store, which we’ve defined in the application.

require 'rails_helper'

module Reservations
  RSpec.describe IdentifyReservationsWithOutstandingBalance do
    describe '#call' do
      context 'when some reservation have outstanding balance' do
        # ...
      end

      context 'when none of reservations have outstanding balance' do
        it 'does not emit anything' do
          property = create(
            :property,
            :outstanding_balance_notifications_enabled,
            pms_property_id: 'prop1'
          )
          create(
            :booking,
            property:,
            pms_reservation_id: '1567',
            price_total: 1000.25,
            paid_total: 1000.25
          )
          create(
            :booking,
            property:,
            pms_reservation_id: '2567',
            price_total: 1000.25,
            paid_total: 1000.25
          )

          expect do
            IdentifyReservationsWithOutstandingBalance.call(
              property, pms_booking_ids: %w[1567 2567]
            )
          end.to have_nothing_published
        end
      end
    end
  end
end

subscribe_stream

In some cases, we want to make sure that a particular event has been added to a particular stream. That’s why we created this matcher:

RSpec::Matchers.define :subscribe_stream do |stream|
  match do |event|
    event.stream_names.include?(stream)
  end
end
RSpec::Matchers.alias_matcher :subscribe_streams, :subscribe_stream

This matcher simply checks if the stream name is included in the event’s list of streams.

subscribe_to

We also need to check if a given domain subscribes to certain events. To do this, we created a matcher based on the Rails Event Store’s have_subscribed_to_events. In addition, we make sure that the subscription is of the correct type - whether it’s a synchronous or asynchronous event. Here is the code for the matcher:

RSpec::Matchers.define :subscribe_to do |event_name|
  match do |domain|
    handler_class = build_handler_class(event_name, domain)
    event_class = build_event_class(event_name)
    subscription_type = async? ? :async : :sync

    expect(
      EventHandlerBuilder.new(handler_class, subscription_type)
    ).to have_subscribed_to_events(event_class).in(event_store)
  end

  chain :asynchronously do
    @asynchronously = true
  end

  private

  def build_handler_class(event_name, domain)
    handler_name = event_name.to_s.sub('__', '/').camelize
    handler_name.remove!('::')
    "#{domain.name}::#{handler_name}Handler".constantize
  end

  def build_event_class(event_name)
    event_class_name = event_name.to_s.sub('__', '/').camelize
    "#{event_class_name}Event".constantize
  end

  def async?
    @asynchronously
  end
end

Additional test configuration

There are times when we don’t want to store events in the database during testing. In such cases, we can use the Rails Event Store repository with the in-memory option. Here’s the configuration for that:

RSpec.configure do |config|
  config.around(:each, :in_memory_res_client) do |example|
    current_event_store = Rails.configuration.event_store
    Rails.configuration.event_store = RubyEventStore::Client.new(
      repository: RubyEventStore::InMemoryRepository.new
    )
    example.run
    Rails.configuration.event_store = current_event_store
  end
end

All you have to do is use it in describe like this, for example:

require 'rails_helper'

RSpec.describe Property, :in_memory_res_client do
  # ...
end

Additional linter support

Although it’s not directly related to testing, this step occurs before the tests are run. We have a linter that runs before the tests. If our codebase passes the linter checks, the tests continue. However, if the linter fails, we get an alert and the tests are not run until the problem is fixed. While this may seem extreme, we’ve found it to be quite useful.

The linter we created is called SubscriptionsList. It iterates through all the domains and the events that each domain should be listening for, and checks to see if the appropriate handler file exists. This ensures that there’s no way to have a defined connection between a domain and an event without handling it. We always get an alert if something is missing. All the information about events and subscribers is set in the subscriptions.yml file, that I described in previous article.

class SubscriptionsList
  include Singleton

  cattr_accessor :config_path
  self.config_path = 'config/subscriptions.yml'

  def self.lint!
    instance.lint!
  end

  def lint!
    SubscriptionsLinter.new(domain_subscriptions).lint!
  end

  def initialize
    @domain_subscriptions = YAML.load_file(self.class.config_path)
  end

  private

  attr_reader :domain_subscriptions
end
class SubscriptionsLinter
  MissingSubscriptions = Class.new(StandardError)

  def initialize(subscriptions)
    @subscriptions = subscriptions
  end

  def lint!
    raise MissingSubscriptions, error_message if missing_subscriptions.present?
  end

  private

  attr_reader :subscriptions

  def error_message
    "The following subscriptions are missing: \n#{missing_subscriptions.join("\n")}"
  end

  def missing_subscriptions
    (handled_subscription_names - all_subscription_names)
  end

  def handled_subscription_names
    Dir[Rails.root.join('app/event_handlers/*/*.rb')].map do |file_path|
      file_path.
        sub(Rails.root.join('app/event_handlers/').to_s, '').
        sub('_handler.rb', '')
    end
  end

  def all_subscription_names
    subscriptions.flat_map do |domain_name, subscriptions|
      subscriptions.keys.map do |event_name|
        "#{domain_name}/#{event_name.sub('__', '_')}"
      end
    end
  end
end

We add this linter to spec/rails_helper.rb so that every time we run the rspec spec/ command, the linter is also run.

SubscriptionsList.lint!

In version 7 of Rails, we will get a similar thing thanks to the Zeitwerk, but without our custom message.

Event factories

Writing tests becomes much easier and faster if we have the necessary data prepared in advance. For this reason, when we need an event in a test, we use event factories to set it up. We use the Factory Bot gem to do this. Here’s an example of an event factory:

FactoryBot.define do
  factory :accounts__account_created_event,
          class: Accounts::AccountCreatedEvent do
    skip_create

    sequence(:account_id)

    initialize_with { new(data: attributes) }
  end

  factory :accounts__remote_lock_account_created_event,
          parent: :accounts__account_created_event,
          class: Accounts::RemoteLockAccountCreatedEvent do
    sequence(:external_account_id)
  end

  factory :accounts__remote_lock_account_deleted_event,
          parent: :accounts__remote_lock_account_created_event,
          class: Accounts::RemoteLockAccountDeletedEvent

  factory :accounts__google_account_created_event,
          parent: :accounts__remote_lock_account_created_event,
          class: Accounts::GoogleAccountCreatedEvent

  factory :accounts__account_scheduled_for_deletion_event,
          parent: :accounts__account_created_event,
          class: Accounts::AccountScheduledForDeletionEvent do
    by { 'john@example.com' }
  end
end

In most cases, these events are fairly straightforward - we just need to populate the correct attributes with data. This is the trade-off for enforcing schema validation on events. We always need to prepare a fully valid event, even if some data isn’t needed for the specific test. Since event objects aren’t meant to be stored in the database, we use skip_create for them (they’re not the same as records in the event_store_events table). We also follow the convention of changing only the data that is relevant to the test, which makes the tests more readable.

Testing events

Events are simple objects that, in most cases, hold data without any logic. In our case, we don’t test the event schema in our tests. For event schema validation, we use Dry::Struct, and you can learn more about how we do that here. We also don’t test specific data types. Instead, we focus on testing any additional methods added to the event object, such as primary_source_name. This method returns information about the source of the event.

require 'rails_helper'

module Accounts
  RSpec.describe RemoteLockAccountCreatedEvent do
    describe '#primary_source_name' do
      context 'when account exists' do
        it 'returns creator name' do
          account = create(:account, creator_email: 'john@example.com')

          event = build(
            :accounts__remote_lock_account_created_event,
            account_id: account.id
          )

          expect(event.primary_source_name).to eq('john@example.com')
        end
      end

      context 'when account does not exist' do
        it 'returns Remmoved User text' do
          event = build(
            :accounts__remote_lock_account_created_event,
            account_id: 3867
          )

          expect(event.primary_source_name).to eq('Removed User')
        end
      end
    end
  end
end

Remember that since we have events in the system, we remember everything that was communicated by events. So we can get the email address of this user, but this was not a point of this method.

Sometimes we also test if the event was added to a specific stream:

require 'rails_helper'

module Messaging
  RSpec.describe CheckInInstructionsSentEvent do
    context 'when triggered for Twillio Conversation type' do
      it 'subscribes to twilio__timeline stream' do
        event = build(
          :messaging__check_in_instructions_sent_event,
          :twilio_conversation_type
        )

        expect(event).to subscribe_stream('twilio__timeline')
      end
    end

    context 'when triggered for SendGrid type' do
      it 'does not subscribe to twilio__timeline stream' do
        event = build(
          :messaging__check_in_instructions_sent_event,
          :send_grid_type
        )

        expect(event).not_to subscribe_stream('twilio__timeline')
      end
    end
  end
end

Our application contains many different streams, each with a specific purpose, such as a reservation timeline or logs for external services. These logs help us keep track of exactly what we get from webhooks. I’ll cover this in more detail in a future article.

Testing event emission

Now let’s explore how we can verify that an event has been emitted. For example, model callbacks are often a good place to start using events. Another good place for events is within services that perform actions and need to notify other parts of the system.

Model class - event instead of callback

require 'rails_helper'

RSpec.describe Property do
  describe 'lifecycle events' do
    context 'when inactive flag is enabled' do
      it 'emits configuration__property_deactivated event' do
        with_current_user(create(:user, email: 'tony@stark.dev')) do
          property = create(:property)
          property.inactive = true

          property.save!

          expect(event_store).to have_published(
            an_event(
              Configuration::PropertyDeactivatedEvent
            ).with_data(
              property_id: property.id,
              by: 'tony@stark.dev'
            )
          ).in_stream('configuration__property_deactivated')
        end
      end
    end

    context 'when inactive flag is disabled' do
      it 'emits configuration__property_activated event' do
        with_current_user(create(:user, email: 'tony@stark.dev')) do
          property = create(:property, :inactive)
          property.inactive = false

          property.save!

          expect(event_store).to have_published(
            an_event(
              Configuration::PropertyActivatedEvent
            ).with_data(
              property_id: property.id,
              by: 'tony@stark.dev'
            )
          ).in_stream('configuration__property_activated')
        end
      end
    end
  end
end

Service class

A good example of a service is the creation of a Facebook account for a user who is not a guest of the property or hotel, but a member of the property team - a crew member.

require 'rails_helper'

module Accounts
  RSpec.describe CreateFacebookAccount do
    describe '.call' do
      it 'creates facebook account' do
        account = create(
          :account,
          first_name: 'Tom',
          last_name: 'Cruise',
          email: 'tom@example.com',
          job_title: 'Event manager'
        )
        allow(FacebookApi::Wrapper).to receive(:create_account).
          with({
            email: 'tom@example.com',
            name: 'Tom Cruise',
            title: 'Event manager'
          }).
          and_return('facebook-id')

        CreateFacebookAccount.call(account.id)

        expect(account.reload.facebook_account_id).to eq('facebook-id')
      end

      it 'emits accounts__facebook_account_created event' do
        account = create(
          :account,
          first_name: 'Tom',
          last_name: 'Cruise',
          job_title: 'staff',
          email: 'tom@example.com'
        )
        allow(FacebookApi::Wrapper).to receive(:create_account).
          with({
            email: 'tom@example.com',
            name: 'Tom Cruise',
            title: 'staff'
          }).
          and_return('facebook-id')

        CreateFacebookAccount.call(account.id)

        expect(event_store).to have_published(
          an_event(Accounts::FacebookAccountCreatedEvent).with_data(
            account_id: account.id.to_s,
            external_account_id: 'facebook-id'
          )
        ).in_stream('accounts__facebook_account_created')
      end
    end
  end
end

In the first test, we check if the communication with Facebook via the API was successful (i.e. the external results). The next test verifies that the event was emitted and correctly placed in the appropriate stream.

Async worker

This scenario is similar to the one with the service.

require 'rails_helper'

module Reservations
  RSpec.describe RemoveBookingWorker do
    describe '#perform' do
      it 'emits reservations__booking_deleted event' do
        property = create(:property, pms_property_id: 'property_pms_id_1')
        booking = create(
          :booking,
          property:,
          pms_reservation_id: 'reservation_id_1'
        )

        RemoveBookingWorker.new.perform(booking.id)

        expect(event_store).to have_published(
          an_event(Reservations::BookingDeletedEvent).with_data(
            uid: 'reservation_id_1',
            property_pms_id: 'property_pms_id_1'
          )
        ).in_stream('reservations__booking_deleted')
      end
    end
  end
end

Testing event subscriptions

These types of tests are fairly general, but sometimes you want to make sure that all domains that should be subscribing to certain events are actually doing so. Here’s an example of this type of test:

require 'rails_helper'

RSpec.describe Accounts, tag: :accounts do
  it 'subscribes to selected events' do
    expect(Accounts).to subscribe_to(:accounts__account_created).asynchronously
    expect(Accounts).to subscribe_to(
      :accounts__account_scheduled_for_deletion
    ).asynchronously
  end
end

As you can see here, we use our own matcher for this.

Event Handler tests

We have two different types of tests for handlers:

  1. Expecting an outcome - We expect something to happen, such as a change in the database, sending an email, or sending a message. These are often called black-box tests.
  2. Call expectation - We expect something to be called, such as checking to see if a handler has called another service. The complete logic of the service is tested separately. Again, it’s a good practice to test the logic through an integration test. An event enters the system, and we expect a certain behavior to follow.

Testing a method call in an event handler

First, let’s look at a test that checks whether a method or service is called:

require 'rails_helper'

module Accounts
  RSpec.describe AccountsAccountCreatedHandler do
    it 'schedules account creation for all specified integrations' do
      ops_integration = instance_double(OpsIntegration, create!: nil)
      remote_lock_integration = instance_double(RemoteLockIntegration, create!: nil)
      allow(OpsIntegration).to receive(:new).and_return(ops_integration)
      allow(RemoteLockIntegration).to receive(:new).
        and_return(remote_lock_integration)
      stub_const(
        'Accounts::Integrations::INTEGRATION_LIST',
        [OpsIntegration, RemoteLockIntegration]
      )
      account = create(
        :account,
        first_name: 'Tom',
        last_name: 'Cruise',
        email: 'tom@example.com',
        ops_role: 'staff',
        ops_permissions: ['charging_cc_on_file'],
        ops_property_ids: [1]
      )
      event = build(:accounts__account_created_event, account_id: account.id)

      AccountsAccountCreatedHandler.new(event).call!

      expect(OpsIntegration).to have_received(:new).with(account)
      expect(RemoteLockIntegration).to have_received(:new).with(account)
      expect(ops_integration).to have_received(:create!)
      expect(remote_lock_integration).to have_received(:create!)
    end
  end
end

Let’s break down what’s happening here. We get an event indicating that a new account has been created. In this test, we want to verify the creation of external accounts, such as Remote Lock, Facebook, Google, and so on. In this particular test, we expect only two integrations, which are defined in the INTEGRATION_LIST constant. To verify the behavior, we check to see if the two classes, OpsIntegration and RemoteLockIntegration, have been executed.

Yes, this test is tightly coupled to the implementation of the handler, but this approach is helpful to guide the development of the correct logic (following the TDD flow). Since the logic involves communication with external APIs, we don’t actually call those APIs during the test. We just want to make sure that our code is set up to communicate with them correctly.

Testing that something happened in the event handler

require 'rails_helper'

module Accounts
  RSpec.describe AccountsAccountScheduledForDeletionHandler do
    it 'schedules deletion of account integrations for all specified integrations' do
      ops_integration = instance_double(OpsIntegration, remove!: nil)
      remote_lock_integration = instance_double(RemoteLockIntegration, remove!: nil)
      allow(OpsIntegration).to receive(:new).and_return(ops_integration)
      allow(RemoteLockIntegration).to receive(:new).
        and_return(remote_lock_integration)
      stub_const(
        'Accounts::Integrations::INTEGRATION_LIST',
        [OpsIntegration, RemoteLockIntegration]
      )
      account = create(
        :account,
        first_name: 'Tom',
        last_name: 'Cruise',
        email: 'tom@example.com',
        ops_role: 'staff',
        ops_permissions: ['charging_cc_on_file'],
        ops_property_ids: [1]
      )
      event = build(
        :accounts__account_scheduled_for_deletion_event,
        account_id: account.id,
        by: 'john@example.com'
      )

      AccountsAccountScheduledForDeletionHandler.new(event).call!

      expect(OpsIntegration).to have_received(:new).with(account)
      expect(RemoteLockIntegration).to have_received(:new).with(account)
      expect(ops_integration).to have_received(:remove!)
      expect(remote_lock_integration).to have_received(:remove!)
    end

    it 'saves user email in the account record as remover_email' do
      ops_integration = instance_double(OpsIntegration, remove!: nil)
      remote_lock_integration = instance_double(RemoteLockIntegration, remove!: nil)
      allow(OpsIntegration).to receive(:new).and_return(ops_integration)
      allow(RemoteLockIntegration).to receive(:new).
        and_return(remote_lock_integration)
      stub_const(
        'Accounts::Integrations::INTEGRATION_LIST',
        [OpsIntegration, RemoteLockIntegration]
      )
      account = create(
        :account,
        first_name: 'Tom',
        last_name: 'Cruise',
        email: 'tom@example.com',
        ops_role: 'staff',
        ops_permissions: ['charging_cc_on_file'],
        ops_property_ids: [1]
      )
      event = build(
        :accounts__account_scheduled_for_deletion_event,
        account_id: account.id,
        by: 'john@example.com'
      )

      AccountsAccountScheduledForDeletionHandler.new(event).call!

      expect(account.reload.remover_email).to eq('john@example.com')
    end
  end
end

In this example, you can see both types of event handler tests. The first test demonstrates checking a specific method call, while the second test checks a specific behavior - storing information about the person who removed the account.

Summary

That’s all I wanted to share with you about testing event-driven logic. We’ve created some additional matchers and configurations to make testing easier for ourselves. We also have a special approach to testing event subscriptions and handlers. All of these tweaks are for our convenience, to make test creation more enjoyable, and to provide flexibility in the testing process. It’s all about making our lives easier. I hope some of these examples inspire your own journey down the Rails Event Store highway.

Please check out my previous Rails Event Store articles: