Traditionally, Rails gives us a full stack development framework including E2E tests with Selenium to develop websites. Let’s see how to transition an app using Rails’ built in system tests to using Cypress, a new E2E framework built on Node.js, targetting modern JavaScript heavy applications.
A common Rails stack looks like:
Moving to Cypress (at least for the E2E tests), it now looks like:
At first glance, and based on my experience, the stack is a lot less “batteries included”, which is what I like about Rails. I’m continuing to try new things out. This article will
I like each blog post to be independant, and include all the steps to recreate it. If you don’t care about setting up the Rails app with RSpec etc, just grab the repo here and move to the second half.
Note: If you want to skip to the section where I add Cypress, ctrl+f “Installing and Setting Up Cypress”.
Generate the Rails app, skipping MiniTest and using Postgres for the database with rails new cypress_app -T --database=postgresql
. Update group :development, :test
in the Gemfile
:
Add FactoryBot and RSpec and webpacker.
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails', '~> 3.8'
gem 'capybara'
gem 'factory_bot_rails'
gem 'selenium-webdriver'
gem 'webdrivers'
gem 'rack-cors'
end
Then run bundle install
, and generate the binstub and system
folder by running:
rails generate rspec:install && mkdir spec/system
Next. update rails_helper.rb
to let us use FactoryBot
methods directly in our specs. Also, we want to use selenium_chrome_headless
for the specs (before moving to Cypress):
require 'webdrivers'
# ...
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
# ...
config.before(:each, type: :system, js: true) do
driven_by :selenium_chrome_headless
end
end
Initalize the database with rails db:create
. That should have set up RSpec, FactoryBot and installed the dependencies for system tests.
We will make a simple blog app, that lets an anonymous user create a post, which has a title
, body
and category
. We need a Post
and Category
model - create them with the following:
rails g model category name:string && \
rails g model post title:string body:text category:references && \
rails db:migrate
Next, we need a posts_controller
to create posts. Create one with touch app/controllers/posts_controller.rb
. We will come back to this in a moment.
Update models/category.rb
to reflect the has_many
relationship (a category can have many posts):
class Category < ApplicationRecord
has_many :posts
end
Update config/routes.rb
:
Rails.application.routes.draw do
resources :posts
end
Add some code to app/controllers/posts_controller.rb
:
class PostsController < ApplicationController
def new
end
def create
end
def index
end
end
Create some views with
mkdir app/views/posts && \
touch app/views/posts/new.html.erb && \
touch app/views/posts/_form.html.erb && \
touch app/views/posts/show.html.erb && \
touch app/views/posts/index.html.erb
Create a test with touch spec/system/posts_spec.rb
, and add:
require 'rails_helper'
feature 'creates a post', type: :system, js: true do
it 'creates a post' do
visit new_post_url
take_screenshot
expect(1).to eq 1
end
end
Make sure everything is working by running rspec spec/system
. If the test passes, everything is working correctly.
Before moving on to using Cypress, let’s make sure the code is working correctly using the built in system tests, which run using selenium_chrome_headless
. Update spec/system/posts_spec.rb
:
require 'rails_helper'
feature 'Posts', type: :system, js: true do
let!(:category) { create(:category) }
context 'the post is valid' do
it 'creates a post' do
visit new_post_url
fill_in 'post_title', with: 'my great post'
fill_in 'post_body', with: 'body'
select category.name, from: 'post[category_id]'
click_on 'Create Post'
Post.all.reload
expect(Post.count).to eq 1
expect(Post.first.title).to eq 'my great post'
expect(page).to have_content('my great post')
end
end
end
This fails with:
Failures:
1) Posts the post is valid creates a post
Failure/Error: fill_in 'post_title', with: 'my great post'
Capybara::ElementNotFound:
Unable to find field "post_title"
Update app/controllers/posts_controller.rb
first:
class PostsController < ApplicationController
def new
@post = Post.new
end
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post
else
flash[:errors] = @post.errors.full_messages
render :new
end
end
def show
@post = Post.find params[:id]
end
def index
@posts = Post.all
end
private
def post_params
params.require(:post).permit(:title, :body, :category_id)
end
end
Now we need the views. Start with app/views/posts/_form.html.erb
:
<% if flash.present? %>
<% flash[:errors].each do |msg| %>
<%= content_tag :div, msg %>
<% end %>
<% end %>
<%= form_with model: @post, local: true do |f| %>
<%= f.label :title %>
<%= f.text_field :title %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.select :category_id do %>
<%= options_from_collection_for_select(Category.all, :id, :name) %>
<% end %>
<%= f.submit %>
<% end %>
We included a flash message validating the minimum length of a post - we will add this validation in a moment. First, update app/views/posts/new.html.erb
:
<%= render partial: 'form' %>
And lastly, app/views/posts/show.html.erb
:
<h2><%= @post.title %></h2>
<div class="category">
Category: <%= @post.category.name %>
</div>
<div class="body">
<%= @post.body %>
</div>
Now running rspec spec/system
should give us a passing test. Let’s implement two more tests, starting with validating the length of a post title. Update app/models/post.rb
.
class Post < ApplicationRecord
validates :title, { length: { minimum: 5 } }
belongs_to :category
end
Next, update spec/system/posts_spec.rb
:
context 'the post title is too short' do
it 'displays a flash' do
visit new_post_url
fill_in 'post_title', with: 'aaa'
fill_in 'post_body', with: 'body'
select category.name, from: 'post[category_id]'
click_on 'Create Post'
Post.all.reload
expect(Post.count).to eq 0
expect(page).to have_content('too short')
end
end
This should pass, too.
Finally, add the following to app/views/posts/index.html.erb
:
<h3>Posts</h3>
<div class="posts">
<% @posts.each do |post| %>
<div class="post">
<div class="title">
Title: <%= post.title %>
</div>
<div class="body">
Body<%= post.title %>
</div>
<%= link_to 'edit', edit_post_url(post) %>
<hr>
</div>
<% end %>
</div>
This shows a list of posts at /posts
. Lastly, a test in spec/system/posts_spec.rb
:
it 'shows a list of posts' do
5.times { create(:post, category: category) }
visit posts_url
expect(all('.post').length).to eq 5
end
Running rspec spec/system
should yield three passing tests.
Now we have a boring, yet working and well tested Rails app. Let’s proceed to add Cypress and migrate our test suite. Firstly, install Cypress and a few dependecies with:
yarn add cypress axios --dev
Next, following their documentation, add a command to package.json
. Mine package.json
looks like this:
{
"name": "cypress_app",
"private": true,
"dependencies": {},
"devDependencies": {
"axios": "^0.18.0",
"cypress": "^3.1.0"
},
"scripts": {
"cypress:open": "cypress open"
}
}
Finally, run yarn cypress:open
. You should see:
Furthermore, a cypress
folder was created for you.
Let’s migrate the first test - creating a post succesfully - to Cypress. First, start the rails server by running rails server
in a separate terminal from Cypress. Next, create the test with touch cypress/integration/posts.spec.js
, and add the following:
const context = describe
describe('Creates a post', () => {
context('the post is valid', () => {
it('redirects to the created post', () => {
cy.visit('localhost:3000/posts/new')
cy.get('#post_title').type('my post', {force: true})
cy.get('#post_body').type('this is the post body', {force: true})
cy.get('#post_category_id').select('ruby', {force: true})
cy.get('input[type="submit"]').click()
cy.get('.category').contains('Category: ruby')
})
})
})
The Cypress DSL is fairly easy to read. Strictly speaking, {force: true}
should not be necessary. Some of my tests were randomly failing without this, though, so I added it. I’ll investigate this in more detail later.
If you still have the Cypress UI open, search for the test using the search box:
This fails, of course:
Because no categories exist. Before implementing a nice work around, just create one by dropping down into rails console
and running Category.create!(name: 'ruby')
. Now the test passes!
There are some problems:
RAILS_ENV=test
.
Let’s get to work on the first two.
Let’s set up some basic seed data for the tests to use. First, create a seeds
folder containing a test.rb
file by running mkdir db/seeds && touch db/seeds/test.rb
. Inside, add:
ruby = Category.create!(name: 'ruby')
javascript = Category.create!(name: 'javascript')
Post.create!(title: 'Seed Post', body: 'This is a seed post.', category: ruby)
Next, in db/seeds.rb
add:
load(Rails.root.join( 'db', 'seeds', "#{Rails.env.downcase}.rb"))
This will seed the correct seed file based on the current RAILS_ENV
.
Now we have a way to seed data, but no way to clean the database after each test. The way I’ve been handling this is by making a POST request to dedicated /test//clean_database
endpoint before each test, as recommended by Cypress. Let’s make that API. First, update config/routes.rb
:
Rails.application.routes.draw do
# ...
if Rails.env.test?
namespace :test do
post 'clean_database', to: 'databases#clean_database'
post 'seed_posts', to: 'seeds#seed_posts'
end
end
end
Next create the controller and spec: mkdir app/controllers/test && touch app/controllers/test/databases_controller.rb
and mkdir spec/controllers && mkdir spec/controllers/test && touch spec/controllers/test/databases_controller_spec.rb
.
Starting with databases_controller_spec.rb
, add the following:
require 'rails_helper'
describe Test::DatabasesController do
describe '/clean_database' do
it 'truncates and seeds the database' do
category = create(:category)
5.times { |i| create(:post, category: category) }
post :clean_database, params: { 'database': { 'should_seed': true } }
# Seed db/seeds/test for default seeds
# Default 2 categories and 1 post
expect(Post.count).to eq 1
expect(Category.count).to eq 2
end
it 'truncates and seeds the database' do
category = create(:category)
5.times { |i| create(:post, category: category) }
post :clean_database, params: { 'database': { 'should_seed': false } }
expect(Post.count).to eq 0
expect(Category.count).to eq 0
end
end
end
There are two functions this API provides. Both specs test for truncation. We also allow a should_seed
parameter to be provided. If should_seed
is true, then we repopulate the database using the data defined in db/seeds/test.rb
.
The controller implementation is as follows:
module Test
class DatabasesController < ApplicationController
skip_before_action :verify_authenticity_token
def clean_database
tables = ActiveRecord::Base.connection.tables
tables.delete 'schema.migrations'
tables.each { |t| ActiveRecord::Base.connection.execute("TRUNCATE #{t} CASCADE") }
Rails.application.load_seed unless ['false', false].include?(params['database']['should_seed'])
render plain: 'Truncated and seeded database'
end
end
end
This should yield two passing specs. Now, restart the Rails server with RAILS_ENV=test rails server
. Now, we need a way to actually access the API from within Cypress. Inside of cypress/support/commands.js
, add the following:
import axios from 'axios'
Cypress.Commands.add('cleanDatabase', (opts = { seed: true }) => {
return axios({
method: 'POST',
url: 'http://localhost:3000/test/clean_database',
data: { should_seed: opts.seed }
})
})
Cypress automatically loads all the helpers in commands.js
for us.
Since Rails is running on port 3000, and Cypress is assigned an arbitrary port, we need to support CORS for the /test
routes. Inside config/environments/test.rb
, add the following:
if Rails.env.test?
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '/test/*', headers: :any, methods: %i(post)
end
end
end
This allows CORS for the test environment only. Restart the Rails server, and reopen the Cypress UI if you closed it. It should pass… alas, it does not.
If you look closely, only on the initial opening of the Cypress UI, the browser kind of “flickers” once. For some reason, this causes the beforeEach
hook to be called twice, messing up the seed data. The post request contains the category id of the first seed run, however since the browser flickers and causes the data to be reseeded, the initial category id used in the test no longer exists!
Once you have the UI running, however, simply rerunning the test should be enough to pass. Typically I only open the UI once, and leave it open, so it is not a big deal locally. On CI, this is a huge problem though. I’m going to get in contact with the Cypress team and see if they have a work around.
One last thing I want to add is the ability to seed some data, depending on the test. For this, I’ll use another test-env-only controller. Create it with touch app/controllers/test/seeds_controller.rb
. Add a test with touch spec/controllers/test/seeds_controller_spec.rb
. Add the following test:
require 'rails_helper'
describe Test::SeedsController do
describe '/seed_posts' do
it 'seeds posts' do
create(:category)
expect {
post :seed_posts, params: { count: 1 }
}.to change { Post.count }.by 1
end
end
end
This endpoint will simply seed a specified number posts. Now, the implementation in seeds_controller.rb
:
module Test
class SeedsController < ApplicationController
skip_before_action :verify_authenticity_token
def seed_posts
category = Category.create!(name: 'ruby')
count = params[:count] || 0
count.to_i.times do |c|
Post.create!(
title: "Post ##{c}",
body: "This is post ##{c}",
category: category)
end
end
end
end
This test should pass. Here are two more tests - one for the case where a post title is too short, and an error is displayed, and another for the /posts
index page. This once will make use of the new /seed_posts
route, so update commands.js
:
Cypress.Commands.add('seedPosts', (count) => {
return axios({
method: 'POST',
url: 'http://localhost:3000/test/seed_posts',
data: { count }
})
})
Everything passes!
This was a very long article. We covered:
beforeEach
, and custom commands
Cypress is certainly a great tool, and a refreshing new angle on E2E testing. The lack of support for non Chromium based browsers, and of information on how to integrate it with various backends led to some challenges. However, I’m positive Cypress is going in a good direction and will continue to refine my workflow and integration with Rails.