Adding a Test Environment to the Active Record Rake Tasks Gem

This post was originally published on my previous blog jer-k.github.io

Published on 2018-03-12

Continuing to work on our gem with active_record rake tasks, we still need to set up a testing environment that can be run locally and in a repeatable fashion for continuous integration; we'll accomplish the latter using a simple Dockerfile. But first let's make it easier for someone to start using the gem by enhancing the scripts in bin/.

We'll start off by changing bin/setup to create the user and the database.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx

bundle install

psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='gem_with_database'" | grep -q 1 || \
psql -c "create role gem_with_database with superuser login password 'password'"

psql -tAc "SELECT 1 FROM pg_database WHERE datname='gem_with_database_development'" | grep -q 1 || \
rake db:create db:migrate db:seed
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx

bundle install

psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='gem_with_database'" | grep -q 1 || \
psql -c "create role gem_with_database with superuser login password 'password'"

psql -tAc "SELECT 1 FROM pg_database WHERE datname='gem_with_database_development'" | grep -q 1 || \
rake db:create db:migrate db:seed

The first command queries the pg_roles table looking to see if there is a role named gem_with_database and returns 1 if so. The result is piped into grep looking for the 1, if it is found we stop, otherwise we issue another command to create the gem_with_database role. If you're curious as to how this works, grep returns a non-zero exit code if it doesn't find something and a bash || only evaluates the right-hand side of the expression if the left-hand side has a non-zero value. We follow the same pattern in the second command and look to see if a database named gem_with_database_development exists; if it doesn't we create it and add our data.

Once the database is created and has data in it, we want to start playing around with our models and we can ensure everything is ready by modifying bin/console.

#!/usr/bin/env ruby

require 'bundler/setup'
require 'gem_with_database'
require 'active_record'
require 'logger'
require 'pry'

ActiveRecord::Base.establish_connection(
:adapter => 'postgresql',
:database => 'gem_with_database_development'
)
ActiveRecord::Base.logger = Logger.new(STDOUT)

Pry.start
#!/usr/bin/env ruby

require 'bundler/setup'
require 'gem_with_database'
require 'active_record'
require 'logger'
require 'pry'

ActiveRecord::Base.establish_connection(
:adapter => 'postgresql',
:database => 'gem_with_database_development'
)
ActiveRecord::Base.logger = Logger.new(STDOUT)

Pry.start

We simply require the needed gems to establish a connection to the database, setup logging so we can see the results of the queries, and my personal preference is to use a Pry console. With that a user can clone the repository, run bin/setup and then bin/console and be able to query data!

[1] pry(main)> GemWithDatabase::Book.first
D, [2018-03-10T17:49:22.051607 #31929] DEBUG -- : GemWithDatabase::Book Load (4.5ms) SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> #<GemWithDatabase::Book:0x00007f8e9cdf6e70
id: 1,
title: "A Game of Thrones",
pages: 694,
published: 1996,
author_id: 1>
[1] pry(main)> GemWithDatabase::Book.first
D, [2018-03-10T17:49:22.051607 #31929] DEBUG -- : GemWithDatabase::Book Load (4.5ms) SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> #<GemWithDatabase::Book:0x00007f8e9cdf6e70
id: 1,
title: "A Game of Thrones",
pages: 694,
published: 1996,
author_id: 1>

Now that we have everyone up and running with a development environment, we need to setup our testing environment. The first thing we will do is add FactoryBot so we can create mock data.

spec.add_dependency 'factory_bot', `~> 4`
spec.add_dependency 'factory_bot', `~> 4`

It is added as a normal dependency because we can actually export our factories so that anyone who uses the gem gets an added bonus of being able to create mock data right off the bat, instead of having to define their own.

We'll create lib/gem_with_database/factories/author.rb.

require 'factory_bot'

FactoryBot.define do
factory :gem_with_database_author, class: GemWithDatabase::Author do
name 'Test Author'
age 1
end
end
require 'factory_bot'

FactoryBot.define do
factory :gem_with_database_author, class: GemWithDatabase::Author do
name 'Test Author'
age 1
end
end

The name of the factory is prefixed with the name of the gem to ensure that we aren't going to create a collision if someone has already defined a factory named book in their application. Now we need to expose the factory in lib/gem_with_database.rb and we can move onto setting up our test database.

require 'gem_with_database/factories/author'
require 'gem_with_database/factories/author'

We'll modify spec/spec_helper.rb to create a database in the test environment for us to use.

ENV['ENV'] = 'test' # Ensure we don't drop the development database

require 'bundler/gem_tasks'
require_relative '../support/active_record_rake_tasks'
task :environment

Rake::Task['db:drop'].invoke
Rake::Task['db:create'].invoke
Rake::Task['db:schema:load'].invoke
ENV['ENV'] = 'test' # Ensure we don't drop the development database

require 'bundler/gem_tasks'
require_relative '../support/active_record_rake_tasks'
task :environment

Rake::Task['db:drop'].invoke
Rake::Task['db:create'].invoke
Rake::Task['db:schema:load'].invoke

First and foremost we need to set the ENV to test to ensure that we're targeting only the test database; remember we set up the DatabaseTasks.env to read from ENV['ENV']. Then we load the needed files to invoke our rake tasks, stub out the task :environment like we did in the Rakefile, and create a new database with our schema. Let's write a test for our Author class at spec/models/author_spec.rb and try it out.

require 'spec_helper'

RSpec.describe GemWithDatabase::Author do
it 'is a test author' do
author = FactoryBot.create(:gem_with_database_author)
expect(author.name).to eq('Test Author')
end
end
require 'spec_helper'

RSpec.describe GemWithDatabase::Author do
it 'is a test author' do
author = FactoryBot.create(:gem_with_database_author)
expect(author.name).to eq('Test Author')
end
end
$ rspec
Dropped database 'gem_with_database_test'
Created database 'gem_with_database_test'
-- enable_extension("plpgsql")
-> 0.0392s
-- create_table("authors", {:force=>:cascade})
-> 0.0093s
-- create_table("books", {:force=>:cascade})
-> 0.0112s
-- add_foreign_key("books", "authors")
-> 0.0209s

GemWithDatabase
has a version number

GemWithDatabase::Author
is a test author

Finished in 0.02844 seconds (files took 2.13 seconds to load)
2 examples, 0 failures
$ rspec
Dropped database 'gem_with_database_test'
Created database 'gem_with_database_test'
-- enable_extension("plpgsql")
-> 0.0392s
-- create_table("authors", {:force=>:cascade})
-> 0.0093s
-- create_table("books", {:force=>:cascade})
-> 0.0112s
-- add_foreign_key("books", "authors")
-> 0.0209s

GemWithDatabase
has a version number

GemWithDatabase::Author
is a test author

Finished in 0.02844 seconds (files took 2.13 seconds to load)
2 examples, 0 failures

Success! We've created a re-usable database for the test environment and our tests are passing. The last thing we want to do is set up a way to run our tests in a continuous integration environment so that when the popularity of the gem has exploded and the number of contributors skyrockets, we're able to ensure no one is committing broken code. We'll do this by creating a Dockerfile, utilizing Docker Compose, and a few helpful scripts. However, please bear with me, I am by no means an expert with Docker; I was able to fumble my way through this and get it working so if my explanations aren't as thorough I apologize.

First, the Dockerfile.

FROM ruby:2.5
WORKDIR /usr/src/app/

#Copy the gem files into the WORKDIR
COPY gem_with_database.gemspec .
COPY Gemfile .
COPY lib/gem_with_database/version.rb lib/gem_with_database/

RUN bundle check || bundle install

# Install psql so bin/wait_for_pg.sh will wait for the database to be up and running
# Get the Key
RUN wget --quiet https://www.postgresql.org/media/keys/ACCC4CF8.asc
RUN apt-key add ACCC4CF8.asc

# Add the Source List
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list

# Update and Install
RUN apt-get update && apt-get -y install postgresql-client-9.6

#Copy the project into the WORKDIR
COPY . .
FROM ruby:2.5
WORKDIR /usr/src/app/

#Copy the gem files into the WORKDIR
COPY gem_with_database.gemspec .
COPY Gemfile .
COPY lib/gem_with_database/version.rb lib/gem_with_database/

RUN bundle check || bundle install

# Install psql so bin/wait_for_pg.sh will wait for the database to be up and running
# Get the Key
RUN wget --quiet https://www.postgresql.org/media/keys/ACCC4CF8.asc
RUN apt-key add ACCC4CF8.asc

# Add the Source List
RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list

# Update and Install
RUN apt-get update && apt-get -y install postgresql-client-9.6

#Copy the project into the WORKDIR
COPY . .

The ruby:2.5 image is the latest as of writing so we'll use that and we set the WORKDIR to /usr/src/app. Next we copy in the .gemspec file, the Gemfile, and the version.rb because it is referenced in the .gemspec. Then we run bundle check || bundle install which will check to see if we need to run bundle install or not, hopefully saving time and not requiring a full install of all the gems each time we use the container. Next, I want to install postgresl-client so that we have access to psql and can run the wait_for_pg.sh script below. I slightly modified the Docker instructions for installing Postgresql. Finally, we copy in the entire contents of the gem.

The docker-compose.yml adds the entrypoint which takes in a parameter, the name of the postgres database we defined, db. Otherwise, we add the environment variables for postgres and builds the image.

version: "2"
services:
app:
entrypoint: ./bin/wait_for_pg.sh db
build: ..
volumes:
- .:/app
environment:
POSTGRES_HOST: db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
depends_on:
- db
db:
image: postgres
ports:
- "5432"
version: "2"
services:
app:
entrypoint: ./bin/wait_for_pg.sh db
build: ..
volumes:
- .:/app
environment:
POSTGRES_HOST: db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
depends_on:
- db
db:
image: postgres
ports:
- "5432"

We'll create a bin/wait_for_pg.sh script, which the code was taken from the Control startup order in Compose page.

#!/bin/bash

set -e

host="$1"
shift
cmd="$@"

until psql -h "$host" -U "postgres" -c '\l'; do
>&2 echo "Postgres is unavailable - sleeping"
sleep 1
done

>&2 echo "Postgres is up - executing command"
exec $cmd
#!/bin/bash

set -e

host="$1"
shift
cmd="$@"

until psql -h "$host" -U "postgres" -c '\l'; do
>&2 echo "Postgres is unavailable - sleeping"
sleep 1
done

>&2 echo "Postgres is up - executing command"
exec $cmd

One last script to run our tests which will be bin/ci.sh.

#!/bin/bash -e

docker-compose build --pull
docker-compose run \
-e "RAILS_ENV=test" \
app bundle exec rake spec
#!/bin/bash -e

docker-compose build --pull
docker-compose run \
-e "RAILS_ENV=test" \
app bundle exec rake spec

We tell docker-compose to build our image and pass --pull to ensure it always pulls the most up-to-date base image. Then we tell docker-compose to run app bundle exec rake spec, using -e to pass an environment variable of RAILS_ENV=test. We can now run bin/ci.sh and we should see Docker build everything and run our tests!

$ bin/ci.sh
db uses an image, skipping
Building app
Step 1/11 : FROM ruby:2.5
2.5: Pulling from library/ruby
Digest: sha256:ed5fc221d5d03d89e1f8c1f7780b98bc708e68b4d8dba73594d017e999156619
Status: Image is up to date for ruby:2.5
---> bae0455cb2b9
Step 2/11 : WORKDIR /usr/src/app/
---> Using cache
---> 6f3c5f15ac42
Step 3/11 : COPY gem_with_database.gemspec .
---> Using cache
---> eb3ecc2ee4cf
Step 4/11 : COPY Gemfile .
---> Using cache
---> cc6f4fb53b10
Step 5/11 : COPY lib/gem_with_database/version.rb lib/gem_with_database/
---> Using cache
---> 687de04b34df
Step 6/11 : RUN bundle check || bundle install
---> Using cache
---> bc457854c58b
Step 7/11 : RUN wget --quiet https://www.postgresql.org/media/keys/ACCC4CF8.asc
---> Using cache
---> 889e0f4155b1
Step 8/11 : RUN apt-key add ACCC4CF8.asc
---> Using cache
---> 9c312fe432e9
Step 9/11 : RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list
---> Using cache
---> 2cc90fde777c
Step 10/11 : RUN apt-get update && apt-get -y install postgresql-client-9.6
---> Using cache
---> b50c64265bb8
Step 11/11 : COPY . .
---> 647917bd7233
Successfully built 647917bd7233
Successfully tagged gemwithdatabase_app:latest
Starting gemwithdatabase_db_1 ... done
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
------------------------+----------+----------+------------+------------+-----------------------
gem_with_database_test | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
(4 rows)

Postgres is up - executing command
/usr/local/bin/ruby -I/usr/local/bundle/gems/rspec-core-3.7.0/lib:/usr/local/bundle/gems/rspec-support-3.7.0/lib /usr/local/bundle/gems/rspec-core-3.7.0/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb
Dropped database 'gem_with_database_test'
Created database 'gem_with_database_test'
-- enable_extension("plpgsql")
-> 0.0256s
-- create_table("authors", {:force=>:cascade})
-> 0.0503s
-- create_table("books", {:force=>:cascade})
-> 0.0251s
-- add_foreign_key("books", "authors")
-> 0.0120s

GemWithDatabase
has a version number

GemWithDatabase::Author
is a test author

Finished in 0.03851 seconds (files took 2.74 seconds to load)
2 examples, 0 failures
$ bin/ci.sh
db uses an image, skipping
Building app
Step 1/11 : FROM ruby:2.5
2.5: Pulling from library/ruby
Digest: sha256:ed5fc221d5d03d89e1f8c1f7780b98bc708e68b4d8dba73594d017e999156619
Status: Image is up to date for ruby:2.5
---> bae0455cb2b9
Step 2/11 : WORKDIR /usr/src/app/
---> Using cache
---> 6f3c5f15ac42
Step 3/11 : COPY gem_with_database.gemspec .
---> Using cache
---> eb3ecc2ee4cf
Step 4/11 : COPY Gemfile .
---> Using cache
---> cc6f4fb53b10
Step 5/11 : COPY lib/gem_with_database/version.rb lib/gem_with_database/
---> Using cache
---> 687de04b34df
Step 6/11 : RUN bundle check || bundle install
---> Using cache
---> bc457854c58b
Step 7/11 : RUN wget --quiet https://www.postgresql.org/media/keys/ACCC4CF8.asc
---> Using cache
---> 889e0f4155b1
Step 8/11 : RUN apt-key add ACCC4CF8.asc
---> Using cache
---> 9c312fe432e9
Step 9/11 : RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ precise-pgdg main" > /etc/apt/sources.list.d/pgdg.list
---> Using cache
---> 2cc90fde777c
Step 10/11 : RUN apt-get update && apt-get -y install postgresql-client-9.6
---> Using cache
---> b50c64265bb8
Step 11/11 : COPY . .
---> 647917bd7233
Successfully built 647917bd7233
Successfully tagged gemwithdatabase_app:latest
Starting gemwithdatabase_db_1 ... done
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
------------------------+----------+----------+------------+------------+-----------------------
gem_with_database_test | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
(4 rows)

Postgres is up - executing command
/usr/local/bin/ruby -I/usr/local/bundle/gems/rspec-core-3.7.0/lib:/usr/local/bundle/gems/rspec-support-3.7.0/lib /usr/local/bundle/gems/rspec-core-3.7.0/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb
Dropped database 'gem_with_database_test'
Created database 'gem_with_database_test'
-- enable_extension("plpgsql")
-> 0.0256s
-- create_table("authors", {:force=>:cascade})
-> 0.0503s
-- create_table("books", {:force=>:cascade})
-> 0.0251s
-- add_foreign_key("books", "authors")
-> 0.0120s

GemWithDatabase
has a version number

GemWithDatabase::Author
is a test author

Finished in 0.03851 seconds (files took 2.74 seconds to load)
2 examples, 0 failures

We've successfully created our testing environment and got it running in Docker (I did run into some issues with needing to add specific versions of gems.) Hopefully this provides a good blueprint to follow for the next gem you create that might need access to a database and a testing environment.