The one about using factories with entities in Sinatra

I mentioned in a previous post that in the project I am currently working on we do not use Rails in most of the applications. There are some exceptions in certain legacy applications that represent the core of the project, from which we have been progressively extracting functionalities and creating new microservices with Sinatra.

In the case of my team, part of our focus so far has been on the new payouts application that I also mentioned in another post, which allows us to automate certain payments.

In that application, Sequel provides the ORM layer for mapping database records to Ruby objects. We do not use models like in Rails inheriting from ActiveRecord, but entities that inherit from the Data class, added to the Ruby core in version 3.2.

BankAccount < Data.define(:account_code, :bank, :currency_code, :country)

When I joined the team, we used factories in tests with FactoryBot:

FactoryBot.define do
  factory :bank_account, class: BankAccount do
    account_code { "CITI-00000001-USD" }
    bank { "citibank" }
    currency_code { "USD" }
    country { "US" }
  end
end

However, those factories were only used to generate instances of the entities:

bank_account = FactoryBot.build(:bank_account, account_code: "CITI-00000001-EUR", currency_code: "EUR", country: "ES")

I was quite surprised that those factories were not used to create the necessary records in the database for every single test example.

The records were defined with the required values in a module:

module Test
  module Helpers
    module BankAccountStorage
      def seed_bank_account_storage
        Database.connection do |db|
          db[:bank_accounts].insert(account_code: "CITI-123456789-USD", bank: "citibank", currency_code: "USD", country: "US")
          db[:bank_accounts].insert(account_code: "CITI-123456789-EUR", bank: "citibank", currency_code: "EUR", country: "ES")
          db[:bank_accounts].insert(account_code: "CITI-123456789-GBP", bank: "citibank", currency_code: "GBP", country: "GB")
          # ...more records...
        end
      end
    end
  end
end

Database.connection is just a custom wrapper around a Sequel connection.

The module was then included in the corresponding test file:

require "spec_helper"
require_relative "../test/helpers/bank_account_storage"

describe "POST /payments/initiate" do
  include Test::Helpers::BankAccountStorage

  before do
    seed_bank_account_storage
  end

  context "when paying in USD" do
  # ...

The way I see it, doing that is not maintainable and would not scale well, for several reasons:

  • We are generating more records than needed for each example. Therefore, each test is slower than necessary.

  • We do not have control over the attributes of each of the records created.

  • We are adding indirection within each test file where that module is used, because we are asserting over values that are not defined neither in the test example nor in the test file itself.

I would even prefer to use fixtures rather than keep creating records that way, but only if I knew that the code would never change. And we all know that rarely happens.

When it was my turn to refactor a part of that application, I decided it was time to change that approach as a previous step to introducing new changes.

Of course, the first step was to delete those modules and start persisting records in the database with FactoryBot:

bank_account = FactoryBot.create(account_code: "CITI-123456789-USD", bank: "citibank", currency_code: "USD", country: "US")

However, when I ran the test suite, I found errors like the following:

Failure/Error: bank_account = FactoryBot.create(account_code: "CITI-123456789-USD", bank: "citibank", currency_code: "USD", country: "US")

NoMethodError:
undefined method `save!' for an instance of BankAccount
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/evaluation.rb:15:in `create'
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/strategy/create.rb:12:in `block in result'
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/strategy/create.rb:9:in `result'
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/factory.rb:43:in `run'
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/factory_runner.rb:29:in `block in run'
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/factory_runner.rb:28:in `run'
# /usr/local/bundle/gems/factory_bot-6.4.6/lib/factory_bot/strategy_syntax_method_registrar.rb:28:in `block in define_singular_strategy_method'
# ...

Of course, I have always used FactoryBot in Rails applications where each factory is associated with models inheriting from ActiveRecord.

Our entities do not implement the save! method, which is the default implementation of the to_create method:

to_create { |obj, context| obj.save! }

Therefore, I had to override that behavior for all our factories.

In this case, I did it in the FactoryBot support file:

require "factory_bot"

RSpec.configure do |config|
  config.before(:suite) do
    FactoryBot.find_definitions
    FactoryBot.define do
      initialize_with { new(**attributes) }

      to_create do |obj, context|
        Database.connection do |db|
          if !context.respond_to?(:table_name)
            raise NotImplementedError,
              "define a transient table_name attribute inside the factory with the name of the database table"
          end

          db[context.table_name.to_sym].insert(obj.to_h)
        end
      end
    end
  end
end

In the factory we define the transient attribute table_name:

FactoryBot.define do
  factory :bank_account, class: BankAccount do
    transient do
      table_name { BankAccountRepository::TABLE_NAME }
    end

    account_code { "CITI-00000001-USD" }
    bank { "citibank" }
    currency_code { "USD" }
    country { "US" }
  end
end

Since it is related to persistence, I decided to define the constant with the name of the table in the corresponding repository:

class BankAccountRepository
  TABLE_NAME = :bank_accounts
  # ...

And that is all. With those simple changes we already have at our disposal all the power that FactoryBot.create offers.

It is important that the error message that would appear in case of using the create strategy without having the transient attribute table_name defined, allow anyone to fix that error very quickly.

I must say that the team welcomed the new changes with open arms.

Thank you for reading and see you in the next one!

0
Subscribe to my newsletter

Read articles from David Montesdeoca directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

David Montesdeoca
David Montesdeoca

I love learning new stuff, especially when it comes to building software. I'm really interested in software architecture, clean code, testing and best practices.