E2E on Rails documentation: https://e2eonrails.com
E2E on Rails is the public brand for this project. The 1.x gem is still installed as
cypress-on-rails; the stagede2e_on_railsgem flip comes later.
This project is sponsored by the software consulting firm ShakaCode, creator of the React on Rails Gem.
ShakaCode focuses on helping Ruby on Rails teams use React and Webpack better. We can upgrade your project and improve your development and customer experiences, allowing you to focus on building new features or fixing bugs instead.
For an overview of working with us, see our Client Engagement Model article and how we bill for time.
We also specialize in helping development teams lower infrastructure and CI costs. Check out our project Control Plane Flow, which can allow you to get the ease of Heroku with the power of Kubernetes and big cost savings.
If you think ShakaCode can help your project, click here to book a call with Justin Gordon, the creator of React on Rails and Shakapacker.
Here's a testimonial of how ShakaCode can help from Florian Gößler of Blinkist, January 2, 2023:
Hey Justin 👋
I just wanted to let you know that we today shipped the webpacker to shakapacker upgrades and it all seems to be running smoothly! Thanks again for all your support and your teams work! 😍
On top of your work, it was now also very easy for me to upgrade Tailwind and include our external node_module based web component library which we were using for our other (more modern) apps already. That work is going to be shipped later this week though as we are polishing the last bits of it. 😉
Have a great 2023 and maybe we get to work together again later in the year! 🙌
Read the full review here.
Feel free to engage in discussions around this gem at our Slack Channel or our forum category for Cypress.
Need help with cypress-on-rails? Contact Justin Gordon.
Consider first learning the basics of Cypress before attempting to integrate with Ruby on Rails.
Consider first learning the basics of Playwright before attempting to integrate with Ruby on Rails.
# 1. Add to Gemfile
gem 'cypress-on-rails', '~> 1.0'
# 2. Install and generate
bundle install
bin/rails g cypress_on_rails:install
# 3. Run tests (new rake tasks!)
bin/rails cypress:open # Open Cypress UI
bin/rails cypress:run # Run headlessFor Playwright:
bin/rails g cypress_on_rails:install --framework playwright
bin/rails playwright:open # Open Playwright UI
bin/rails playwright:run # Run headlessGem for using cypress.io or playwright.dev in Rails and Ruby Rack applications to control state as mentioned in Cypress Best Practices.
It allows you to run code in the context of the application when executing Cypress or Playwright tests. Do things like:
- use database_cleaner before each test
- seed the database with default data for each test
- use factory_bot to set up data
- create scenario files used for specific tests
Has examples of setting up state with:
- factory_bot
- rails test fixtures
- scenarios
- custom commands
- E2E on Rails Docs - Canonical docs and landing page
- Best Practices Guide - Recommended patterns and practices
- Troubleshooting Guide - Solutions to common issues
- Playwright Guide - Complete Playwright documentation
- VCR Integration Guide - HTTP recording and mocking
- DX Improvements - Recent improvements based on user feedback
Add this to your Gemfile:
group :test, :development do
gem 'cypress-on-rails', '~> 1.0'
endStarting with 1.21.0 the gem is also published as e2e_on_rails, the future 2.0 name (see ADR-0001).
Generate the boilerplate code using:
# by default installs only cypress
bin/rails g cypress_on_rails:install
# if you have/want a different cypress folder (default is e2e)
bin/rails g cypress_on_rails:install --install_folder=spec/cypress
# to install playwright instead of cypress
bin/rails g cypress_on_rails:install --framework playwright
# if you target the Rails server with a path prefix to your URL
bin/rails g cypress_on_rails:install --api_prefix=/api
# if you want to install with npm instead
bin/rails g cypress_on_rails:install --install_with=npm
# if you already have cypress installed globally
bin/rails g cypress_on_rails:install --install_with=skip
# to update the generated files run
bin/rails g cypress_on_rails:install --install_with=skipThe generator creates the following structure in your application:
For Cypress:
e2e/
cypress.config.js # Cypress configuration
e2e_helper.rb # Helper code for factory_bot, database_cleaner, etc.
app_commands/ # Your custom commands and scenarios
clean.rb
factory_bot.rb
scenarios/
basic.rb
fixtures/
vcr_cassettes/ # VCR recordings (if using VCR)
cypress/
support/
index.js
commands.js
on-rails.js # Cypress on Rails support code
e2e/
rails_examples/ # Example tests
For Playwright:
e2e/
playwright.config.js # Playwright configuration
e2e_helper.rb # Helper code for factory_bot, database_cleaner, etc.
app_commands/ # Your custom commands and scenarios (shared with Cypress)
fixtures/
vcr_cassettes/ # VCR recordings (if using VCR)
playwright/
support/
index.js
on-rails.js # Playwright on Rails support code
e2e/
rails_examples/ # Example tests
Additional files:
config/initializers/cypress_on_rails.rb- Configuration for Cypress on Rails
Important: Note that e2e_helper.rb and app_commands/ are at the root of the install folder (e.g., e2e/), NOT inside the framework subdirectory (e.g., e2e/cypress/). This allows both Cypress and Playwright to share the same commands and helpers when using both frameworks.
If you are not using database_cleaner look at e2e/app_commands/clean.rb.
If you are not using factory_bot look at e2e/app_commands/factory_bot.rb.
Now you can create scenarios and commands that are plain Ruby files that get loaded through middleware, the ruby sky is your limit.
When writing and running tests on your local computer, it's recommended to start your server in development mode so that changes you make are picked up without having to restart your local server.
It's recommended you update your database.yml to check if the CYPRESS environment variable is set and switch it to the test database
otherwise, cypress will keep clearing your development database.
For example:
development:
<<: *default
database: <%= ENV['CYPRESS'] ? 'my_db_test' : 'my_db_development' %>
test:
<<: *default
database: my_db_testCypressOnRails mounts a rack middleware that runs the ruby files in your
app_commands folder, and the generated eval.rb command runs whatever ruby the
test runner sends it. Anything that can reach those endpoints can therefore execute
arbitrary code, truncate your database, and read whatever the Rails process can read.
Treat it like web-console or better_errors: a development tool that must never be
reachable from production or from an untrusted network.
The endpoints are /__e2e__/command (plus the deprecated /__cypress__/command), the
VCR insert/eject endpoints, and the state reset endpoints
(/__cypress__/reset_state, /cypress_rails_reset_state).
Defaults. The library default for use_middleware is now "everywhere except
Rails.env.production?". It is resolved lazily, when the middleware would be mounted,
so it does not depend on load order: an app that configures the gem by hand still gets
the right answer. The generated initializer keeps the explicit
c.use_middleware = !Rails.env.production? line. An explicit value always wins, so
setting c.use_middleware = true in production re-enables remote code execution — don't.
The default only reads Rails.env, so plain rack apps must opt out themselves.
Without Rails there is nothing for the default to inspect — RACK_ENV=production is
not consulted, and the default resolves to enabled. The railtie is also the only
thing that applies use_middleware?, so mounting CypressOnRails::Middleware yourself
in config.ru bypasses the check even when it is set to false. Outside Rails, set
c.use_middleware explicitly and mount conditionally, as the
rack example does.
Still be careful outside production. Binding your server to 0.0.0.0, running the
gem on a shared review app, or serving a permissive CORS policy makes these endpoints
reachable by other machines on your network, or by any web page your browser visits.
Set a shared secret and every command, VCR and state reset request must carry it:
CypressOnRails.configure do |c|
# defaults to ENV['CYPRESS_ON_RAILS_TOKEN'], and is disabled when that is not set
c.middleware_token = ENV['CYPRESS_ON_RAILS_TOKEN']
endRequests without a matching X-Cypress-On-Rails-Token header are answered with
403 {"message":"invalid or missing token"}; the comparison is constant time. A blank
value counts as unset and leaves the check disabled — worth knowing if your CI expands a
missing secret to an empty string. The generated on-rails.js helpers send the header
for you when the value is present:
# the same value for the rails server and for the test runner
export CYPRESS_ON_RAILS_TOKEN=$(openssl rand -hex 16)- Cypress: the generated helpers read
Cypress.env('CYPRESS_ON_RAILS_TOKEN')and fall back toCypress.env('ON_RAILS_TOKEN'), so all three of these work:cypress.env.jsoncontaining{ "CYPRESS_ON_RAILS_TOKEN": "..." }- the plain
export CYPRESS_ON_RAILS_TOKEN=...shown above — Cypress strips theCYPRESS_prefix from OS environment variables, so the helpers see it asON_RAILS_TOKEN, which is why that fallback exists export CYPRESS_CYPRESS_ON_RAILS_TOKEN=..., which strips down toCYPRESS_ON_RAILS_TOKEN
- Playwright reads
process.env.CYPRESS_ON_RAILS_TOKEN. Playwright does no prefix stripping, so that single spelling is all it needs.
The generated helpers cover every gem endpoint, including cy.appResetState() /
appResetState() for the state reset endpoint. If you call a gem endpoint yourself with
a raw cy.request, fetch or curl, you have to send the X-Cypress-On-Rails-Token
header yourself once a token is configured, or the request is rejected with a 403.
The optional use_cassette VCR middleware wraps ordinary application requests instead
of exposing an endpoint of its own, so it is not affected by the token.
before_request is the general purpose hook for
anything the token cannot express: warden, an IP allowlist, request signing, metrics.
before_request guards the command endpoint only. It is invoked by
CypressOnRails::Middleware, which serves /__e2e__/command and the deprecated
/__cypress__/command. The state reset endpoints (/__cypress__/reset_state,
/cypress_rails_reset_state) and the VCR insert/eject endpoints are served by
separate middlewares that never call it. middleware_token is checked by all of them.
So a before_request hook is not a substitute for middleware_token on a shared
development or review server: an unauthenticated request can still reset your database
or swap VCR cassettes even though the hook rejects commands. Set middleware_token as
well, or leave use_middleware / use_vcr_middleware off, wherever the server is
reachable by anything other than your own machine.
Getting started on your local environment
The easiest way to run tests is using the provided rake tasks, which automatically manage the Rails server:
# For Cypress
bin/rails cypress:open # Opens Cypress test runner UI
bin/rails cypress:run # Runs Cypress tests in headless mode
# For Playwright
bin/rails playwright:open # Opens Playwright test runner UI
bin/rails playwright:run # Runs Playwright tests in headless modeThese tasks will:
- Start the Rails test server automatically
- Execute your tests
- Stop the server when done
You can also manage the server manually:
# start rails
CYPRESS=1 bin/rails server -p 5017
# in separate window start cypress
yarn cypress open --project ./e2e
# or for npm
npx cypress open --project ./e2e
# or for playwright
yarn playwright test --ui
# or using npm
npx playwright test --uiHow to run cypress on CI
# setup rails and start server in background
# ...
yarn run cypress run --project ./e2e
# or for npm
npx cypress run --project ./e2eYou can run your factory_bot directly as well
then in Cypress
// spec/cypress/e2e/simple.cy.js
describe('My First Test', () => {
it('visit root', () => {
// This calls to the backend to prepare the application state
cy.appFactories([
['create_list', 'post', 10],
['create', 'post', {title: 'Hello World'} ],
['create', 'post', 'with_comments', {title: 'Factory_bot Traits here'} ] // use traits
])
// Visit the application under test
cy.visit('/')
cy.contains('Hello World')
// Accessing result
cy.appFactories([['create', 'invoice', { paid: false }]]).then((records) => {
cy.visit(`/invoices/${records[0].id}`);
});
})
})then in Playwright
const { test, expect, request } = require('@playwright/test');
test.describe('My First Test', () => {
test('visit root', async ({ page }) => {
// This calls to the backend to prepare the application state
await appFactories([
['create_list', 'post', 10],
['create', 'post', { title: 'Hello World' }],
['create', 'post', 'with_comments', { title: 'Factory_bot Traits here' }]
]);
// Visit the application under test
await page.goto('/');
await expect(page).toHaveText('Hello World');
// Accessing result
const records = await appFactories([['create', 'invoice', { paid: false }]]);
await page.goto(`/invoices/${records[0].id}`);
});
});You can check the association docs on more ways to setup association with the correct data.
In some cases, using static Cypress fixtures may not provide sufficient flexibility when mocking HTTP response bodies. It's possible to use FactoryBot.build to generate Ruby hashes that can then be used as mock JSON responses:
FactoryBot.define do
factory :some_web_response, class: Hash do
initialize_with { attributes.deep_stringify_keys }
id { 123 }
name { 'Mr Blobby' }
occupation { 'Evil pink clown' }
end
end
FactoryBot.build(:some_web_response => { 'id' => 123, 'name' => 'Mr Blobby', 'occupation' => 'Evil pink clown' })This can then be combined with Cypress mocks:
describe('My First Test', () => {
it('visit root', () => {
// This calls to the backend to generate the mocked response
cy.appFactories([
['build', 'some_web_response', { name: 'Baby Blobby' }]
]).then(([responseBody]) => {
cy.intercept('http://some-external-url.com/endpoint', {
body: responseBody
});
// Visit the application under test
cy.visit('/')
})
cy.contains('Hello World')
})
})# spec/e2e/app_commands/activerecord_fixtures.rb
require "active_record/fixtures"
fixtures_dir = ActiveRecord::Tasks::DatabaseTasks.fixtures_path
fixture_files = Dir["#{fixtures_dir}/**/*.yml"].map { |f| f[(fixtures_dir.size + 1)..-5] }
logger.debug "loading fixtures: { dir: #{fixtures_dir}, files: #{fixture_files} }"
ActiveRecord::FixtureSet.reset_cache
ActiveRecord::FixtureSet.create_fixtures(fixtures_dir, fixture_files)// spec/cypress/e2e/simple.cy.js
describe('My First Test', () => {
it('visit root', () => {
// This calls to the backend to prepare the application state
cy.appFixtures()
// Visit the application under test
cy.visit('/')
cy.contains('Hello World')
})
})Scenarios are named before blocks that you can reference in your test.
You define a scenario in the spec/e2e/app_commands/scenarios directory:
# spec/cypress/app_commands/scenarios/basic.rb
Profile.create name: "Cypress Hill"
# or if you have factory_bot enabled in your cypress_helper
CypressOnRails::SmartFactoryWrapper.create(:profile, name: "Cypress Hill")Then reference the scenario in your test:
// spec/cypress/e2e/scenario_example.cy.js
describe('My First Test', () => {
it('visit root', () => {
// This calls to the backend to prepare the application state
cy.appScenario('basic')
cy.visit('/profiles')
cy.contains('Cypress Hill')
})
})Create a Ruby file in the spec/e2e/app_commands directory:
# spec/e2e/app_commands/load_seed.rb
load "#{Rails.root}/db/seeds.rb"Then reference the command in your test with cy.app('load_seed'):
// spec/cypress/e2e/simple.cy.js
describe('My First Test', () => {
beforeEach(() => { cy.app('load_seed') })
it('visit root', () => {
cy.visit('/')
cy.contains("Seeds")
})
})Scenarios are named before blocks that you can reference in your test.
You define a scenario in the spec/e2e/app_commands/scenarios directory:
# spec/e2e/app_commands/scenarios/basic.rb
Profile.create name: "Cypress Hill"
# or if you have factory_bot enabled in your cypress_helper
CypressOnRails::SmartFactoryWrapper.create(:profile, name: "Cypress Hill")Then reference the scenario in your test:
// spec/playwright/e2e/scenario_example.spec.js
import { test, expect } from "@playwright/test";
import { app, appScenario } from '../../support/on-rails';
test.describe("Rails using scenarios examples", () => {
test.beforeEach(async ({ page }) => {
await app('clean');
});
test("setup basic scenario", async ({ page }) => {
await appScenario('basic');
await page.goto("/");
});
});Please test and give feedback.
Add the npm package:
yarn add cypress-on-rails --dev
This only works when you start the Rails server with a single worker and single thread It can be used in two modes:
- with separate insert/eject calls (more general, recommended way)
- with use_cassette wrapper (supports only GraphQL integration)
Add your VCR configuration to your config/cypress_on_rails.rb
c.vcr_options = {
hook_into: :webmock,
default_cassette_options: { record: :once },
cassette_library_dir: File.expand_path("#{__dir__}/../../e2e/fixtures/vcr_cassettes")
}Add to your cypress/support/index.js:
import 'cypress-on-rails/support/index'Add to your cypress/app_commands/clean.rb:
VCR.eject_cassette # make sure we no cassettes inserted before the next test starts
VCR.turn_off!
WebMock.disable! if defined?(WebMock)Add to your config/cypress_on_rails.rb:
c.use_vcr_middleware = !Rails.env.production? && ENV['CYPRESS'].present?
# c.use_vcr_use_cassette_middleware = !Rails.env.production? && ENV['CYPRESS'].present?You have vcr_insert_cassette and vcr_eject_cassette available. https://www.rubydoc.info/github/vcr/vcr/VCR:insert_cassette
describe('My First Test', () => {
beforeEach(() => { cy.app('load_seed') })
it('visit root', () => {
cy.app('clean') // have a look at e2e/app_commands/clean.rb
cy.vcr_insert_cassette('cats', { record: "new_episodes" })
cy.visit('/using_vcr/index')
cy.get('a').contains('Cats').click()
cy.contains('Wikipedia has a recording of a cat meowing, because why not?')
cy.vcr_eject_cassette()
cy.vcr_insert_cassette('cats')
cy.visit('/using_vcr/record_cats')
cy.contains('Wikipedia has a recording of a cat meowing, because why not?')
})
})Add to your config/cypress_on_rails.rb:
# c.use_vcr_middleware = !Rails.env.production? && ENV['CYPRESS'].present?
c.use_vcr_use_cassette_middleware = !Rails.env.production? && ENV['CYPRESS'].present?Adjust record mode in config/cypress_on_rails.rb if needed:
c.vcr_options = {
hook_into: :webmock,
default_cassette_options: { record: :once },
} Add to your cypress/support/command.js:
// Add proxy-like mock to add operation name into query string
Cypress.Commands.add('mockGraphQL', () => {
cy.on('window:before:load', (win) => {
const originalFetch = win.fetch;
const fetch = (path, options, ...rest) => {
if (options && options.body) {
try {
const body = JSON.parse(options.body);
if (body.operationName) {
return originalFetch(`${path}?operation=${body.operationName}`, options, ...rest);
}
} catch (e) {
return originalFetch(path, options, ...rest);
}
}
return originalFetch(path, options, ...rest);
};
cy.stub(win, 'fetch', fetch);
});
});Add to your cypress/support/on-rails.js, to beforeEach:
cy.mockGraphQL() // for GraphQL usage with use_cassette, see cypress/support/commands.rbThere is nothing special to be called during the Cypress scenario. Each request is wrapped with VCR.use_cassette.
Consider VCR configuration in cypress_helper.rb to ignore hosts.
All cassettes will be recorded and saved automatically, using the pattern <vcs_cassettes_path>/graphql/<operation_name>
When using the rake tasks (cypress:open, cypress:run, playwright:open, playwright:run), you can configure lifecycle hooks to customize test server behavior:
CypressOnRails.configure do |c|
# Run code before Rails server starts
c.before_server_start = -> {
puts "Preparing test environment..."
}
# Run code after Rails server is ready
c.after_server_start = -> {
puts "Server is ready for testing!"
}
# Run code after database transaction begins (transactional mode only)
c.after_transaction_start = -> {
# Load seed data that should be rolled back after tests
}
# Run code after application state is reset
c.after_state_reset = -> {
Rails.cache.clear
}
# Run code before Rails server stops
c.before_server_stop = -> {
puts "Cleaning up test environment..."
}
# Configure server settings
c.server_host = 'localhost' # or use ENV['CYPRESS_RAILS_HOST']
c.server_port = 3001 # or use ENV['CYPRESS_RAILS_PORT']
c.transactional_server = true # Enable automatic transaction rollback
c.server_shutdown_timeout = 10 # or use ENV['CYPRESS_RAILS_SHUTDOWN_TIMEOUT']
endSeconds to wait after sending TERM to the test server before escalating to
KILL. Defaults to 10, and can also be set with the
CYPRESS_RAILS_SHUTDOWN_TIMEOUT environment variable. It must be a finite
number greater than zero; anything else raises an ArgumentError when you
configure it, rather than producing an unbounded wait at shutdown.
Raise it if your application needs longer to finish in-flight requests and
release resources on shutdown. Lower it to fail faster in CI. After KILL the
server is given a further fixed 5 second grace to be reaped, so a stop can
never take longer than server_shutdown_timeout + 5 seconds.
You may perform any custom action before running a CypressOnRails command, such as authentication, or sending metrics. Please set before_request as part of the CypressOnRails configuration.
You should get familiar with Rack middlewares.
If your function returns a [status, header, body] response, CypressOnRails will halt, and your command will not be executed. To execute the command, before_request should return nil.
For a plain shared secret, prefer the built-in
middleware_token: it uses a constant-time comparison, is sent
automatically by the generated helpers, and — unlike before_request, which only runs
for the command endpoint — it also guards the state reset and VCR insert/eject
endpoints. Use before_request when you need something the token cannot express:
CypressOnRails.configure do |c|
# ...
# Refer to https://www.rubydoc.info/gems/rack/Rack/Request for the `request` argument.
c.before_request = lambda { |request|
body = JSON.parse(request.body.string)
if body['cypress_token'] != ENV.fetch('SWEEP_CYPRESS_SECRET_TOKEN')
# You may also use warden for authentication:
# if !request.env['warden'].authenticate(:secret_key)
return [401, {}, ['unauthorized']]
end
}
end CypressOnRails.configure do |c|
# ...
# Refer to https://www.rubydoc.info/gems/rack/Rack/Request for the `request` argument.
c.before_request = lambda { |request|
statsd = Datadog::Statsd.new('localhost', 8125)
statsd.increment('cypress_on_rails.requests')
}
endAdd CypressOnRails to your config.ru
# an example config.ru
require File.expand_path('my_app', File.dirname(__FILE__))
require 'cypress_on_rails/middleware'
CypressOnRails.configure do |c|
c.cypress_folder = File.expand_path("#{__dir__}/test/cypress")
# There is no Rails.env out here, so the production-safe default cannot decide
# for you. The middleware runs arbitrary ruby: say when it is allowed.
c.use_middleware = ENV['RACK_ENV'] != 'production'
end
# Mounting the middleware directly bypasses `use_middleware?`, so check it here.
use CypressOnRails::Middleware if CypressOnRails.configuration.use_middleware?
run MyAppadd the following file to Cypress
// test/cypress/support/on-rails.js
// CypressOnRails: don't remove these commands
Cypress.Commands.add('appCommands', (body) => {
cy.request({
method: 'POST',
url: '/__cypress__/command',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
log: true,
failOnStatusCode: true
})
});
Cypress.Commands.add('app', (name, command_options) => {
cy.appCommands({name: name, options: command_options})
});
Cypress.Commands.add('appScenario', (name) => {
cy.app('scenarios/' + name)
});
Cypress.Commands.add('appFactories', (options) => {
cy.app('factory_bot', options)
});
// CypressOnRails: end
// The next is optional
beforeEach(() => {
cy.app('clean') // have a look at cypress/app_commands/clean.rb
});add the following file to Playwright
// test/playwright/support/on-rails.js
async function appCommands(body) {
const context = await request.newContext();
const response = await context.post('/__e2e__/command', {
data: body,
headers: {
'Content-Type': 'application/json'
}
});
if (response.status() !== 201) {
const responseBody = await response.text();
throw new Error(`Expected status 201 but got ${response.status()} - ${responseBody}`);
}
return response.json();
}
async function app(name, commandOptions = {}) {
const body = await appCommands({ name, options: commandOptions });
return body[0];
}
async function appScenario(name, options = {}) {
const body = { name: `scenarios/${name}`, options };
const result = await appCommands(body);
return result[0];
}
async function appFactories(options) {
return app('factory_bot', options);
}
async function clean() {
await app('clean');
}If your Rails server is exposed under a proxy, typically https://my-local.dev/api, you can use the api_prefix option.
In config/initializers/cypress_on_rails.rb, add this line:
CypressOnRails.configure do |c|
# ...
c.api_prefix = '/api'
end- Fork it ( https://github.com/shakacode/cypress-on-rails/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
The following companies support our open source projects, and ShakaCode uses their products!