There are many reasons I opt to use Ruby on Rails for web apps. One of the reasons is that the framework provides you with all the gear to write tests right out of the gate. The problem is that end-to-end tests become quite messy once you introduce a frontend library (like React) while only using Rails as a backend.
This is the case I found myself in on my current project. But after a bit of experimenting, I found a way to develop end-to-end tests. I’ll show you how I’ve implemented a solution using Cypress and leveraging the multi-database support that’s in Rails 6 and later.
What This Setup Will Do
This setup will:
- work with a frontend and backend running in two separate containers
- maintain a single set of fixtures on the Rails side, none in Cypress
- use a single development environment to run the web app and to run the Cypress tests against
- switch the database connection when the Cypress tests are running so it won’t muddy up the data in the other database environments
If this setup sounds useful to you, keep reading! I’ll go over the steps to set this up below.
Rails
Set Up Multi-Database Connections
This is where the real magic is. By using multi-database connections, you can default your Rails app to talk to your development database as it’s defined in your database.yaml
file. Plus, you can switch to your test database (as defined in the same file) on a per-request basis.
To do this, you’ll first need to update your database.yml
file to allow Rails to access your test database while the app is in development mode, i.e., RAILS_ENV=development
# config/database.yml
default: &default
adapter: mysql2
url: <%= ENV['MYSQL_CONNECTION'] %>
pool: 5
timeout: 5000
test: &test
<<: *default
database: webapp_test
development:
primary:
<<: *default
database: webapp
test:
<<: *test
# production block
Next, you’ll update the ApplicationRecord base model to make the ORM aware of the multiple database connections you previously defined. Note that the development environment now has a primary
and test
connection.
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :primary, testing: :test }
end
Set Up Routes and Controllers for Cypress to Call
Now that ActiveRecord is aware of the multiple database connections, it’s time to create the logic to switch the database connection if a request comes from Cypress. For this, you’ll need to create a new around_action hook in your ApplicationController base controller to switch to the test database when a certain browser user-agent is detected.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
around_action :switch_db
def switch_db(&block)
if request.headers["User-Agent"] =~ /Electron/
Rails.logger.warn "switching to test db"
ActiveRecord::Base.connected_to(role: :testing) do
yield
end
else
yield
end
end
end
Note: This logic will only work if you run your Cypress tests through an Electron browser. This isn't ideal, though I have not found a better way to intercept each API request in Cypress to add a custom HTTP header like x-application-test-id
. When trying to do so, I run into the same problem as described in this StackOverflow post. Anyway, moving on...
This Rails app now has the ability to switch the database connection on a per-request basis.
Next, you need to implement an endpoint to initialize the test database. This endpoint will be called at the beginning of your Cypress end-to-end tests. Below is a simple controller and action that does the following:
- drop the database
- create the database
- run the migrations
- load the fixtures data into the schema
# app/controllers/testing_controller.rb
require 'active_record/fixtures'
class TestingController < ApplicationController
def reset_database
# DANGEROUS ensure this only runs on databases with test in the name
raise if ActiveRecord::Base.connection.current_database !~ /test/
# drop db
ActiveRecord::Tasks::DatabaseTasks.drop(Rails.configuration.database_configuration["test"])
# create db
ActiveRecord::Tasks::DatabaseTasks.create(Rails.configuration.database_configuration["test"])
# migrate db
ActiveRecord::Tasks::DatabaseTasks.migrate
# load fixtures
`RAILS_ENV=test rails db:fixtures:load`
end
end
Precautions
As you probably noticed, adding these kinds of database operations to an endpoint is scary. For this, I added a raise statement if the following controller action is called and the current database is not the test database. I also added a conditional in the Rails routes to make it impossible to hit this controller action in production. See the code example below.
# config/routes.rb
Rails.application.routes.draw do
post "/api/reset_database", {action: :reset_database, controller: :testing} unless Rails.env.production?
end
Next, I’ll show you how to configure Cypress to wrap up this example.
Cypress
Finally, you’ll just need to register a before
function to call the Rails endpoint that initializes the test database. Here’s the code you can put in the cypress/suppport/e2e.js
configuration file:
// cypress/support/e2e.js
before(() => {
return cy.request({
url: `${Cypress.config().baseUrl}/api/reset_database`,
method: "POST",
timeout: 120000, // 2 minutes
});
});
Using this before
hook, Cypress will make a single POST /api/reset_database
call to your API and wait for it to complete as the first step of running your test suite.
You may need to change the timeout on the request to ensure Cypress gives the API sufficient time to complete the test database initialization. Two minutes is more than enough for my application.
That's it!
Switch Rails Database Connections While Running Cypress Tests
This implementation is definitely experimental as I haven’t seen any examples or read any articles of others using Rails database switching in this fashion. Most of the articles online show how to use it for performance benefits through sharding and reader/writer replica connections. But, this implementation worked for my project and made for a nice dev experience. We can simply run docker-compose up
and develop the app and all the tests against that docker network.