Cypress and its inner workings

Cypress is a powerful, open-source end-to-end testing framework designed for modern web applications. It enables developers to write, run, and debug tests for anything that runs in a browser. Here are the key aspects of Cypress:
Main Features:
- Real-time browser testing with automatic reloading
- Easy debugging with detailed error messages and stack traces
- Built-in time travel debugging with DOM snapshots
- Automatic waiting for elements and API requests
- Network traffic control and stubbing capabilities
- Screenshots and video recording of test runs
Key Characteristics:
- JavaScript-based testing (tests are written in JavaScript/TypeScript)
- Runs directly in the browser alongside your application
- Simple setup with minimal configuration
- Comprehensive documentation and active community support
- Cross-browser testing capabilities (Chrome, Firefox, Edge, Electron)
Use Cases:
- End-to-end testing
- Integration testing
- Unit testing (though less common)
- Visual regression testing
- API testing
Cypress has become popular among developers for its developer-friendly approach, rich debugging features, and ability to provide fast and reliable test results for modern web applications.
How to create your own project with cypress
I recommend using vs code for going forward as it's a great environment for developing javascript projects.
The first step you are going to take when starting off with cypress is creating a separate project that contains just the cypress test suites for your main code.
To get started, create an empty folder, empty project, and install the cypress dependency.
mkdir test-cypress
cd test-cypress
npm init -y
npm install cypress --save-dev
At this point you have an empty project with a dependency. It's what you need, but it's not as useful as having a good starting point.
Scaffolding the Cypress project (template)
Simply run the command and all the files you need are going to be created. Cypress will do so because it recognises you are running it for the first time.
npx cypress open
Note: that on Mac OS you might get a widget saying that vs code doesn't have permission to run, you'll see this when you are opening the browser and you won't be able to click on the webpage.
On the webpage that opens up, you have the option to create some start up files. Those are great to do so if you are unfamiliar with cypress and you can learn a lot from them. For the purpose of the rest of this article I've created a separate project. We won't go through the files that cypress generates.
The basics of Cypress
Cypress is used to automate the actions of a user on a webpage. They might interact with certain elements such as forms, links, buttons. They might also navigate to different pages and expect to see certain text on a page. They might login, logout, or register. They might upload a document or a picture. All these actions may store state on the server and set cookies on the client.
Your cypress tests should ideally check every interaction a user might take on the website. This way you are free to change the website, run the tests, and be confident that your users won't have issues when you deploy a new version of the website. It's meant to remove all the manual work that might otherwise need to be done.
Cypress is just one technology that is able to do this. Other frameworks such as Selenium and Playwright exist. People prefer Cypress due to the ease of use, visualisation of steps, large number of libraries and plugins that work with it, its popularity, and its ecosystem such as Cypress cloud.
My expectation of you going forward
If you want to follow along with the tutorial, I expect you to have Cypress open using the command npx cypress open
and be familiar with the UI page that opens test suites. You can find this in the navigation bar under the tab "Specs".
Once you create new files, they will automatically appear there.
If you open a file containing a test and you modify the test in your system, Cypress is going to automatically detect that and it will rerun your test.
Writing our first test
Now that you know where your tests live, let's write a simple test in a file cypress/e2e/1-checkbox.cy.js
cy.visit("https://play.ioan.blog/")
cy.get('#form-checkbox #form-checkbox-element').check()
cy.get('#form-checkbox button').click()
cy.get('#form-checkbox').contains("Success!").should('be.visible')
There a few things going on here. First, the test is navigating to a page in the browser. Then, it checks a checkbox and submits the form. Finally, it tests to see that the form was submitted Successfully.
Beware that there is a bit of code you have to wrap this test with, for example:
describe('Test suite checkbox', () => {
it('Submits the form with a checkbox', () => {
// Your test goes here
})
})
Every test suite is placed in a describe
block, and every test is placed in an it
block. This is the default way of writing tests in Mocha, which cypress comes with by default. Other testing frameworks can be used if so desired. For the purpose of this article we won't focus on Mocha unless necessary.
How tests are written in Cypress
Cypress works with asynchronous commands under the hood, but it's recommended not to await operations as Cypress takes care of it for you and in doing so you allow it to retry operations as well. It's common in Cypress to not await
operations even if it would make sense, and instead to chain then
callbacks.
Here are a few more details on the use of then
over await
:
- Cypress Commands are Chainable: Cypress commands are designed to be chained together and they return chainable objects, not promises. While they look like promises, they have special behavior for retrying and waiting.
- Command Queue: Cypress commands are enqueued and executed sequentially. Using
.then()
works well with this queue-based architecture. - Automatic Waiting: Cypress automatically waits for commands to complete, making
.then()
more natural thanasync/await
. - Compatibility: Many Cypress commands don't work properly with
async/await
because they need to maintain the command queue order. - Best Practices: The Cypress documentation and community examples predominantly use
.then()
, making it the conventional approach.
While you can use async/await
in some cases (like with cy.wrap()
or when working with JavaScript promises), it's not recommended for most Cypress commands. The .then()
pattern is more idiomatic and works better with Cypress's retry-ability and automatic waiting features.
Checkpoints
It is also common to write assertions after taking actions in the webpage. But of course, that's the whole point. Well, you also want to write assertions to make sure Cypress is seeing what's expected to see before moving to the next operation. For example, once you submit a form and may be redirected to another page, you want to assert that you are seeing a piece of text that is distinguishable from the page you were previously in. This ties into how Cypress waits up to a certain period of time (4000ms by default) before moving to the next line of code. What I'm describing here is similar to checkpoints in a game. Before moving onto more assertions, check that you are seeing what you're supposed to see on the webpage at that point in time.
This can be done simply with a line such as
cy.get('#form-checkbox').contains("Success!").should('be.visible')
What the code above does is it looks for any elements that contains the text "Success!" and expects it to be visible. If this is not the case, Cypress will keep trying up to 4000ms (by default). If the criteria continues to not be matched, the test is marked as a failure.
Visualising what Cypress is doing
As the cypress test runs, it takes screenshots of before and after each action. This may contain visit commands, get commands, assertions, clicking commands, logs, etc. As you can see in the screenshot, it also shows where the cursor is at the moment the action is taken, in this case a click event.
Logging, pausing and debugging
Feel free to skip this for now, but come back to it if you need to find out why your test is not working properly.
cy.log('Test starting');
cy.log('Login attempt with user: ', username);
cy.pause(); // Pauses test execution
cy.get('.element').debug(); // Pauses here
Instead of using console.log
(which you can totally still do), it is recommended to use the utility provided by Cypress. A benefit being that it is executed after the previous line. console.log
is going to be executed immediately, usually when the test first starts.
The command pause
pauses the test at that point, and it can be resumed via the UI with a button which can be found at the top right corner. This is useful if you want to inspect the application state and step through commands.
Note: In headless mode (when running in a pipeline) the pause command is ignored, so you don't have to worry yourself with always removing it, although it would be helpful for your coworkers. It acts similar to the keyword debugger
outside Cypress where it's not going to get activated unless you have the console window open, even in production.
Debug is easiest to understand by comparing it to pause:**
- Tool context:
debug()
uses browser DevTools, whilepause()
uses Cypress UI - Scope access:
debug()
gives full debugger access,pause()
only allows stepping through commands - Headless behavior:
debug()
technically runs but isn't useful, whilepause()
is completely ignored - Use case:
debug()
for code debugging,pause()
for visual test inspection
Getting elements to act upon
Cypress supports jQuery selectors, similar to document.querySelector but with a few additions. It's also similar to css selectors. An example of an addition is the keyword :visible
which as you've probably guessed, it would only select visible elements on the screen.
Here are some commonly used Cypress commands that are similar to .get()
:
- Contains - Find elements by text:
cy.contains('Submit')
cy.contains('button', 'Submit')
cy.contains('[data-test=form]', 'Sign up')
- Find - Find within an element:
cy.get('.container').find('.item')
cy.get('form').find('input[type="email"]')
- Within - Restrict commands to a scope:
cy.get('.modal').within(() => {
cy.get('button').click()
cy.get('input').type('hello')
})
- First/Last - Get first or last element:
cy.get('li').first()
cy.get('li').last()
- Eq - Get element by index:
cy.get('li').eq(0) // First item
cy.get('li').eq(2) // Third item
- Next/Prev - Get adjacent sibling elements:
cy.get('#item1').next()
cy.get('#item3').prev()
cy.get('#item1').nextAll()
cy.get('#item3').prevAll()
- Parent/Parents - Get parent elements:
cy.get('button').parent()
cy.get('button').parents('.modal')
- Children - Get child elements:
cy.get('.list').children()
cy.get('.list').children('li')
- Siblings - Get all sibling elements:
cy.get('#item2').siblings()
cy.get('#item2').siblings('.active')
- Closest - Find closest ancestor:
cy.get('button').closest('.form-group')
cy.get('span').closest('div')
- Element - Find element of specific type
cy.get('button', 'Text')
These commands can be chained together for more specific selections:
cy.get('.container').find('ul').children('li').contains('Active').click()
You can recognise most of these from jQuery.
jQuery calls inside Cypress
As an example, here are the jQuery equivalents of .show()
and .hide()
// Show/hide elements
cy.get('#myElement').invoke('hide')
cy.get('#myElement').invoke('show')
Getting a property from an element
In Cypress, prop
and attr
are methods used to interact with HTML elements' properties and attributes, which can sometimes behave differently:
.prop()
(Properties)
- Retrieves or sets DOM properties
- Works with the current state of an element
- Returns values as their actual data types (boolean, string, number, etc.)
- Examples:
checked
,disabled
,value
,selectedIndex
// Gets the current 'checked' property value (true/false)
cy.get('input[type="checkbox"]').prop('checked')
// Gets the current input value
cy.get('input').prop('value')
.attr()
(Attributes)
- Retrieves or sets HTML attributes
- Works with the initial state defined in HTML
- Always returns values as strings
- Examples:
id
,class
,href
,data-*
// Gets the 'href' attribute value
cy.get('a').attr('href')
// Gets a data attribute
cy.get('#element').attr('data-test-id')
Key Differences:
- Boolean values:
.prop()
returnstrue/false
for boolean attributes, while.attr()
returns the attribute name orundefined
- Dynamic vs Static:
.prop()
reflects current DOM state,.attr()
reflects initial HTML markup - Input values: Use
.prop('value')
to get current input value, as.attr('value')
only returns the initial value
Common use case:
// Check if checkbox is actually checked
cy.get('input[type="checkbox"]').should('have.prop', 'checked', true)
// Check if button is disabled
cy.get('button').should('have.prop', 'disabled', true)
In short, always use .prop()
Aliases
cy.get('table').find('tr').as('rows')
cy.get('@rows').first().click()
If you have a long query selectors, this might be useful for you.
Waiting
Simply type
cy.wait(1000) // 1000ms
Typing text and verifying anchor attribute with chaining commands
const phoneNumber = "0123456789"
cy.visit("http://play.ioan.blog/")
cy.get('#form-text-field #form-text-field-input').type(phoneNumber)
cy.get('#form-text-field button').click()
cy.get('#form-text-field').contains("Success!").should('be.visible')
cy.get('#form-text-field a')
.contains(phoneNumber)
.should('be.visible')
.and('have.attr', 'href', `tel:${phoneNumber}`)
Selecting from a dynamic select box
const country = "United Kingdom"
cy.visit("https://play.ioan.blog/")
cy.get('#form-country-select #form-country-select-input').type(country)
// cy.contains("United Kingdom").debug()
cy.get('.mantine-Popover-dropdown .mantine-Select-option').contains(country).click()
cy.get('#form-country-select button').last().click()
cy.get('#form-country-select').contains("Success!").should('be.visible')
cy.get('#form-country-select p')
.contains(country)
.should('be.visible')
Solving black screenshots when looking at the history of the commands - next js solution
// Add this to cypress/support/e2e.js
beforeEach(() => {
// This will run before each test
cy.on('window:load', () => {
// Window is loaded, but let's ensure Next.js is ready
cy.get('#__next').should('be.visible')
cy.window().its('__NEXT_DATA__').should('exist')
})
})
Intercepting calls and overriding their response
If the webapp you are testing is making calls to the backend, you can inject your own code in the middle and return a different response than the one the server sends back.
In the webapp https://play.ioan.blog there is a table of books that's being displayed. It contains books fetched from the URI /api/books
and it's always returning three books. Let's change it to so it only returns two.
cy.intercept("/api/books", {
body: [
{ id: 1, author: 'Fake Authour 1', name: 'Fake Name 1', unitsSold: 1 },
{ id: 2, author: 'Fake Authour 2', name: 'Fake Name 2', unitsSold: 2 },
]
})
cy.visit("https://play.ioan.blog/")
cy.get('#books tbody tr').should('have.length', 2)
Downloading files
In the code below we are downloading a CSV file, checking for any errors when parsing, and making sure it contains one of the books
cy.visit("https://play.ioan.blog/")
cy.get('#books').contains("Download CSV").click()
const downloadsFolder = Cypress.config("downloadsFolder");
var filePath = path.join(downloadsFolder, "books.csv")
cy.readFile(filePath).then((fileContent) => {
// Parse CSV using Papaparse
const parseResult = Papa.parse(fileContent, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
transformHeader: (header) => {
// Normalize header names
const headerMap = {
'units sold': 'unitsSold',
'unitssold': 'unitsSold'
};
return headerMap[header.toLowerCase()] || header.toLowerCase();
}
});
if (parseResult.errors.length > 0) {
throw new Error(`CSV parsing errors: ${JSON.stringify(parseResult.errors)}`);
}
const books = parseResult.data
// Assert that books contains a book with name "1984"
const has1984 = books.some(book => book.name == 1984);
expect(has1984).to.be.true;
})
Cypress config
On newer versions of cypress the config file can be found in the root directory and it's called cypress.config.js
.
A list of available settings can be found here.
If you want to change the default timeout from 4000ms to 10000ms, you'd have this in your config:
e2e: {
defaultCommandTimeout: 10000,
},
You can also override the default cypress config for the current test by having a line like this:
Cypress.config({
defaultCommandTimeout: 10000,
})
This will only affect the test you are calling it from.
You can view a list of the settings that Cypress runs by opening your project with Cypress and going into the navigation list to the "Settings" tab. From there, navigate to the "Project settings" section.
Another configuration usually overridden is specPattern
which defaults to cypress/e2e/**/*.cy.{js,jsx,ts,tsx}'
. You may not want your cypress tests to end with .cy.js
, and this is the place to change that.
Retrying tests automatically
Some tests can be flaky. Instead of having your entire suite break when you are running a deployment, you can enable retry mode in the config. Of course, don't rely on this, and fix the flaky tests when you can.
module.exports = defineConfig({
retries: {
// Configure retry attempts for `cypress run`
// Default is 0
runMode: 2,
// Configure retry attempts for `cypress open`
// Default is 0
openMode: 0,
},
})
Environment variables
Environment variables can be set up in your config and overridden when you run the cli.
Example test:
cy.visit(Cypress.env('baseUrl'))
cypress.config.js
env: {
'baseUrl': 'https://google.com/'
}
Running Cypress normally will open google.com:
npx cypress open
Running Cypress with an environment variable set to microsoft.com will open microsoft.com:
npx cypress open --env baseUrl="https://microsoft.com/"
Uploading a file
cy.visit("https://play.ioan.blog/")
cy.get('#books').contains("Download CSV").click()
const downloadsFolder = Cypress.config("downloadsFolder");
var filePath = path.join(downloadsFolder, "books.csv")
cy.readFile(filePath).then((fileContent) => {
const newFilePath = filePath.replace("books.csv", "books-upload.csv")
cy.writeFile(newFilePath, fileContent.replace("1984", "1985"))
cy.get('#books').contains("Upload CSV").click()
// The upload happens here, it's using force because the element is hidden
cy.get('#books input[type="file"]').selectFile(newFilePath, { force: true })
cy.get('#books tbody tr:nth-child(2) td:nth-child(3)').should('have.text', "1985")
})
Command line
Opening cypress for developing
npx cypress open
Running all tests in headless mode
npx cypress run
Running all tests in headless mode with specific browser
npx cypress run --browser chrome
Running all tests in headed more
npx cypress run --headed
Running a specific test suite (file) in headless mode.
This is useful if you want to have a smoke test or some tests that run more frequently than others.
npx cypress run --spec cypress/e2e/1-checkbox.cy.js
Cy.wrap
In Cypress, wrap()
is a command that allows you to turn any JavaScript value or jQuery object into a Cypress chain, so you can perform Cypress commands on it.
Here are some key uses of cy.wrap()
:
Wrap simple values:
cy.wrap(42).should('equal', 42)
cy.wrap('hello').should('have.length', 5)
cy.wrap({name: 'John'}).its('name').should('equal', 'John')
Use with aliases:
cy.wrap('my value').as('myAlias')
cy.get('@myAlias').should('equal', 'my value')
The main benefit of wrap()
is that it allows you to use Cypress commands and assertions on any JavaScript object, not just DOM elements. This is particularly useful for testing the results of API calls, working with custom objects, or handling promises in your tests.
Fixtures
Cypress fixtures are a way to provide static test data in your tests. Here's what you need to know about them:
Purpose Fixtures help you separate test data from your test logic, making tests cleaner and more maintainable. They're especially useful for API responses, user data, or any other static data your tests might need.
Location
Fixtures are typically stored as JSON files in the cypress/fixtures
directory by default.
Usage You can load fixtures in your tests in several ways:
- Using cy.fixture():
cy.fixture('users.json').then((users) => {
// Use the fixture data
cy.get('[data-test="username"]').type(users[0].username)
})
- Using beforeEach/before hook:
describe('My Test', () => {
let userData;
beforeEach(() => {
cy.fixture('userData').then((data) => {
userData = data;
});
});
it('should use fixture data', () => {
cy.get('[data-test="email"]').type(userData.email);
});
});
- Using import (TypeScript/ES6):
import userData from '../fixtures/userData.json';
it('should use imported fixture', () => {
cy.get('[data-test="name"]').type(userData.name);
});
Conclusion
There are many parts of Cypress that were not covered here. Their documentation is top notch for delving deeper and I highly suggest you check it out. Check out the page I've created at play.ioan.blog and try writing some tests against it - it's there for you to use it. I hope you are enjoying Cypress as much as I am.