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
- How Cypress controls a Browser If you are interested in knowing more how Cypress deals with Browsers than this is a good page
- How Cypress works Great doc page that explains how Cypress works under the hood
- Angular + Cypress repo You can easily take the Cypress tests and build a similar app in Vue or React, that's the beauty of Cypress.
- Installing Cypress
- Writing your first test with Cypress
- [Testing strategies](https://docs.cypress.io/guides/guides/network-requests.html#Testing-Strategies https://docs.cypress.io/api/api/table-of-contents.html) It's always good to think of the approach to use when testing
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
Then install Cypress with:
npm install cypress
It's possibly to use Yarn as well:
yarn add cypress --dev
Your executable now exist on:
./node_modules/.bin/
Starting Cypress
You want to be calling cypress open
to start Cypress. You can do so in one of the following ways:
./node_modules/.bin/cypress open
$(npm bin)/cypress open
npx cypress open
for NPM version > 5.4, ornpx
installed separatelyyarn 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
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
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)
})
})
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)
})
})
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:
- Visit a web page.
- Query for an element.
- Interact with that element.
- 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
2
3
Now, give it the below content:
describe('My First Test', function() {
it('Visits page', function() {
cy.visit('https://example.cypress.io')
})
})
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')
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')
})
})
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()
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 assertioninclude
, 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')
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>')
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', '')
})
})
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')
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 testcy.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')
})
})
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:
- Inline code Mocking, this means we will intercept a certain route call and answer with a javascript object
- 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'
}]
})
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
2
3
4
and the following content of list.json
:
[{
"id" : 1,
"title" : "Sir Mockalot"
}]
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');
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()
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:
- Scaffold an Angular project
- Install Cypress
- Set up package.json
- Write some tests
- Startup everything
Scaffold our app
In Angular this is as easy as calling:
ng new <name of my project>
cd <name of my project>
2
Install Cypress
Installing Cypress is accomplished with the following command:
npm install cypress --save-dev
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
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\""
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");
})
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);
})
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");
})
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);
})
2
3
4
5
6
7
8
9
10
Start up everything
To start everything up we call:
npm run cypress
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"
]
}
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();
})
})
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.