We’ve recently started using Cypress to handle End to End tests for our Drupal sites. Thus far, the experience has been great! Cypress makes it easy to quickly add new tests to your site as you iterate your code. The process, however, has not completely been without hurdles. While pursuing ways to cut down on test development and run time, I’ve found a few concepts that have been helpful specifically for Drupal sites. Follow along as I walk through some of these concepts or download my completed example repo.
Setup
To serve our example site, I’ll be using Aquia’s Dev Desktop client with the Drupal 8 distribution and Standard install profile.
The only module I’ll be adding on top of the standard install will be the JSON API. Drupal 8 comes with the RESTful Web Services which can serve many of the same purposes. However, I’ve found that the JSON API makes a few things easier out of the box, such as querying for nodes by field.
You have a few options for installing Cypress, but my preferred option is through an NPM (or Yarn) package.json. The first step to this route is creating our package.json file in the root of our project. Once the file is in place, install it by running npm i from the project root.
1 2 3 4 5 6 7 8 9 | { "name": "cypress_testing", "scripts": { "cy:dd": "cypress open --config baseUrl=http://drupal-cypress.dd:8083" }, "devDependencies": { "cypress": "^3.1.0" } } |
This example is pretty bare bones but does provide a script to run Cypress on our site with npm run cy:dd . The script will also pass our site’s base URL (in my case, http://drupal-cypress.dd:8083) into Cypress as a flag, allowing us to use relative paths (e.g. “/login”) in our tests. On its first initialization, Cypress will create a skeleton directory at cypress/ with some example specs in cypress/integration/examples/ . Feel free to run through a few of these by clicking on them in the Cypress dashboard. You can either keep this example directory around for reference and inspiration or get rid of it.
First Test
Let’s write a quick test just to make sure everything is functioning properly. We’ll need to first create a spec file for the test to live in.
1 2 3 4 5 6 7 | describe('Homepage', function() { it('visits homepage', function() { cy.visit('/'); cy.get('.site-branding__name') .contains('Cypress Testing'); }); }); |
The test does two things:
- Visits our site’s root address (configured by our NPM script)
- Checks that the page has an element with “Cypress Testing” (our site name) in it.
Account Creation
There are a few ways we can add user accounts. Depending on your environment, some options may be more feasible than others. We’ll go through a few of these options. In order to do the things we need to do, like creating Drupal entities, we’ll need access to an administrator account. We could manually create the account in our database and pass the account credentials to Cypress through an environment variable. However, this approach would add an environment dependency and reduce the autonomous nature of our tests. Instead, I prefer to have Cypress create this account each time it runs the tests. This decreases the chance that our tests will fail right off the bat due to a lack of administrator access. The cy.exec() command allows us access to system commands, and specifically in our case, Drush.
We’ll first need to decide on credentials for our test user. In cypress.json , we can add an env object with key values that will be passed to our tests as environment variables. The variables can then be accessed by calling Cypress.env('ourCustomVariableName') . Let’s add the username/password for the admin user we want to create.
1 2 3 4 5 6 | { "env": { "cyAdminUser": "admin", "cyAdminPassword": "password" } } |
Now that our credentials are available, we can use them to create our user. We’ll need to create the user in order to run any tests.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | before(function(){ createAdminUser(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')); // ... }); const createAdminUser = function(user, pass){ cy.exec( `drush ucrt ${user} --password="${pass}"`, { failOnNonZeroExit: false } ); cy.exec( `drush user-add-role administrator ${user}`, { failOnNonZeroExit: false } ); cy.exec(`drush uinf ${user}`); } |
Here, there are a few things to point out. First, we’ve placed our user creation block in its own function that is then executed in the before() hook callback. Any code in this callback will run one time before Cypress executes any single spec or set of specs. Second, we use cy.exec() with Drush to try and create the user account. More importantly, we’ve passed in a second option object to cy.exec with the failOnNonZeroExit property set to false. This will allow our code to continue executing in the event that the user account already exists. We use the same method to add the administrator role to the user. To better ensure that the drush ucrt command didn’t fail for a reason other than the user account already existing, we’ve followed with another cy.exec() command that checks for the user account. This time, since we didn’t explicitly set the failOnNonZeroExit property, the test code will fail if the user account doesn’t exist.
Logging In
In order to test any restricted actions to authenticated users, we’ll first need to log in. The most obvious way to do this is the same way a user would log in, through the ui. In fact, we should test to ensure that logging in through our ui is possible.
1 2 3 4 5 6 7 8 9 | describe('Login', function() { it('logs in via ui', function(){ cy.visit('/user/login'); cy.get('#edit-name').type(Cypress.env('cyAdminUser')); cy.get('#edit-pass').type(Cypress.env('cyAdminPassword')); cy.get('#edit-submit').click(); }); // ... }); |
After every test, Cypress leaves your browser in the state it was in when your last test finished running. I find this useful because it leaves me in a great position to determine the next steps (for example, determining an appropriate DOM query selector of a field or button). For this particular case, Cypress will return the browser to us with our admin user logged in.
To keep tests independent from each other, Cypress clears the browser cookies before each of your tests run. This helps prevent side effects between tests, but it also means that you’ll need to log in each time a test runs that requires authentication.
Since it is likely that we’ll need to log in from several different test specs, we should put the login code somewhere that is accessible to all specs. cypress/support/commands.js is the perfect place for this. In this file, we can declare our own custom commands via the Cypress.Commands.add() function. Once declared in this way, a command can be called anywhere in your tests by calling cy.ourCustomCommandName() .
Now that we have a place for our login code, we need to write it. We could just reuse our ‘logs in via ui’ test code, but if we have to run that same code before every test, there wouldn’t be much point in having the test to begin with. More importantly, logging in through the ui is slow. If we have to log in before every test we run, over the course of running several tests, a lot of time will be wasted on logging in. Drupal (at least in the case of our test site) logs in simply by posting form data to the login URL. We can both confirm this, and steal a large portion of our login code, by watching the network requests while our ‘logs in via ui’ test runs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // ... Cypress.Commands.add("login", (user, password) => { return cy.request({ method: 'POST', url: '/user/login', form: true, body: { name: user, pass: password, form_id: 'user_login_form' } }); }); Cypress.Commands.add('logout', () => { return cy.request('/user/logout'); }); // ... |
Now that we can easily login before tests, let’s try it out by testing the logout functionality. This code turned out to be a little more complicated than intended. However, it does provide an opportunity to point out a few of Cypress’ features.
1 2 3 4 5 6 7 8 9 10 11 12 13 | describe('Login', function() { // ... it('logs out via ui', function(){ cy.login(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')); cy.server(); cy.route('POST', '/quickedit/*').as('quickEdit'); cy.visit('/'); cy.wait('@quickEdit'); cy.get('#block-bartik-account-menu a') .contains('Log out') .click({force: true}); // This is a workaround due to the admin bar getting in the way. Not a great approach. }); }); |
The planned implementation of this test was to:
- Use our custom login command to log in
- Query for the ‘Log out’ button
- Click it
The first roadblock with this approach was that Cypress could not see the ‘Log out’ button because of the admin bar position. Cypress will fail on certain commands if it detects that the queried element isn’t visible to the user. This is normally a good thing. After struggling for too long to ensure the button was in view, I finally gave up and forced Cypress to click the button by passing in the “force” option. This throws away a lot of the utility this test was intended to provide, so I wouldn’t recommend doing the same for a site you are actually testing. For now, let’s just pretend that it worked without the force option.
The second issue was that Cypress was logging out before Drupal finished logging in. This was causing an AJAX request to ‘/quickedit/attachments?_wrapper_format=drupal_ajax’ to return a 403 response. In this case, I needed to ensure that Cypress wouldn’t try to log out until that request was returned. The cy.wait() command allows you to wait either for a specified length of time or a requested resource to resolve. We first call the cy.server() command to start a server that can watch for our specified request. Then we describe the request by its method and URL, and then attach the ‘quickEdit’ alias to it chaining the .as() command. The request info was showing up in the Cypress log but you could also open up the network tab of dev tools to view it.
Now we call cy.wait('@quickEdit') and chain our .click() command off of that. Cypress will now wait for the quickEdit request to resolve before it tries clicking the ‘Log out’ button.
Seeding Data Through JSON API
Now we should look at how we can use the JSON API to seed the data we’d like to test. It’s important to understand how the API authenticates its requests. By default, for any unsafe/non-read-only requests (POST, DELETE, PATCH) both the JSON and the standard REST module require a X-CSRF-Token request header to be present. You can request one of these tokens for your site at ${your-site}/session/token while logged in. We can now use this token to create and delete data by posting to endpoints exposed by the JSON API module.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | const testArticleFields = { title: { value: 'Cypress Test Article' }, body: { value: 'Body here' } }; before(function(){ cy.getRestToken(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')).then(token => { return cy.reseedArticle(token, testArticleFields) .as('testArticle'); }); cy.logout(); }); describe('Article', function() { it('displays published articles', function(){ cy.visit(`/node/${this.testArticle.data.attributes.nid}`); cy.get('h1').contains(testArticleFields.title.value); cy.get('.field--name-body').contains(testArticleFields.body.value); }); }); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | Cypress.Commands.add("createNode", (token, nodeType, fields) => { return cy.request({ method: 'POST', url: `/jsonapi/node/${nodeType}`, headers: { 'Accept': 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', 'X-CSRF-Token': token }, body: { data: { type: `node--${nodeType}`, attributes: fields } }, }).its('body'); }); Cypress.Commands.add("deleteNode", (token, nodeType, uuid) => { return cy.request({ method: 'DELETE', url: `/jsonapi/node/${nodeType}/${uuid}`, headers: { 'Accept': 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', 'X-CSRF-Token': token }, }).its('body'); }); // ... Cypress.Commands.add("getNodesWithTitle", (token, nodeType, title) => { return cy.request({ method: 'GET', url: `/jsonapi/node/${nodeType}?filter[article-title][path]=title&filter[article-title][value]=${title}&filter[article-title][operator]==`, headers: { 'Accept': 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', 'X-CSRF-Token': token }, }).then(res => { return JSON.parse(res.body).data; }); }); Cypress.Commands.add("getRestToken", (user, password) => { cy.login(user, password); return cy.request({ method: 'GET', url: '/session/token', }).its('body'); }); // ... Cypress.Commands.add('reseedArticle', (token, fields) => { cy.getNodesWithTitle(token, 'article', fields.title.value) .then(nodes => { nodes.map(function(node){ cy.deleteNode(token, 'article', node.id); }); }); return cy.createNode(token, 'article', fields); }); // ... |
Some things to note about this code: In the before hook, we first get our authentication token to use, and then we get an object with our desired Article fields to call a custom cypress command cy.reseedArticle(). This command first looks for and deletes any Article nodes that have the same title as our test Article, then creates a new test Article with the desired fields. cy.seedArticle() will return our test article as an object which we will save to the testArticle alias by chaining .as('testArticle') so we can reference its properties in the tests.
It’s also worth noting that Cypress exposes an after hook. It’s tempting to delete our test nodes in the after hook since, at that point, we would have access to the test node’s id and could delete the test content without having to query by the title. However, this approach can be problematic in the event that your test runner quits or refreshes before running the after block. In this case, the test content would never get cleaned up since you wouldn’t have access to the node’s id in future test runs. Once our test article is seeded, the “displays published articles” test will visit the node’s page and confirm that the fields we passed in are rendering correctly.
Reseeding Data with Database Dumps
The last thing I’d like to cover is resetting your database when the JSON API isn’t feasible. For content that requires complicated relationships, using the JSON or REST APIs can become very tedious. I have personally run into this problem for sites that use the Organic Groups Module. In these situations, it may not be worth the trouble to set up these complicated relationships through the API. Instead, you might opt to seed them once through another less complex but potentially slower to execute method (such as the ui), create a backup, then restore to this backup each time you need to reset the database.
Since comment data is dependent on relationships, we’ll use it as an example.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | const testArticleFields = { title: { value: 'Cypress Test Article' }, body: { value: 'Body here' } }; const testCommentFields = { subject: 'Cypress test comment subject', body: '<p>Cypress test comment body</p>' }; const seedCommentThroughUi = function(articleNid, fields) { cy.visit(`/node/${articleNid}`); cy.get('.comment-form input[name="subject[0][value]"]').type(fields.subject); cy.window().then(win => { win.CKEDITOR.instances['edit-comment-body-0-value'].insertHtml(fields.body); }); cy.get('.comment-form #edit-submit').click(); } before(function(){ cy.getRestToken(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')).then(token => { return cy.reseedArticle(token, testArticleFields) .as('commentTestArticle'); }).then(function(){ seedCommentThroughUi(this.commentTestArticle.data.attributes.nid, testCommentFields); }); cy.dumpDb('comment-seeded'); }); describe('Comment', function() { it('displays published comments', function(){ cy.restoreDb('comment-seeded'); cy.visit(`/node/${this.commentTestArticle.data.attributes.nid}`); }); }); |
We first reseed a test article the same way we did in the Article spec. To attach a comment to the article, we fill out the comment form elements in almost the same way a user would. One thing to note here is that CKEditor uses an iframe to display the comment body field. There are documented ways to handle iframe elements in Cypress, however, CKEditor exposes a Javascript API to handle its instances via a global variable. We can access that by calling the cy.window() command.
Once the article and it’s related comment are both created, we dump a copy of the database into cypress/backups/ using cy.exec() and Drush. It’s helpful to create some custom commands for this so we can create or reseed a backup by calling cy.dumpDb('our-backup-name') or cy.restoreDb('our-backup-name') , respectively. This admittedly isn’t very useful in its current state, but it can be very helpful for a more complex data structure.
There is no silver bullet approach to writing E2E tests in Drupal/Cypress. Your particular environment will call for a particular approach. This is what has worked for us so far, and it’s already begun to pay off on sites that have it implemented. I’ve found these custom commands to be easily transferable to other Drupal projects as well as helpful starting points for other CMSs. Feel free to comment below with any questions about testing or reach out to us for a free consultation.