Switch Rails Database Connections While Running Cypress Tests

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:

  1. drop the database
  2. create the database
  3. run the migrations
  4. 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.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *