Great E2E testing with Cypress

Follow me on Twitter, happy to take your suggestions on topics or improvements /Chris

Testing is a necessary thing, yet are tools we use for testing aren't great which leads to us both authoring and running tests less than we should. Why can't we have a great tool for testing? Well we can - with Cypress

TLDR; The test tool of the future is here. It sounds like a dream, read on and I'm sure by the end of the article you will agree with me. Cypress - a test runner built for humans.

References

WHAT

Cypress is a testing tool that greatly improves your testing experience. It offers features such as:

  • Time travel, it takes snapshots of your tests as you run the tests. This enables you to easily hover over each command that took place in your test
  • Debuggability, Debug directly from familiar tools like Chrome DevTools. Our readable errors and stack traces make debugging lightning fast
  • Real-time reloads, Cypress automatically reloads whenever you make changes to your tests. See commands execute in real-time in your app.
  • Automatic waiting, Never add waits or sleeps to your tests. Cypress automatically waits for commands and assertions before moving on - No more async hell.
  • Spies, stubs, and clocks, Verify and control the behavior of functions, server responses, or timers. The same functionality you love from unit testing is right at your fingertips.
  • Consistent results, Our architecture doesn’t use Selenium or WebDriver. Say hello to fast, consistent and reliable tests that are flake-free.
  • Network traffic control, Easily control, stub, and test edge cases without involving your server. You can stub network traffic however you like.
  • Screenshots and videos, View screenshots taken automatically on failure, or videos of your entire test suite when running headlessly.

WHY

Testing today doesn't feel like a first-class citizen. We often have a great looking IDE for writing code but authoring and running tests feels lacking - like an afterthought. We need testing to feel confident in what we are building but we should have a right to expect first-class tools.

One problem with E2E tests is that they are flaky. Cypress offers a great test runner, that's not flaky, for running Unit as well as E2E tests, the future is here.

Installing Cypress

You want to install Cypress as a dependency to your project. Make sure npm init has been run. I usually go with the smart defaults option:

npm init -y
1

Then install Cypress with:

npm install cypress
1

It's possibly to use Yarn as well:

yarn add cypress --dev
1

Your executable now exist on:

./node_modules/.bin/
1

Starting Cypress

You want to be calling cypress open to start Cypress. You can do so in one of the following ways:

  1. ./node_modules/.bin/cypress open
  2. $(npm bin)/cypress open
  3. npx cypress open for NPM version > 5.4, or npx installed separately
  4. yarn run cypress open

We'll go with npx cypress open:

This also pops up a window, looking like this:

According to the header text it has added test samples. Let's have a look at our project structure

Above we see that we got a directory cypress containing an integration subfolder, like so:

-| cypress/
---| integration
------| examples
1
2
3

Looking at the content of the examples folder we can see that it contains everything we could possibly want to know how to do like:

  • writing tests
  • mock APIs
  • different assertions
  • aliases

and much much more. This is great, we will have a lot of reasons to return back, but first we need to learn how to crawl. So where to start?

Our first test

Let's create a new file sample_spec.js under the cypress/integration folder:

- | cypress
---| integration
------| sample_spec.js
1
2
3

Upon creating the file, it gets picked up by our test runner, that's still running:

Let's add the following content to sample_spec.js:

describe('My First Test', function() {
  it('Does not do much!', function() {
    expect(true).to.equal(true)
  })
})
1
2
3
4
5

Let's save the content and let's click the test in our test runner. Doing so should produce the following window:

We have a passing test 😃

We can easily make it fail by changing the code:

describe('My First Test', function() {
  it('Does not do much!', function() {
    expect(true).to.equal(false)
  })
})
1
2
3
4
5

As soon as we save the code - the runner now displays this:

A real test

Let's write something more real looking. So far we tested true vs true. While that technically is a a test - we wanted to show what Cypress really can do. So let's test out its capability on a web page.

Our high-level approach looks like so:

  1. Visit a web page.
  2. Query for an element.
  3. Interact with that element.
  4. Assert about the content on the page.

Visit

Cypress has provided as with numerous helpers to make the above really easy to achieve. First, let's set up a test suite with a test in it. Let's create a file page_spec.js under our integration folder

-| cypress/
---| integration/
------| page_spec.js
1
2
3

Now, give it the below content:

describe('My First Test', function() {
  it('Visits page', function() {
    cy.visit('https://example.cypress.io')
  })
})
1
2
3
4
5

We can see that we use the global object cy and the helper method visit() to go to a page.

Per usual our Visits page test shows up and we are able to click it. Now we are faced with the following UI:

On our left, we see our test suite, test and what action we are currently on aka VISIT and to our right we see the result of carrying out said action, which is the webpage we navigated to.

Query for an element

Now let's find an element. There are many helpers that help you find an element but let's find this one by content:

cy.contains('type')
1

Let's add this to our code so our test now reads:

describe('My First Test', function() {
  it('Visits page', function() {
    cy.visit('https://example.cypress.io')
    cy.contains('type')
  })
})
1
2
3
4
5
6

Let's save this. Note how our test runner now says:

Above we see our cy.contains() created a CONTAINS action on our left. On our right, we see how the element is highlighted that matches our cy.contains().

Interact

Next step is to interact with our UI and specifically with our found element, so let's click it, like so:

cy.contains('type').click()
1

Let's save this. You should have the below result:

Clicking our type element expanded it and it's now showing us a lot of content that we can assert on.

Assert

Ok then. There are more than one thing we could assert on here:

  • URL, our URL actually changed from us clicking this element
  • Content, new content is being shown, let's assert it's the correct content

To assert on the URL we can use the helper cy.url().should('include', '<something>'). We are using multiple helpers here:

  • cy.url(), this helps us grab the URL
  • .should(), this is an assertion
  • include, this is a keyword telling us what part of the URL should be matched against our desired output

In our case, we want the expression to read:

cy.url().should('include', '/commands/actions')
1

which means that we want the URL to contain /commands/actions

What about other types of assertions, like input elements? For that, we can use the cy.get() helper, like so:

cy.get('.action-email')
    .should('have.value', '<some value>')
1
2

Above we are getting the email by CSS class.

Let's add the above assertions to our test so the test now reads:

describe('page test - suite', () => {
  it('Visits page', function () {
    cy.visit('https://example.cypress.io')
    cy.contains('type').click()
    cy.url().should('include', '/commands/actions')

    cy.get('.action-email')
      .should('have.value', '')
  })
})
1
2
3
4
5
6
7
8
9
10

Let's save this new code. You should get the below update in the UI as the test is rerun.

As we can see it is able to correctly assert on the URL as well as the element.

Let's try to change the content of the input element though, just to ensure it gets properly updated (Yes I have messed that up in every single SPA framework I've coded with 😃 ).

To enter content into an input element using the helper .type(), like so:

cy.get('.action-email')
    .type('fake@email.com')
    .should('have.value', 'fake@email.com')
1
2
3

Saving this, and having the test re-run, results in the following:

We see above that it types into our text element, so yes we didn't mess up the 2-way, unidirectional data-flow that we were using with our SPA (our we could have been using Vanilla JS 😉 )

Debugging

Ok, so we've learned a bit above on how to author and run our tests and everything went mostly green. What if it doesn't though, what if we have problems? For that, we can use the excellent debug support in the form of Time traveling and Snapshots. For every single action carried out you can freely go back and forth between snapshots. Let's demonstrate this

Additionally to our Snapshots, we can use two additional commands:

  • cy.pause(), this gives us the ability to pause on a specific place in our test
  • cy.debug()

cy.pause()

By adding this in the code like so, for example:

describe('page test - suite', () => {
  it('Visits page', function () {
    cy.visit('https://example.cypress.io')
    cy.contains('type').click()
    cy.url().should('include', '/commands/actions')

    cy.pause()

    cy.get('.action-email')
      .should('have.value', '')

      cy.get('.action-email')
        .type('fake@email.com')
        .should('have.value', 'fake@email.com')
  })
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

We code the test execution to halt, like below:

As you can see from the above picture, we also get a control panel that indicates what state we are in paused, a play button to resume the test run and also a step button to the right that allows us to step each line of code in our test.

### cy.debug()

What if we replace our cy.pause() for cy.debug()? Well, that works a bit differently. If you pull up developer tools the code will halt on debugger line like so:

If you go to the console you get some additional logging, like so:

So this is an additional way of getting Developer Tools to help out debugging whatever your issue is.

## Mocking

Mocking is an interesting topic. It's interesting because this is about what kind of tests we want to write. If we Mock the backend do we actually have a true E2E test? No we don't but there is still value to mock certain endpoints of your entire backend just to make sure we can easily test certain scenarios. So let's talk about how we can achieve that.

There are two ways we could be mocking:

  1. Inline code Mocking, this means we will intercept a certain route call and answer with a javascript object
  2. Fixtures, this is essentially the same as above but instead of having the response as JavaScript code we point to a JSON file

Inline code Mocking

First we need to call cy.server(), this will tell Cypress we allow mocking to happen. Next, we tell it what endpoint to mock, like so:

cy.route({
  method: 'GET',
  url: 'http://localhost:3000/products', 
  response: [{
    id: 1,
    title: 'Mocked Tomato'
  }]
})
1
2
3
4
5
6
7
8

The above is quite readable but let's explain it anyway:

  • method, this is the HTTP method we mean to listen to
  • url, this is simply the URL, we can match it exactly like above or use a more wildcard matching with *
  • response, this is where we specify the response we want instead of letting the actual API call go through

This doesn't feel quite nice though. Sure, it works, but it feels like we can do this better. We can, with fixtures

Fixtures

There are a few things we need to know about this one:

  • fixtures are JSON files
  • Cypress automatically looks in the fixtures directory for fixtures
  • You can create whatever subdirectories you need and Cypress can find them

Let's show how it can look. Given the following directory structure:

-| cypress/
---| fixtures/
------| heroes/
---------| list.json
1
2
3
4

and the following content of list.json:

[{
  "id" : 1,
  "title" : "Sir Mockalot"
}]
1
2
3
4

we can now instruct cypress to use the above JSON file like so:

cy.fixture('heroes/list.json').as('heroesList')
cy.route('GET', 'http://localhost:3000/products', '@heroesList');
1
2

the call to cy.fixture() says where my JSON file is relative to /fixtures and we und by creating an alias heroesList that we can use in the next line. The call to cy.route() does the same as before but we have to type less. It first takes an HTTP verb, followed by what URL to mock and lastly it takes our alias. Note, how we prefix the alias with @.

Just imagine the possibilities being able to specify all the possible scenarios you might need.

The million-dollar question is where to place our code? Well, the answer is where it's needed. It should before the endpoint is being called. So let's say the above endpoint is being hit when loading the page then the following code would be correct:

cy.fixture('heroes/list.json').as('heroesList')
cy.route('GET', 'http://localhost:3000/products', '@heroesList');

cy.visit('http://localhost:4200')
cy.server()
1
2
3
4
5

Adding Cypress to your SPA app

Now, its quite easy to add Cypress to any SPA app out there, it's that good. Let's use Angular as an example, but feel free to apply it to React, Vue or Svelte. We will do the following:

  1. Scaffold an Angular project
  2. Install Cypress
  3. Set up package.json
  4. Write some tests
  5. Startup everything

Scaffold our app

In Angular this is as easy as calling:

ng new <name of my project>
cd <name of my project>
1
2

Install Cypress

Installing Cypress is accomplished with the following command:

npm install cypress --save-dev
1

Set up package.json

We want to be able to start up our app and Cypress at the same time. There are many ways to do this but a popular option is using the library concurrently, which we can install with:

npm install concurrently
1

Let's now set up a task in package.json and our scripts section, like so:

"cypress": "concurrently \"ng serve\" \"cypress open\" \"json-server --watch db.json\""
1

above you can see how we use concurrently to start the angular app with ng server, followed by starting Cypress with cypress open and lastly starting up our API with json-server --watch db.json. Now, if you have a real API use whatever command you need to start that up instead. We just use json-server as a simple way to pretend we have a real API.

Write some tests

Ok, let's write some tests given the following app:

This is a simple todo app, we can:

  • Add items
  • Update Item
  • Delete item

Add item to list

For this test, we enter a value in a textbox. Then we click a button to add the item and lastly, we assert that the textbox we used for input has been cleared and that the added item exists in the list.

it('should add Hulk to list', () => {
  cy.visit("http://localhost:4200")
  cy.get(".new-hero")
    .type("Hulk")
    .should("have.value", "Hulk")

  cy.get(".new-hero-add")
    .click()
  
  cy.get(".new-hero")
    .should("have.value", "");

  cy.get(".hero-input")
    .eq(2)
    .should("have.value", "Hulk");  
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Assert we have 2 items in a list

This just ensures we have 2 items in a list. We grab a reference to the list element and checks that its length is 2.

it('should have list with length 2', () => {
  cy.visit('http://localhost:4200')
  cy.get(".hero-input")
    .its('length')
    .should("eq", 2);
})
1
2
3
4
5
6

Update item in list

Here we change an item in the list, then we click to update the item and lastly we assert that the item has been updated.

it('should update our item' , () => {
  cy.visit("http://localhost:4200")
  cy.get(".hero-input")
    .eq(1)
    .should("have.value", "Captain Marvel")
    .type("s")
    ;

  cy.get('.hero-update')
    .eq(1)
    .click()

  cy.get(".hero-input")
    .eq(1)
    .should("have.value", "Captain Marvels");
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Delete item

This is about locating an item at a specific index in the list. Then we click the corresponding delete button. Lastly, we assert and ensure that our item is removed from the list.

it('should remove our item', () => {
  cy.visit("http://localhost:4200");
  cy.get(".hero-remove")
    .eq(1)
    .click();

  cy.get(".hero-input")
    .its("length")
    .should("eq", 1);
})
1
2
3
4
5
6
7
8
9
10

Start up everything

To start everything up we call:

npm run cypress
1

To see the full source code of the Angular project with accompanying tests have a look at the following repo.

https://github.com/softchris/cypress-demo

TypeScript

Most of the major SPA frameworks today support Typescript. Typescript doesn't always make sense for your project and it's up to you if you want to add it. Remember, you can gradually add parts of it where it makes sense.

So how easy is it to add TypeScript to my Cypress tests?

Very easy, the reason for that is that Cypress ships with TypeScript types. The only thing you need is a tsconfig.json file with the following content:

{
  "compilerOptions": {
    "strict": true,
    "baseUrl": "../node_modules",
    "target": "es5",
    "lib": ["es5", "dom"],
    "types": ["cypress"]
  },
  "include": [
    "**/*.ts"
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12

With that in place auto-complete will work fine like so:

Screenshots

Finally, let's talk about something really amazing, namely screenshots, that you get for free. All you need is a call to cy.screenshot(). If you place in a lifecycle method like beforeEach() it will produce a screenshot for each test in that test suite.

Screenshots are places in the screenshots directory by default.

Below we have an example where we have invoked the following in list_spec.js:

describe('', () => {
  beforeEach(() => {
    cy.screenshot();
  })
})
1
2
3
4
5

Summary

This has been a somewhat long article but hopefully, you've seen what Cypress can do. To be honest I've only shown you a small fraction. You as a developer, deserve not only a great IDE to code in but also an outstanding test runner. I promise you, give Cypress a chance and next you will volunteer to write tests.