Moving from Capybara to Cypress
By Martijn Storck
Capybara has served me well for many years. Writing tests with Rspec and Capybara was relatively easy and ever so often our CI pipeline would notify me that I broke a Capybara test while trying to add or fix something. However, more often, CI would fail for other reasons: quirks with Selenium, PhantomJS or poorly written tests that would fail randomly. Also, debugging tests or building more complex ones was incredibly frustrating because the only tools at your disposal was a screenshot or HTML dump of the browser state at some point in time. For years this has been the state of integration testing in Rails and frankly, I hated it.
The new (JavaScript) kid on the block, Cypress
Luckily, outside the Ruby ecosystem, new ideas were emerging and Cypress was born. Cypress is an end-to-end testing experience that runs in an actual browser. Your tests are written in JavaScript and have full native access to the entire front-end of your application. During development, Cypress connects to a browser (for example Chrome or Firefox) and allows you to see the tests being executed. Should a step fail, you have the full browser development tools at your disposal to solve the issue. And to top it off, there are Docker images to run your test headless on your CI infrastructure.
This screenshot taken from the Cypress website shows the test runner in action:
You can actually hover over the command log on the left and view a DOM snapshot of every step of the test, ready to prod around in your browser. This alone has sped up the process of writing tests by a huge amount for me. The clear test syntax and well-defined waiting and matching behavior are the cherries on top.
The syntax is easy to understand:
describe('My First Test', () => {
it('clicking "type" navigates to a new url', () => {
cy.visit('/')
cy.contains('type').click()
// Should be on a new URL which includes '/commands/actions'
cy.url().should('include', '/commands/actions')
})
})
The cy.contains()
command helps you find an element by contents. You can also chain operators. For example to check the contents of a definition list:
cy.get("dt").contains("Status").next("dd").should("contain", "Ok")
This looks for a dt
element with the word Status and ensures that the first dd
element after that contains the text “Ok”.
Want to click the action button at the end of a certain table row? Easy:
cy.get("td").contains("Row I want to click").parents("tr").find("td:first-child .button").click()
So: look for a table cell, find the parent row and click the first .button element in the first table cell of that row. It’s like jQuery, but appropriate.
Integrating Cypress with Ruby on Rails
Since Cypress is only dependent on a browser (and actually even comes with a built-in Electron based browser), it’s framework agnostic. You don’t need to do anything special to test Rails applciations with it, but you’ll want to for example:
- Run DatabaseCleaner before each test
- Load fixtures
- Provide hooks that Cypress can call to run factories, sign in users etc.
Software consulting firm ShakaCode has released a gem to provide an integration with Ruby on Rails applications called CypressOnRails. It provides all of the above and gets you up and running with Cypress easily.
After installing CypressOnRails as desrcibed in the README you can start a test server using bin/rails server -e test
. You’ll have to install cypress on your system (or as a JavaScript development dependency in your project) and start the test runner manually using cypress open --project ./test
.
To get started, read the CypressOnRails README and the Cypress Getting Started guide. I had my first test running within 15 minutes, so it’s worth your time!
Signing in to your Rails app
Cypress recommends certain best practices where Cypress CI can help. When you need to sign in as s user to test a certain function, you don’t need to sign in through your UI every time. Instead, do it directly by defining a support function in test/cypress/support
such as this:
Cypress.Commands.add("signIn", (username, follow = true) => {
cy.log(`Sign in as ${username}`)
cy.request({
method: "POST",
url: "sessions",
body: { username: username, password: username + "secret-test-password" },
followRedirect: false
}).then((resp) => {
if (follow)
cy.visit(resp.redirectedToUrl)
})
This sends a post request to sign you in and follows the redirect unless opted out.
Executing scenarios
CypressOnRails allows you to define scenarios that you can execute from a test. For example to advance the status of a work item, define this Ruby code in a file in test/cypress/app_commands/scenarios/advance.rb
:
WorkItem.all.each { |wi| wi.update(status: wi.status + 1) } # or whatever
You can call this from javascript using a simple:
cy.appScenario("advance")
Running Cypress integration tests on CI
To make sure you have a portable CI environment, I’d advise sticking with running Cypress in Docker. The Cypress people maintain an image called cypress/included which has cypress installed globally along with various popular browser.
However this image weighs in at 885 MB compressed, which I found a bit too much, especially since I don’t test multiple browsers (yet) and instead am fine using the built-in Electron browser. That’s why I’ve created a smaller Docker image that I call cypress-some-included. It’s basically the node:slim
image with cypress installed as entrypoint.
Using it is simple. Start your app in the test environment on port 5000 and connect it to the my-network
network. Then you can start the cypress runner as follows:
docker run \
-v /my-app-path:/app \
-w /app/test \
--network my-network \
-e CYPRESS_BASE_URL=http://app.my-network:5000 \
--rm \
martijnstorck/cypress-some-included:latest
In conclusion
Cypress made writing integration tests (or system tests) a lot less painful and I dare say even fun for me. Owing to the fact that the test runner lives outside your Rails application it’s easy to integrate in new or existing applications. I encourage every Rails developer to give this a try.