External Read-Only Database Setup in RSpec
If you have a Rails application that connects to an external, read-only database, there are a few ways to wire things up in the test suite. In my scenario, I needed some data seeded into that external database to adequately test the flow of my application.
If you are in a similar situation, this article may be of some help. I found several posts in my research but some were outdated, some contained rather complicated and verbose rake task overrides which I ultimately did not need, and others just did not work as advertised when tried in my environment.
After consulting members of my team, here is the solution that we ended up finding.
Context
We have a base class for all active record models that interact with the external database that looks like this.
module MyExternal
class Base < ApplicationRecord
include ReadonlyExternalDatabase
self.abstract_class = true
connects_to database: { reading: :external_database, writing: :external_database }
end
end
ReadonlyExternalDatabase
restricts major writes from going through to this external database as an extra layer of protection.
module ReadonlyExternalDatabase
def readonly?
true
end
def destroy(*)
raise ActiveRecord::ReadOnlyRecord
end
def delete(*)
raise ActiveRecord::ReadOnlyRecord
end
def self.destroy_all(*)
raise ActiveRecord::ReadOnlyRecord
end
def self.delete_all(*)
raise ActiveRecord::ReadOnlyRecord
end
end
Solution
First, the default declaration for this external database has replica: true
since it is pointing to another database.
# database.yml
external_database_default: &external_database_default
<<: *default
replica: true
However, the test database is not a replica.
# database.yml
test:
primary:
<<: *default
database: my_app_test
external_database:
<<: *external_database_default
replica: false
database: external_database_test
Due to the limitation of having a read-only database, seeding the data becomes a bit more difficult. If that isn't a requirement for you, you could just include a schema file for the external database in db/
and run your own seed file against it. Instead of doing a schema load, I decided to load the database schema and populate it with data via an .sql
file. To get this file, I loaded my development copy of this external database to a state that I thought would be good for my test suite, and ran this command.
pg_dump -U -d external_database_development -f external_database_dump.sql
Some fixup was required in this file. I had to go through and replace any instances of the user (which happened to be my name) with "postgres."
I also ran into an error message that indicated I needed to include some additional postgres plugins in that file, which I did.
This SQL file represents the instructions to create the tables and populate them, but it can be very large. I decided to store this file in db/dump
and I opted to gzip the file to save space.
gzip db/dump/external_database_dump.sql
Rake Task
Next, I added a rake task to apply this SQL file.
# lib/tasks/db.rake
namespace :db do
namespace :external do
task load: :environment do
if File.exist?(Rails.root.join(external_database_filename))
system("PGPASSWORD='' gunzip < #{external_database_filename} | psql -U postgres external_database_test")
end
end
end
end
def external_database_filename
'db/dump/external_database_dump.sql.gz'
end
This rake task runs a system command that un-gzips the SQL file and pipes the contents into a psql
command which ultimately executes the SQL.
You may not want to run this before all your tests, since it could be slow. We added a flag that rspec can pick up on which will cause the SQL to only be executed once and if the test being run requires it.
# spec/support/external_database.rb
require 'rake'
Rails.application.load_tasks
RSpec.configure do |config|
config.when_first_matching_example_defined(external_database: true) do
connection = MyExternal::Base.connection
database_not_yet_built = connection.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'").none?
Rake::Task['db:external:load'].invoke if database_not_yet_built
end
end
# example of using the flag
RSpec.describe MyExternal::MyModel, :external_database, type: :model do
And with all that, it was working! Now, in my tests, I can do things like this to interact with the seeded data.
MyExternal::Project.find_by(...)
Semaphore
A few changes needed to be made in the semaphore configuration to get these tests working on CI. Because there was no schema file for the new external database, doing a typical bin/rails db:setup db:test:prepare
would throw an error because ultimately it was trying to load the schema for each attached database. Instead, we did the following.
jobs:
- name: RSpec Unit Tests
commands:
- bin/rails db:create db:schema:load:primary
Hopefully you found this helpful!
Update for Rails 7.1
After upgrading to Rails 7.1.4.2 some issues were uncovered. Setting replica: false
for the test database no longer worked.
This command no longer worked as a check for whether or not the db had been created.
database_not_yet_built = connection.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'").none?
I changed the rspec setup to be the following:
RSpec.configure do |config|
config.before(:suite) do
ApplicationRecord.connection.execute("drop database if exists external_database")
ApplicationRecord.connection.execute("create database external_database")
Rake::Task['db:external:load'].invoke
end
end
Now set the external database back to replica: true
and add database_tasks: false
external_default: &external_default
<<: *default
database_tasks: false
And in CI I changed it from
bin/rails db:create db:schema:load:primary
back to
bundle exec rails db:setup db:test:prepare
Since it is now a replica again, you can delete the schema file for the external database.
Subscribe to my newsletter
Read articles from Josh McLeod directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by