CodeWorlds
Back to collections
Guide17 min readCodeWorlds Team

Cypress, end to end testing inside the browser

Cypress 15.21.0 runs test code inside the browser. MIT licence, Cypress Cloud pricing, free plan limits and the places where Playwright wins instead.

Cypress, end to end testing inside the browser

Cypress is an end to end testing tool whose test code executes in the same event loop as the application under test. The current version is 15.21.0, released on 18 August 2026, the cypress-io/cypress repository holds roughly 51 thousand stars, and the npm package is licensed under MIT. What costs money is the Cypress Cloud service, not the tool itself.

What Cypress actually does

The difference between Cypress and the rest of the field comes down to one sentence from the official documentation: Cypress commands run inside the browser. There is no wire protocol, no object serialization to JSON, no Node process telling a browser over a socket to click an element. The spec file loads into the same tab as the application, in an adjacent frame, and has direct access to its window object, to the document model, and to everything the application exposes.

Everything else follows from that, the good and the bad. The good part is the interface. Running cypress open gives you a window with the command log on the left and the application on the right. Every command leaves a snapshot of the page state, so hovering over a cy.get('[data-cy=submit]') entry restores the application preview to the moment before the click. This works after the test has finished, without rerunning anything. Competitors reproduce the same effect from a recording rather than from live state, and that is a real difference in daily work.

The second consequence is automatic retrying. Commands do not return values immediately; they queue up and re-query the document model until the query succeeds or defaultCommandTimeout elapses, which defaults to 4000 milliseconds. Assertions such as .should('be.visible') are retried the same way. Cypress tests therefore contain almost no explicit waiting on a condition, because the mechanism handles it.

The third is a set of limitations the documentation calls permanent outright. Test code runs in the browser, so it is always JavaScript and never anything else. You cannot drive two browsers at once. Each test is bound to a single superdomain. There is no command that switches context into an iframe, although same-origin frames can be queried with a plain cy.get. Talking to a database or a backend goes through cy.task() and cy.request(), because you simply cannot import a Node module into a spec.

Cypress also handles component testing, in the same window and with the same command log. That competes directly with tests in Vitest plus an in-memory rendering library, except that here the component renders in a real browser and you can see it on screen.

Version, licence and distribution

Releases arrive roughly every two weeks. Versions 15.16.0 through 15.21.0 shipped between 26 May and 18 August 2026. The engines field requires Node ^20.1.0 || ^22.0.0 || >=24.0.0, so Node 18 and Node 21 are out of support. The package carries 39 direct dependencies.

The licence needs checking in three places, because the sources disagree. The LICENSE file in the repository holds the MIT text with the notice "Copyright (c) 2023 Cypress.io". The license field in the npm registry for the cypress package reads MIT. The published package, however, once you unpack its 836 archive entries, contains no licence file at all. No LICENSE, no LICENSE.md, no COPYING. If your compliance process harvests licence texts from node_modules, it will harvest nothing for Cypress and flag the dependency as undocumented.

The second half of that story is more interesting. The cypress package on npm is essentially a downloader. Its package.json declares "postinstall": "node dist/index.js --exec install", and that script fetches the actual binary from download.cypress.io. The npm archive weighs under a megabyte, while what you really run arrives through a separate channel, outside the registry and outside its verification machinery. The behaviour can be bent with the CYPRESS_INSTALL_BINARY, CYPRESS_DOWNLOAD_MIRROR and CYPRESS_CACHE_FOLDER variables, and in an air-gapped environment you have to bend it anyway. The practical conclusion is that the MIT licence describes the code in the repository, not the executable that lands in a cache on your build machine.

Cypress Cloud stands apart. It is a closed commercial service run by the same company and none of its code is public. Since version 15.21.0 there is also a CYPRESS_DISABLE_GUEST_TELEMETRY variable that disables the reporting sent for sessions without a login. The mere fact that it was added only now says that anonymous events from open mode and from the command line previously went out by default.

Installation and configuration

Entering a project looks like this.

Code
Bash
# install pinned to an exact version
npm install --save-dev --save-exact cypress

# window with the command log, setup wizard on first launch
npx cypress open

# headless run, for a continuous integration pipeline
npx cypress run

# a chosen browser and a single spec
npx cypress run --browser firefox --spec "cypress/e2e/login.cy.ts"

# component tests instead of end to end
npx cypress run --component

# a run recorded in Cypress Cloud, split across machines
npx cypress run --record --key "$CYPRESS_RECORD_KEY" --parallel \
  --ci-build-id "$GITHUB_RUN_ID" --group "e2e-chrome"

# check whether the downloaded binary starts at all
npx cypress verify

# agent access to an open session, added in 15.21.0
npx cypress tap --help

Configuration sits in cypress.config.ts at the project root. Below is a set of fields that genuinely exist in the 15.21.0 type definitions.

Code
TypeScript
import { defineConfig } from 'cypress'

export default defineConfig({
  defaultCommandTimeout: 8000,
  pageLoadTimeout: 60000,
  requestTimeout: 5000,
  responseTimeout: 30000,
  viewportWidth: 1280,
  viewportHeight: 800,
  video: false,
  videoCompression: false,
  screenshotOnRunFailure: true,
  trashAssetsBeforeRuns: true,
  numTestsKeptInMemory: 20,
  experimentalMemoryManagement: true,
  retries: { runMode: 2, openMode: 0 },
  blockHosts: ['*.google-analytics.com', '*.hotjar.com'],
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
    supportFile: 'cypress/support/e2e.ts',
    testIsolation: true,
    setupNodeEvents(on, config) {
      on('task', {
        resetDatabase: async () => {
          await fetch(`${config.env.apiUrl}/test/reset`, { method: 'POST' })
          return null
        }
      })
      return config
    }
  },
  component: {
    devServer: { framework: 'react', bundler: 'vite' },
    specPattern: 'src/**/*.cy.{ts,tsx}'
  }
})

A few fields deserve comment. retries takes separate values for run mode and open mode, and setting retries in open mode usually makes no sense, because it hides the failure you are trying to look at. numTestsKeptInMemory defaults to 50 and is one of the main reasons the browser swells during long specs; lowering it costs you snapshots of older tests. experimentalMemoryManagement is sometimes necessary in containers with a hard memory limit. The default for videoCompression is false, even though the type definitions until recently claimed 32 incorrectly.

The debugging loop, the tool's strongest side

The test itself looks ordinary, but what matters is what happens after it runs.

Code
TypeScript
describe('cart', () => {
  beforeEach(() => {
    cy.task('resetDatabase')
    cy.visit('/shop')
  })

  it('adds a product and recalculates the total', () => {
    cy.intercept('POST', '/api/cart', { statusCode: 201, body: { items: 1 } }).as('addToCart')

    cy.get('[data-cy=product-card]').first().within(() => {
      cy.get('[data-cy=add-to-cart]').click()
    })

    cy.wait('@addToCart').its('request.body').should('deep.equal', { sku: 'CW-001', qty: 1 })

    cy.get('[data-cy=cart-total]')
      .should('be.visible')
      .and('contain.text', '129.00')

    cy.window().its('localStorage.cartId').should('be.a', 'string')
  })
})

Once this test finishes, the Cypress window shows a list of four steps. Click cy.wait('@addToCart') and the browser console prints the full request and response objects. Hover over .click() and the preview returns to the state before the click. Pin the step, switch to the developer tools and poke around the document model from that exact moment. None of this requires another run.

The cy.window() command shows in passing what running test code in the browser buys you. You reach the application's real window object, not a representation shipped over a protocol. You can read Redux store state from it, replace a method on a global object or call a function the application exposes. In tools that drive a browser from outside, the same thing requires passing a function to be evaluated on the other side and receiving back only serializable values.

Version 15.21.0 added the cypress tap command, which exposes an open session to an agent. Its subcommands list the running sessions, start and rerun a spec, report a run's status and results, print a failing test's error together with the command log, and show the document model and accessibility tree of the application under test. Each prints readable text by default and machine-readable data with --json. For anyone writing tests with GitHub Copilot or another assistant, that is a concrete change, because the agent stops guessing why a test failed.

Sessions, network and multiple origins

Three commands decide whether a suite will be fast or not.

Code
TypeScript
// log in once per spec rather than before every test
const login = (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit('/login')
    cy.get('[data-cy=email]').type(email)
    cy.get('[data-cy=password]').type(password, { log: false })
    cy.get('[data-cy=submit]').click()
    cy.url().should('include', '/dashboard')
  }, {
    cacheAcrossSpecs: true,
    validate: () => {
      cy.request('/api/me').its('status').should('eq', 200)
    }
  })
}

// logging in through an external identity provider
it('logs in through an external provider', () => {
  cy.visit('/login')
  cy.get('[data-cy=sso]').click()

  cy.origin('https://accounts.example.com', { args: { user: 'alice@example.com' } }, ({ user }) => {
    cy.get('input[name=identifier]').type(user)
    cy.get('button[type=submit]').click()
  })

  cy.url().should('include', '/dashboard')
})

cy.session() stores cookies plus the contents of localStorage and sessionStorage, and on the next call with the same identifier it restores them instead of walking through the form. The cacheAcrossSpecs option defaults to false, so without it the session cache ends together with the spec. The validate function checks whether the restored session is still valid, and on failure Cypress rebuilds it. Without this mechanism every test logs in through the form and the suite becomes several times slower.

cy.origin() is the way around the single superdomain restriction. The block passed to that command executes in the context of another origin, but it is a separate world: it sees no variables from the surrounding scope, so everything has to be passed through args, and the values must be serializable. Importing a module inside the block requires enabling experimentalOriginDependencies. For a single login flow this is enough. For a test that hops across three origins and compares their state, the code gets heavy.

cy.intercept() captures network requests and lets you replace them or merely observe them. Cypress proxies WebSocket connections transparently and can see the protocol upgrade request as resourceType: 'websocket', but stubbing individual frames is not supported. If you are testing a chat or live notifications, that is the boundary of the tool.

Cypress Cloud, or what you pay for

The tool itself is free and unrestricted. What you pay for is the service that accepts run results, and it governs two things that matter in a pipeline: parallelization and run replay. The --parallel and --record flags require a project key, because it is the Cypress Cloud server that distributes specs across machines and balances the load using historical timings.

Contrary to widespread belief, parallelization and Test Replay are available on the free plan too. The limitation is not the feature but the counter. The Starter plan covers 50 users, 500 test results per month, 100 test generation prompt executions per month, 30 days of data retention and community support only. A test result is one test in one run, so a suite of 60 tests triggered on every merge burns that allowance in under nine runs. For a solo project that is enough; for a team it is not.

Pricing for the higher plans reads as follows on the pricing page. The Team plan costs 799 dollars a year and covers 120 thousand test results per year, 9 thousand prompt executions, 90 days of retention, plus flake detection and a Jira integration. The Business plan costs 3199 dollars a year, has the same 120 thousand results, 24 thousand prompt executions, spec prioritization, automatic run cancellation and single sign-on. Enterprise is negotiated, with unlimited users, 1.8 million results per year and 180 days of retention.

Two things in that price list need flagging. The plan cards show monthly figures of 67 and 267 dollars, but those are the annual price divided by twelve and rounded up, since 799 over twelve is 66.58 and 3199 over twelve is 266.58. Multiplying the monthly figure by twelve does not match the annual figure, and this is not a separate offer. The second thing is the cost of going over: results bought in advance run at 5.28 dollars per thousand on Team, 4.40 on Business and 4.00 on Enterprise, while results billed after the fact run at 6 and 5 dollars per thousand respectively. That is where a bill grows unnoticed, because every retry of a failing test counts.

The alternative is parallelizing by hand: splitting specs across machines through a job matrix in your continuous integration system and passing the range through --spec. You lose load balancing based on history, and the report has to be assembled from separate files. For a hundred specs across four machines that works and costs nothing.

Cypress against the alternatives

FeatureCypressPlaywrightSelenium WebDriverWebdriverIOPuppeteer
Where test code runsin the browserin Nodein Nodein Nodein Node
Test languagesJavaScript and TypeScriptJS, TS, Python, Java, .NETmany languagesJavaScript and TypeScriptJavaScript and TypeScript
Multiple tabs in a testvia the @cypress/puppeteer pluginnativelynativelynativelynatively
Multiple originsvia cy.originnativelynativelynativelynatively
Parallelizationrequires Cypress Cloudbuilt in, workers optionthrough Selenium Gridbuilt in, maxInstances optionyour own implementation
Browser enginesChromium and Firefox, WebKit experimentalChromium, Firefox, WebKitdepends on the driverdepends on the driverChromium and Firefox
Current version15.21.01.62.14.47.09.31.225.8.0
npm package licenceMITApache 2.0Apache 2.0MITApache 2.0

The choice hinges on a few questions. If the flow under test touches multiple tabs, multiple origins or multiple concurrent users, a chat say, or a document edited by two people, Playwright wins without argument and there is nothing to spin here. If the team needs to write tests in Python or Java, Cypress is not a candidate at all. If parallelization in the pipeline has to be built in and free, Playwright is again the simpler pick.

Cypress still wins where onboarding time and time to find a failure's cause matter. State snapshots after every command, one window instead of a report in a browser, no need to understand JavaScript asynchrony when writing your first tests. For a team where tests are also written by someone outside the core development group, that difference is felt. On top of that come component tests in a real browser, which pair sensibly with React and with a component catalogue in Storybook, where in-memory rendering falls short.

Common mistakes

The first is using cy.wait() with a number of milliseconds. Since commands retry until they succeed anyway, a fixed wait only lengthens the run and still does not guarantee the condition held. The correct form is cy.wait('@alias') after intercepting a request, or an assertion that retries on its own.

The second is treating a command's result as an ordinary value. const el = cy.get('.item') does not return an element, it returns a queue object. You get the value inside .then() or through cy.wrap(). Mixing async and await with the command chain ends with a test that passes for the wrong reason.

The third is selectors based on CSS classes or on button text. A class changes at the next styling rearrangement, and text changes when a translation does. The data-cy attribute is ugly in markup and cheap to maintain, and that is the right trade.

The fourth is logging in through the form before every test. Use cy.session() with cacheAcrossSpecs: true and a validate function instead. Without cacheAcrossSpecs the cache ends together with the spec file, which across a hundred files means a hundred logins.

The fifth is disabling testIsolation so that tests can depend on one another in sequence. The field exists and defaults to true, but setting it to false produces a suite where a failure in the third test topples every later one, with a cause that cannot be read from the report.

The sixth is retries as a cure for flakiness. retries: { runMode: 2 } hides the problem and doubles the Cypress Cloud bill, because every repeat counts as a separate test result. Retries make sense as a safety net, not as a way of keeping the pipeline green.

The seventh is not caching the binary in the pipeline. The npm package downloads the executable on every install, so without persisting the directory named by CYPRESS_CACHE_FOLDER, each run begins by pulling several hundred megabytes from the vendor's server.

The eighth is leaving the types entry out of the configuration. The type definitions ship inside the package, but without "types": ["cypress"] in tsconfig.json the editor knows neither cy nor Cypress, which in a TypeScript project wipes out half the benefit of completions.

FAQ

Is Cypress free?

The tool itself is, under MIT, with no restrictions on commercial use and no cap on local or pipeline runs. What costs money is the Cypress Cloud service, although the Starter plan is free and covers 500 test results per month plus 30 days of data retention. Higher plans cost 799 and 3199 dollars a year.

Does parallelization require a paid plan?

It does not require a paid plan, but it does require a Cypress Cloud account, because the service's server distributes specs across machines. On the free plan it fits inside the 500 results per month allowance. The alternative without the service is splitting specs yourself through --spec and a job matrix in your continuous integration system, at the cost of losing load balancing.

Does Cypress handle multiple tabs and multiple origins?

Multiple tabs not natively; the documentation points to the @cypress/puppeteer plugin at version 0.1.8. Multiple origins work through cy.origin(), where the block executes in a separate context, arguments are passed through args and must be serializable. That covers logging in through an external provider, but not elaborate cross-origin scenarios.

When should I pick Playwright over Cypress?

When you need multiple tabs, multiple concurrent users, tests in Python or Java, built-in parallelization without an external service, or full support for the WebKit engine, which in Cypress is still marked experimental. For simple single-origin flows the difference is small and debugging comfort decides instead.

Does the npm package contain a licence file?

No. The repository has a LICENSE file with the MIT text and the npm registry reports MIT, but the published 15.21.0 archive contains no licence file whatsoever. On top of that, the actual binary is not published to npm at all; it is fetched from the vendor's site by the postinstall script.

Does Cypress replace unit tests?

No, and bending it that way is not worthwhile. Cypress component tests render a component in a real browser, which makes sense for checking styles, input events and accessibility. For fast logic tests without a document model, a runner that executes in Node is an order of magnitude quicker.

Documentation lives on the Cypress docs site, the list of permanent limitations in the trade-offs section, and the source code in the GitHub repository.

Read next

We use cookies to enhance your experience on the site