Setting up RSpec and DatabaseCleaner to support multiple databases

Jan Bajena
Productboard engineering
4 min readNov 25, 2021

--

Photo by Marius Niveri

We recently added a second database to our monolithic Rails application, which provides data to our new Reports feature.

Rails framework has an out-of-the-box support for multiple databases, so you might assume that it’d be a relatively straightforward task. It was for production and staging environments. Things, however, got more tricky when it came to setting up the new database in a test environment.

The new database is used in only a single part of the application, so most engineers at Productboard won’t ever have a need to run the reporting database container locally.

Our goal was to:

  • Initialize the reporting database connection only when the DATABASE_REPORTING_URL environment variable is present to prevent developers from running containers that they don't need.
  • Set up and tear down the new database only for tests where it is really needed, so that the impact on CI times is as low as possible.

Let’s break down how we did it:

Adding conditional connection to database.yml

This part was relatively straightforward — Rails preprocesses the database.yml file with ERB, so we just rendered the pb_reporting block conditionally like this:

pb_reporting_default: &pb_reporting_default
adapter: postgresql
migrations_paths: db/pb_reporting_migrate
variables:
statement_timeout: 20000
test:
<% if ENV['DATABASE_REPORTING_URL'] %>
pb_reporting:
<<: *pb_reporting_default
url: <%= ENV['DATABASE_REPORTING_URL'] %>
<% end %>

We also needed to prevent autoloader from crashing for developers who don’t use the reporting database. We did it simply by catching the ActiveRecord::AdapterNotSpecified exception:

module Reporting
class Base < ApplicationRecord
self.abstract_class = true
begin
establish_connection :pb_reporting
rescue ActiveRecord::AdapterNotSpecified => e
Rails.logger.info("Skipping reporting DB connection")
end
end

RSpec & DatabaseCleaner configuration

Similar to thousands of other Ruby apps, we use DatabaseCleaner gem for ensuring a clean database state between tests.

The gem is very simple when it comes to setting up a single database connection. Before introducing our reporting database, our configuration was pretty much a copy-paste from DatabaseCleaner's documentation:

RSpec.configure do |config|
config.before(:suite) do
# Skip DatabaseCleaner's safeguard in order to be able to connect to a database using an URL:
DatabaseCleaner.allow_remote_database_url = true
DatabaseCleaner.clean_with(:deletion)
DatabaseCleaner.strategy = :transaction
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end

Unfortunately, when it comes to handling multiple databases, the documentation isn’t that great. It isn’t instantly clear how the API should be used. Fortunately, after some trial and error, we learned that specifying a setting per each ActiveRecord base model does the job (ApplicationRecord connects to the main database, while Reporting::Base connects to the new reporting database):

RSpec.configure do |config|
config.before(:suite) do
# Skip DatabaseCleaner's safeguard in order to be able to connect to a database using an URL:
DatabaseCleaner.allow_remote_database_url = true
DatabaseCleaner[:active_record, db: ApplicationRecord].clean_with(:deletion)
DatabaseCleaner[:active_record, db: Reporting::Base].clean_with(:deletion)
DatabaseCleaner[:active_record, db: ApplicationRecord].strategy = :transaction
DatabaseCleaner[:active_record, db: Reporting::Base].strategy = :transaction
end
config.before(:each) do
DatabaseCleaner[:active_record, db: ApplicationRecord].start
DatabaseCleaner[:active_record, db: Reporting::Base].start
end
config.after(:each) do
DatabaseCleaner[:active_record, db: ApplicationRecord].clean
DatabaseCleaner[:active_record, db: Reporting::Base].clean
end
end

Using RSpec metadata for conditional cleanup

While the configuration outlined above is correct, it still didn’t meet our goal. Under this set up, the new database would be cleaned up after each test, even if it wasn’t used. This is a waste of resources.

We solved this problem by making use of metadata in RSpec. We agreed that each example or group of examples having the :with_pb_reporting_db tag would trigger a cleanup by DatabaseCleaner.

Here’s an example definition of a describe block:

# Every spec in this block will start with a clean reporting DB
RSpec.describe Reporting::SomeService, :with_pb_reporting_db do
...
end

Now, the RSpec configuration needed a slight update to clean the reporting database up conditionally depending on the :with_pb_reporting_db tag.

Here’s how we did it:

RSpec.configure do |config|
config.before(:suite) do
# Skip DatabaseCleaner's safeguard in order to be able to connect to a database using an URL:
DatabaseCleaner.allow_remote_database_url = true
DatabaseCleaner[:active_record, db: ApplicationRecord].clean_with(:deletion)
DatabaseCleaner[:active_record, db: ApplicationRecord].strategy = :transaction
end
config.when_first_matching_example_defined(with_pb_reporting_db: true) do
config.before(:suite) do
DatabaseCleaner[:active_record, db: Reporting::Base].clean_with(:deletion)
DatabaseCleaner[:active_record, db: Reporting::Base].strategy = :transaction
end
end
config.before(:each) do
DatabaseCleaner[:active_record, db: ApplicationRecord].start
end
config.before(:each, with_pb_reporting_db: true) do
DatabaseCleaner[:active_record, db: Reporting::Base].start
end
config.after(:each, with_pb_reporting_db: true) do
DatabaseCleaner[:active_record, db: Reporting::Base].clean
end
config.after(:each) do
DatabaseCleaner[:active_record, db: ApplicationRecord].clean
end
end

Nice! Now developers from other teams won’t bother us with questions about why they can’t run the test suite 🎉

BONUS: How to remember to add :with_pb_reporting_db tag

While the reporting database would now be cleaned up correctly when the :with_pb_reporting_db tag is present, we still had one problem — what if someone creates a test that uses the reporting database but forgets to add the :with_pb_reporting_db tag? In the best-case scenario, tests would just instantly fail. But such specs could also fail randomly depending on how RSpec orders the examples in the suite.

To prevent this from happening, we came up with a simple solution — whenever a code under test tries to use the reporting database connection and doesn’t have the :with_pb_reporting_db tag, the developer will see a clear error message.

To raise an error message, we just stubbed the connection method on the Reporting::Base — simple, yet powerful 🤓 Here's an example of how to do this:

RSpec.configure do |config|
config.before(:each) do |example|
next if example.metadata[:with_pb_reporting_db]
allow(Reporting::Base).to receive(:connection).and_raise(
"Add ':with_pb_reporting_db' to your spec's metadata if you want to use the reporting DB!"
)
end
end

Time to change the status quo

It’s a pity that so many guides on the Internet completely skip over the part about testing, which often is the most complicated. At Productboard, we are doing our best to change this trend by giving tests the red-carpet treatment they deserve.

Interested in joining our growing team? Well, we’re hiring across the board! Check out our careers page for the latest vacancies.

--

--

Jan Bajena
Productboard engineering

I’m a software developer @ Productboard, mostly interested in building backends using Ruby language. CSS doesn’t make me cry though ;)