Home > Designing, Others > An Intro to Web Site Testing with Cypress

An Intro to Web Site Testing with Cypress

End-to-end testing is awesome because it mirrors the user’s experience. Where you might need a ton of unit tests to get good coverage (the kind where you test that a function returns a value you expect), you can write a single end-to-end test that acts like a real human as it tests several pieces of your app at once. It’s a very economical way of testing your app.

Cypress is a new-ish test runner with some features that take some of the friction out of end-to-end testing. It sports the ability to automatically wait for elements (if you try to grab onto an element it can’t find), wait for Ajax requests, great visibility into your test outcomes, and an easy-to-use API.

Note: Cypress is both a test runner and a paid service that records your tests, allowing you to play them back later. This post focuses on the test runner which you can use for free.

Installing Cypress

Cypress.io installs easily with npm. Type this into your terminal to install it for your project:

npm install --save-dev cypress

If everything works, you should see output that looks like this in your terminal:

Now, let’s write some tests to see how this thing works!

Setting up tests for CSS-Tricks

We’ll write some tests for CSS-Tricks since it’s something we’re all familiar with… and maybe this will help Chris avoid any regressions (that’s where changing one thing on your site breaks another) when he adds a feature or refactors something. ?

I’ll start inside my directory for this project. I created a new directory called testing-css-tricks inside my projects directory. Typically, your Cypress tests will go inside the directory structure of the project you want to test.

By default, Cypress expects integration tests to be in cypress/integration from the project root, so I’ll create that folder to hold my test files. Here’s how I’d do that in the terminal:

mkdir cypress
mkdir cypress/integration

You don’t have to use this default location though. You can change this by creating a cypress.json configuration file in your project root and setting the integrationFolder key to whatever path you want.

Test: Checking the Page Title

Let’s start with something really simple: I want to make sure the name of the site is in the page title.

A Chrome browser window with an open tab containing the CSS-Tricks page title.

The describe function

I’ve created a file inside cypress/integration called sample-spec.js. Inside that file, I’ll kick off a test with a call to describe.

describe('CSS-Tricks home page', function() {
});

describe takes two arguments: a string which I think of as the “subject” of your testing sentence and a callback function which can run any code you want. The callback function should probably also call it which tells us what we expect to happen in this test and checks for that outcome.

The it function

describe('CSS-Tricks home page', function() {
  it('contains "CSS-Tricks" in the title', function() {
  });
});

The it function has the same signature: it takes a string and a callback function. This time, the string is the “verb” of our testing sentence. The code we run inside the it callback should ultimately check our assertion (our desired result) for this test against reality.

This describe callback can contain multiple calls to it. Best practice says each it callback should test one assertion.

Setting up tests

We’re getting slightly ahead of ourselves, though. In our describe call, we’ve made it clear that we intend to test the homepage, but we’re not on the homepage. Since all the tests inside this describe callback should be testing the homepage (or else they belong somewhere else), we can just go ahead and navigate to that page in a beforeEach inside the describe callback.

describe('CSS-Tricks home page', function() {
  beforeEach(function() {
    cy.visit('https://css-tricks.com/');
  });
  
  it('contains "CSS-Tricks" in the title', function() {
  });
});

beforeEach reads just like what it does. Whatever code is in the callback function passed to it gets executed before each of the tests in the same scope (in this case, just the single it call under it). You have access to a few others like before, afterEach, and after.

You may wonder why not use before here since we’re going to test the same page with each of our assertions in this block. The reason beforeEach and afterEach are used more frequently than their one-time counterparts is that you want to ensure a consistent state at the start of each test.

Imagine you write a test that confirms you can type into the search field. Great! Imagine you follow it with a test that ensures the search field is empty. Fail! Since you just typed into the search field in the previous test without cleaning up, your second test will fail even though the site functions exactly as you wanted: when it’s first loaded, the search field is empty. If you had loaded the page before each of your assertions, you wouldn’t have had a problem since you’d have a fresh state each time.

Driving the browser

cy.visit() in the example above is the equivalent to our user clicking in the address bar, typing https://css-tricks.com/, and pressing return. It will load up this page in the web browser. Now, we’re ready to write out an assertion.

describe('CSS-Tricks home page', function() {
  beforeEach(function() {
    cy.visit('https://css-tricks.com/');
  });
  
  it('contains "CSS-Tricks" in the title', function() {
    cy.title().should('contain', 'CSS-Tricks');
  });
});

Title Assertion

cy.title() yields the page title. We chain it with should() which creates an assertion. In this example, we pass should() two arguments: a chainer and a value. For your chainer, you can draw from the assertions in a few different JavaScript testing libraries. contains comes from Chai. (The Cypress docs has a handy list of all the assertions it supports.)

Sometimes, you’ll find multiple assertions that accomplish the same thing. Your goal should be for your entire test to read as close to an English sentence as possible. Use the one that makes the most sense in context.

In our case, our assertion reads as: The title should contain “CSS-Tricks.”

Running our first test

Now, we have everything we need in place to run our test. Use this command from the project root:

$(npm bin)/cypress open

Since Cypress isn’t installed globally, we have to run it from this project’s npm binaries. $(npm bin) gets replaced with the npm binary path for this project. We’re running the cypress open command from there. You’ll see this output in the terminal if everything worked:

…and you’ll get a web browser with the test runner GUI:

Click that “Run all specs” button to start running your tests. This will spawn a new browser window with your test results. On the left, you have your tests and their steps. On the right, you have the test “browser.”

This brings us to another cool feature of Cypress. One problem with end-to-end tests is visibility into your test outcomes. Every test runner gives you a “pass” or “fail,” but they do a terrible job of showing you what happened to cause a failure. You know what didn’t happen (your test assertion), but it’s harder to find out what did happen. In the past, I have resorted to taking a screenshot of the test browser at various points throughout the test which rarely gave me the answers I needed. It’s the automated test equivalent to spamming your code with console.log to debug a problem.

With Cypress, I can click on each step of the test on the left to see the state of the page at that point on the right.

Test: Checking for an element on the page

Next, we’ll check for an element we want to be sure is on the page. The page should always include the logo, and it should be visible.

Since we’re testing the same page, we’ll add a new it call to our describe callback.

it('has a visible star logo', function() {
  cy.get('.icon-logo-star').should('be.visible');
});

We’re still testing from the home page like before since the cy.visit() call happens before each of these tests. This test is using cy.get() to grab the element we want to check for. It works kinda like jQuery: you pass it a CSS selector string. Then, I chain a should() call and check for visibility.

Two things to note here: first, if this element had loaded asynchronously, cy.get() will automatically wait the defaultCommandTimeout to see if the element shows up. (The default value for that is four seconds, which can be changed in cypress.json.) Second, if you add that test and save the file, your tests will automatically re-run with the new test. This makes it really quick and easy to iterate your tests.

Here’s the result:

Test: Making sure navigation is responsive

We’ll try something slightly fancier with this test. I want to be sure the responsive menu is available on smaller viewports. Otherwise, users might not be able to navigate the site properly.

We’re still testing the home page, so I’ll write this test inside the same describe callback. I’m testing a slightly different scenario though, so I’ll nest another describe call to indicate the specific circumstances of my test and to set up those circumstances.

describe('CSS-Tricks home page', function() {
  // Our existing tests and the beforeEach are here

  describe('with a 320x568 viewport', function() {
    
  });
});

Testing at 320px width

Here, I’ve decided to test for the responsive navigation menu at 320px width, but it would be useful to know about the default testing viewport. You can click on any of your tests in the test runner and see the viewport width above the browser pane.

1000×660 is the default viewport size. You can change this in your cypress.json configuration file. We’ll start by writing the test to run at 320px width. Then, we’ll duplicate that test for a few different viewports.

To change the viewport for this test only, we can call cy.viewport().

describe('with a 320x568 viewport', function() {
  beforeEach(function() {
    cy.viewport(320, 568);
  });
});

Now, we’ll drop an it call inside the nested describe callback. Now that we have the viewport set, this test will look very similar to the logo test.

it('has a visible mobile menu toggle', function() {
  cy.get('#mobile-menu-toggle').should('be.visible');
});

Testing at 1100px width

I’m going to run the same test at 1100px to make sure the responsive menu is still there. I think this is the maximum width that should have the menu, so I want to make sure it does.

describe('with a 1100x660 viewport', function() {
  beforeEach(function() {
    cy.viewport(1100, 660);
  });

  it('has a visible mobile menu toggle', function() {
    cy.get('#mobile-menu-toggle').should('be.visible');
  });
});

Oh crap! What happened here?

Since the test only tested for a single thing, we have a good idea what happened: the responsive menu wasn’t visible at 1100px viewport width. The feedback from the test give us some good information, too.

“Timed out retrying: expected ‘

This element

  1. No comments yet.
  1. No trackbacks yet.
You must be logged in to post a comment.