Resource library

QA How-To

How to Use Cypress cy.stub and cy.spy (2026)

Learn cypress cy.stub and cy.spy with TypeScript examples for return values, call assertions, browser APIs, aliases, components, timers, and intercepts.

20 min read | 2,496 words

TL;DR

cy.spy(object, method) records calls while allowing the original method to run. cy.stub(object, method) replaces the method and lets the test define returns, fakes, errors, promise outcomes, or per-call behavior. Both are synchronous Sinon utilities, and Cypress restores their sandboxed changes between tests.

Key Takeaways

  • Use cy.spy when the original function should still execute, and cy.stub when the test must replace or control it.
  • Both utilities return Sinon test doubles synchronously and support Sinon and Sinon-Chai behavior and assertions.
  • Create browser API doubles before application code captures or calls the target method, often through visit onBeforeLoad.
  • Alias a double when later Cypress commands need retryable assertions against its call history.
  • Use cy.intercept for HTTP traffic and cy.stub or cy.spy for in-process JavaScript function boundaries.
  • Prefer observable user outcomes plus a focused interaction assertion instead of testing every internal call.
  • Rely on Cypress sandbox restoration between tests and avoid leaking manually replaced functions.

Cypress cy.stub and cy.spy are Sinon-backed utilities for controlling or observing JavaScript functions during a test. Use cy.spy() when the real method should run and you only need call evidence. Use cy.stub() when the test must replace the method, prevent its side effect, or force a deterministic result.

They are useful for browser APIs, analytics, callbacks, dependency boundaries, component props, timers, and error branches that are difficult to trigger naturally. They are not the right tool for every network response or every implementation detail. This guide shows runnable patterns and explains the design decisions that keep test doubles useful instead of brittle.

TL;DR

Need Use Does original run? Typical example
Record calls and arguments cy.spy(object, method) Yes Verify analytics call
Replace return value cy.stub(object, method) No Control window.confirm
Supply custom implementation cy.stub(...).callsFake(fn) No Simulate dependency result
Observe or stub HTTP cy.intercept() Not a function double Control application API traffic
Control time cy.clock() and cy.tick() Fake clock Test delayed callback
const calculator = {
  add(a: number, b: number) {
    return a + b
  },
}

const spy = cy.spy(calculator, 'add')
expect(calculator.add(2, 3)).to.eq(5)
expect(spy).to.have.been.calledWith(2, 3)
spy.restore()

const stub = cy.stub(calculator, 'add').returns(99)
expect(calculator.add(2, 3)).to.eq(99)
expect(stub).to.have.been.calledOnce

1. How Cypress cy.stub and cy.spy Work

cy.spy(object, method) wraps an existing method and records calls, arguments, return values, thrown errors, and related Sinon history while allowing the original implementation to execute. cy.stub(object, method) replaces the method. A no-argument cy.stub() creates a standalone function that the test can pass as a callback or prop.

Despite the cy prefix, both are Cypress utility functions rather than Cypress commands, queries, or assertions. They return a Sinon spy or stub synchronously. They are not retryable, timeout-aware command subjects. Cypress does add convenient logging and alias support, and its bundled Sinon-Chai integration enables readable call assertions without extra configuration.

That timing distinction matters. This works synchronously:

it('records a pure function call', () => {
  const formatter = {
    label(id: number) {
      return `Item ${id}`
    },
  }

  const labelSpy = cy.spy(formatter, 'label')

  const result = formatter.label(42)

  expect(result).to.eq('Item 42')
  expect(labelSpy).to.have.been.calledOnce
  expect(labelSpy).to.have.been.calledWith(42)
})

The spy must be installed before the call. If application code captured a reference to the original function earlier, replacing the object's property later may not affect that captured reference. This is why browser methods are commonly stubbed in cy.visit() through onBeforeLoad, before the application loads.

Think of a test double as a boundary tool. It is most valuable where a test needs to observe a meaningful interaction or control an expensive, nondeterministic, destructive, or unavailable dependency. It is least valuable when it merely restates internal implementation calls.

2. Choose cy.stub vs cy.spy With Intent

The choice is based on side effects and control. A spy is safer when the real behavior is part of the outcome. A stub is safer when the real behavior would block automation, leave the browser, send analytics, charge a payment method, or make a test branch hard to reproduce.

Question Choose spy Choose stub
Should the original function execute? Yes No
Do you only need call history? Yes Maybe, but replacement is the key
Must the test force a return value? No Yes
Can the real side effect harm or destabilize the test? No Yes
Are you checking a callback passed to a component? Either Standalone stub is common
Are you changing an HTTP response? Neither Use cy.intercept()

A spy on console.error can prove that a component logs an error, but the real error output still appears. A stub can suppress the output and record it. A spy on window.open can open a real tab or trigger browser behavior, while a stub prevents that. Select based on whether executing the original is desirable.

Do not convert every dependency into a stub merely to make a test pass. A component tested with all collaborators replaced may only prove that its mock wiring matches itself. Keep real pure logic and stable in-memory collaborators when they are fast and deterministic. Stub at meaningful system boundaries.

For network-level decisions, the Cypress intercept guide explains request matching, aliases, and response control.

3. Stub Return Values, Fakes, Errors, and Promises

A stub's central feature is behavior control. Sinon methods such as returns, callsFake, throws, resolves, and rejects are available on Cypress stubs. Choose the smallest behavior that represents the dependency contract.

describe('stub behaviors', () => {
  it('returns a fixed value', () => {
    const pricing = {
      discountFor(_customerId: string) {
        return 0
      },
    }

    const discountStub = cy.stub(pricing, 'discountFor').returns(15)

    expect(pricing.discountFor('customer-42')).to.eq(15)
    expect(discountStub).to.have.been.calledWith('customer-42')
  })

  it('runs a type-safe fake', () => {
    const inventory = {
      available(_sku: string, _quantity: number) {
        return false
      },
    }

    cy.stub(inventory, 'available').callsFake((sku: string, quantity: number) => {
      return sku === 'BOOK-1' && quantity <= 3
    })

    expect(inventory.available('BOOK-1', 2)).to.eq(true)
    expect(inventory.available('BOOK-1', 5)).to.eq(false)
  })
})

Use throws for a synchronous failure and rejects for a Promise rejection. Do not return Promise.reject() from a synchronous dependency just because the test wants an error. Match the actual contract.

it('models an asynchronous rejection', async () => {
  const profileApi = {
    save(_name: string): Promise<void> {
      return Promise.resolve()
    },
  }

  const saveStub = cy.stub(profileApi, 'save')
    .rejects(new Error('Service unavailable'))

  let caught: unknown
  try {
    await profileApi.save('Sam')
  } catch (error) {
    caught = error
  }

  expect(caught).to.be.instanceOf(Error)
  expect((caught as Error).message).to.eq('Service unavailable')
  expect(saveStub).to.have.been.calledOnce
})

In an end-to-end test, prefer cy.intercept() when the dependency is reached through HTTP. Stubbing an internal API module can couple the test to bundler behavior and bypass the observable network contract. Function stubs are most natural in component tests and browser API boundaries.

4. Spy on Real Behavior Without Replacing It

A spy proves an interaction occurred while retaining the result and side effects of the real method. It is ideal when the interaction itself is important and safe. Consider a controller that formats and sends an audit event. Spying on the formatter would be implementation-heavy, while spying on the public audit client boundary can express a business obligation.

it('records an audit event and keeps the real implementation', () => {
  const audit = {
    events: [] as Array<{ action: string; entityId: string }>,
    record(event: { action: string; entityId: string }) {
      this.events.push(event)
    },
  }

  const recordSpy = cy.spy(audit, 'record')

  audit.record({ action: 'project.publish', entityId: 'prj_42' })

  expect(recordSpy).to.have.been.calledOnce
  expect(recordSpy).to.have.been.calledWith({
    action: 'project.publish',
    entityId: 'prj_42',
  })
  expect(audit.events).to.deep.equal([
    { action: 'project.publish', entityId: 'prj_42' },
  ])
})

The final state assertion shows that the original method ran. That outcome often matters more than the call count. Avoid exact call counts when the implementation may legitimately batch, debounce, or retry. Assert calledWith or a user-visible result if the contract allows multiple internal calls.

Spies are useful for native methods too, but consider the effect. Spying on window.location.assign can still navigate and interrupt the test. A stub is more appropriate when the original action is disruptive. Spying on a safe callback or analytics adapter is less risky.

5. Stub Browser APIs Before the App Loads

Browser APIs such as window.prompt, window.confirm, and window.open are common test-double targets. In an end-to-end visit, install the stub in onBeforeLoad so application startup code sees the replacement.

it('uses the name returned by window.prompt', () => {
  cy.visit('/profile', {
    onBeforeLoad(win) {
      cy.stub(win, 'prompt')
        .withArgs('Choose a display name')
        .returns('QA Captain')
        .as('prompt')
    },
  })

  cy.contains('button', 'Change display name').click()

  cy.get('@prompt').should('have.been.calledOnce')
  cy.get('[data-testid=display-name]').should('have.text', 'QA Captain')
})

The alias is created while the stub is installed and can be retrieved later with cy.get('@prompt'). The UI assertion proves the application used the returned value. Both assertions are useful: one diagnoses the boundary interaction, and the other protects the user outcome.

To reject a destructive action, control confirm.

it('keeps the project when confirmation is declined', () => {
  cy.visit('/projects/prj_42', {
    onBeforeLoad(win) {
      cy.stub(win, 'confirm').returns(false).as('confirmDelete')
    },
  })

  cy.contains('button', 'Delete project').click()

  cy.get('@confirmDelete').should(
    'have.been.calledWith',
    'Delete this project?',
  )
  cy.location('pathname').should('eq', '/projects/prj_42')
  cy.contains('h1', 'Release plan').should('be.visible')
})

If the application destructures or stores a browser method during module initialization, stubbing after cy.visit() can be too late. onBeforeLoad solves that timing for page startup.

6. Use Aliases and Sinon-Chai Assertions

A direct expect() assertion executes immediately. That is correct when the call has already happened synchronously. When an application action happens through queued Cypress commands, alias the double and retrieve it later. A .should() assertion on the alias benefits from Cypress command retry behavior while the application completes the interaction.

it('sends analytics after saving settings', () => {
  cy.visit('/settings', {
    onBeforeLoad(win) {
      const analytics = { track: (_name: string) => undefined }
      Object.assign(win, { analytics })
      cy.spy(analytics, 'track').as('track')
    },
  })

  cy.get('[data-testid=timezone]').select('UTC')
  cy.contains('button', 'Save').click()

  cy.get('@track').should('have.been.calledWith', 'settings_saved')
  cy.contains('[role=status]', 'Settings saved').should('be.visible')
})

Common Sinon-Chai assertions include called, calledOnce, calledWith, calledWithExactly, and calledBefore. Use the least brittle one. calledWith allows additional arguments, while calledWithExactly requires an exact list. Exactness is valuable for a public callback contract, but it can overfit an internal implementation.

For lower-level inspection, a returned spy or stub exposes Sinon history such as callCount, firstCall.args, and getCall(index). Prefer expressive assertions first because their failure messages are clearer.

High-volume events can flood the Command Log. Both utilities support .log(false) on the returned test double. Use it selectively:

const observer = { onScroll(_position: number) {} }
const scrollSpy = cy.spy(observer, 'onScroll').log(false)

Disabling log display does not change call recording and does not make sensitive argument data safe everywhere.

7. Configure Per-Call and Argument-Specific Behavior

Real collaborators sometimes produce a sequence: a transient failure followed by success, or different outcomes for different inputs. Sinon stubs support onFirstCall, onSecondCall, and withArgs. These patterns are more precise than a counter hidden inside callsFake.

it('models a retry that succeeds on the second call', () => {
  const uploader = {
    send(): Promise<{ id: string }> {
      return Promise.resolve({ id: 'real' })
    },
  }

  const sendStub = cy.stub(uploader, 'send')
  sendStub.onFirstCall().rejects(new Error('Temporary failure'))
  sendStub.onSecondCall().resolves({ id: 'upload-42' })

  const attempt = () => uploader.send().catch(() => uploader.send())

  cy.then(attempt).then((result) => {
    expect(result.id).to.eq('upload-42')
    expect(sendStub).to.have.been.calledTwice
  })
})

Argument-specific behavior can model a supported and unsupported input.

const permissions = { can(_action: string) { return false } }
const canStub = cy.stub(permissions, 'can')
canStub.withArgs('project:read').returns(true)
canStub.withArgs('project:delete').returns(false)

expect(permissions.can('project:read')).to.eq(true)
expect(permissions.can('project:delete')).to.eq(false)

A sequence test should assert why the sequence matters, such as visible retry feedback and eventual success. Otherwise it merely tests the fake. Avoid simulating complex state machines entirely in stubs. When behavior depends on HTTP status, headers, or response timing, an intercept fixture is usually a clearer boundary.

8. Use Stubs and Spies in Component Tests

Component tests commonly pass a standalone stub as a callback prop. This avoids replacing a module method and expresses the component's public contract. The following React example assumes Cypress component testing is configured with mount from cypress/react.

import { mount } from 'cypress/react'

type SaveButtonProps = {
  onSave: (value: string) => void
}

function SaveButton({ onSave }: SaveButtonProps) {
  return (
    <button onClick={() => onSave('release-ready')}>
      Save
    </button>
  )
}

it('emits the saved value', () => {
  const onSave = cy.stub().as('onSave')

  mount(<SaveButton onSave={onSave} />)
  cy.contains('button', 'Save').click()

  cy.get('@onSave').should('have.been.calledOnceWith', 'release-ready')
})

A standalone stub is a real function, so the component can call it normally. If the prop must return a value, configure .returns(value) before mounting. For a Promise-returning callback, use .resolves(value) or .rejects(error) and assert the component's loading or error UI.

Dependency injection makes components easier to test than trying to replace read-only ES module imports. Imported bindings may be immutable or transformed differently by the bundler. Prefer passing an adapter through props, context, or a factory boundary. If you must stub a module method, ensure the exported object property is actually configurable in the test environment.

Cypress automatically restores sandboxed stubs and spies between tests. Still create the double inside each test or its local setup so ownership is obvious and call history cannot influence another assertion.

9. Compare Function Doubles With cy.intercept

cy.stub() and cy.spy() operate on JavaScript functions in the test's accessible runtime. cy.intercept() operates on HTTP requests made by the application. Confusing those boundaries leads to fragile tests.

it('shows an error returned by the save API', () => {
  cy.intercept('POST', '/api/projects', {
    statusCode: 503,
    body: { code: 'SERVICE_UNAVAILABLE' },
  }).as('saveProject')

  cy.visit('/projects/new')
  cy.get('[name=name]').type('Release plan')
  cy.contains('button', 'Create').click()

  cy.wait('@saveProject').its('response.statusCode').should('eq', 503)
  cy.contains('[role=alert]', 'Please try again').should('be.visible')
})

Trying to stub an internal fetchProject() import for this end-to-end case ties the test to code structure and can miss request serialization, headers, and error mapping. Intercept expresses the actual external boundary.

Conversely, cy.intercept() cannot replace window.confirm, observe a callback prop, or control a pure formatter. Use a function double there. cy.request() is different again: it initiates a direct request from the test and is not captured by intercept. See cy.request versus cy.intercept for the complete network model.

A layered test may stub a browser popup, intercept an application POST, and assert the rendered outcome. Use each tool only where it has a clear responsibility.

10. Combine Test Doubles With Clock Control

A callback scheduled by setTimeout is easier to test with cy.clock() and cy.tick() than with a real wait. The callback itself can be a stub. Install the clock before the application schedules the timer.

it('autosaves after the debounce interval', () => {
  cy.clock()

  const editor = {
    save(_value: string) {},
    schedule(value: string) {
      setTimeout(() => this.save(value), 500)
    },
  }

  const saveStub = cy.stub(editor, 'save')

  editor.schedule('draft text')
  cy.tick(499)
  cy.then(() => {
    expect(saveStub).not.to.have.been.called
  })

  cy.tick(1)
  cy.then(() => {
    expect(saveStub).to.have.been.calledOnceWith('draft text')
  })
})

This test controls two boundaries: the fake clock controls when time advances, and the stub records the delayed action while preventing a real save. There is no arbitrary cy.wait(500), so the intent and failure are deterministic.

Be careful when the application uses Date, intervals, animation frames, or third-party scheduling. Configure the clock according to the APIs the scenario needs, and do not freeze time so early that application boot depends on a moving clock. The Cypress clock and tick guide covers timer-specific patterns.

A fake timer does not make asynchronous network work complete. Use intercept aliases for network readiness and clock control for client timers.

11. Cypress cy.stub and cy.spy Review Checklist

Before merging a test double, ask whether the test controls the correct boundary and still proves a user or business outcome.

  • The spy is installed before the expected call and the original behavior is safe.
  • The stub replaces a configurable method or is passed as an explicit callback.
  • Stub behavior matches the real synchronous or Promise contract.
  • Browser methods needed during startup are replaced in onBeforeLoad.
  • Aliases are used when call assertions occur after queued UI actions.
  • The assertion is not stricter than the public contract requires.
  • A visible or state-based outcome complements internal interaction assertions where possible.
  • HTTP behavior uses cy.intercept() instead of a deep module stub.
  • Secrets and high-volume calls are not exposed through logging.
  • Per-call sequences have a real scenario reason, not fake complexity.
  • The test relies on Cypress sandbox restoration and does not manually leak replacements.

A good double makes a hard branch deterministic and the expected interaction obvious. A bad double knows more about the implementation than the user-facing behavior does. If a harmless refactor breaks many stub assertions without changing outcomes, move the test boundary outward or relax assertions to the real contract.

Interview Questions and Answers

Q: What is the difference between cy.spy() and cy.stub()?

A spy wraps a method, records its use, and lets the original execute. A stub replaces the method and controls behavior. I choose based on whether the real side effect is safe and whether the test needs a forced result.

Q: Are they normal Cypress commands?

No. They are synchronous utility functions that return Sinon doubles. They are not retryable, chainable command subjects, or timeout-aware, although Cypress provides alias and log support around them.

Q: When should a double be installed in onBeforeLoad?

When application startup may capture or invoke a window method, install the double before the page code loads. Stubbing after cy.visit() can be too late because the app may already hold the original reference.

Q: Why alias a stub or spy?

An alias lets later Cypress commands retrieve the double after queued browser actions. A .should() assertion can retry while the application completes the expected call, and the alias gives clearer Command Log output.

Q: When do you use cy.intercept() instead?

I use intercept for HTTP requests made by the application, including response stubbing, delay, errors, and request assertions. I use function doubles for JavaScript boundaries such as callbacks and browser APIs.

Q: How are doubles restored?

Cypress creates them inside its Sinon sandbox and automatically resets and restores them between tests. I still create each double in local setup so test ownership and call history remain obvious.

Q: How do you test different behavior on successive calls?

I use onFirstCall, onSecondCall, and related Sinon behavior methods, then assert the outcome that requires the sequence. For input-dependent behavior I use withArgs. I avoid building a complex fake state machine.

Q: What makes a stub assertion brittle?

Exact internal call counts and argument lists are brittle when the public contract permits batching, retries, or additional metadata. I assert exactness only at a stable boundary and pair it with an observable result.

Common Mistakes

  • Spying on a destructive method and accidentally allowing the real side effect.
  • Installing a browser method stub after application startup captured the original.
  • Treating cy.stub() as a retried Cypress query.
  • Stubbing HTTP client internals when cy.intercept() expresses the boundary better.
  • Returning a rejected Promise from a dependency that is actually synchronous.
  • Asserting exact call counts for implementation details with no user impact.
  • Replacing every collaborator and testing only mock wiring.
  • Trying to replace an immutable ES module import without a configurable adapter.
  • Logging sensitive arguments or thousands of high-frequency spy calls.
  • Adding real time waits instead of using cy.clock() for client timers.

Conclusion

Cypress cy.stub and cy.spy are precise tools for JavaScript boundaries. Spy when real behavior should continue, stub when the test needs control, install doubles before the relevant call, and use aliases when assertions follow queued application actions. For HTTP behavior, use intercept at the network boundary.

Start with one hard-to-reproduce branch, such as a declined confirmation, rejected callback, or debounced save. Add the smallest double that makes it deterministic, assert the meaningful interaction, and keep a user-visible outcome in the test. That balance provides fast diagnosis without tying the suite to every internal function call.

Interview Questions and Answers

Explain cy.stub versus cy.spy.

cy.spy wraps a method and records its interactions while preserving the original behavior. cy.stub replaces the method and gives the test control over its behavior. I use a spy for safe observation and a stub for deterministic control or side-effect prevention.

Are Cypress stubs and spies asynchronous commands?

No. They are synchronous utilities that return Sinon doubles immediately. They do not have normal Cypress query retry or timeout behavior. Aliasing lets later Cypress commands retrieve them for assertions after UI actions.

How do you stub a browser method before page load?

I pass an onBeforeLoad callback to cy.visit and call cy.stub on the provided window object. This ensures application initialization sees the replacement. I then assert both the call and the resulting UI behavior.

What is the difference between cy.stub and cy.intercept?

cy.stub replaces an accessible JavaScript function in the runtime. cy.intercept observes or controls HTTP traffic made by the application. I use intercept for service boundaries and stubs for callbacks, adapters, and browser APIs.

How do you model successive outcomes with a stub?

I configure onFirstCall, onSecondCall, and later calls with Sinon behavior methods. I use the sequence only when the production scenario genuinely retries or changes behavior, and I assert the resulting business or UI outcome.

How do aliases improve call assertions?

An alias makes the double available through cy.get after queued Cypress actions. A should assertion can retry until the application makes the call, and the Command Log has a meaningful name. For an immediate synchronous call, direct expect is simpler.

How do you avoid over-mocking in Cypress?

I double only unstable, destructive, unavailable, or hard-to-control boundaries. Pure logic and stable collaborators remain real. I pair interaction checks with visible or persisted outcomes so the test protects behavior rather than implementation wiring.

How are stubs and spies cleaned up?

Cypress manages them through a Sinon sandbox and restores them between tests. Manual restoration is usually unnecessary for cy.stub and cy.spy. I avoid direct unmanaged assignment because it can leak a replacement beyond the test.

Frequently Asked Questions

What is the difference between cy.stub and cy.spy?

cy.spy wraps an existing method and records calls while the original still runs. cy.stub replaces the method and lets the test control returns, fakes, errors, promises, and per-call behavior.

Are cy.stub and cy.spy automatically restored?

Yes. Cypress creates them in its Sinon sandbox and restores or resets them between tests. Define them locally so test ownership and call history remain clear.

How do I assert a Cypress stub was called?

Use a direct Sinon-Chai expect assertion after a synchronous call, or alias the stub and retrieve it with cy.get after queued UI actions. Assertions such as calledOnce and calledWith are available.

Can cy.stub replace window.confirm or window.prompt?

Yes. In end-to-end tests, install the stub in cy.visit onBeforeLoad when the application may call or capture the method during startup. Configure the desired return and assert the visible outcome.

Should I use cy.stub or cy.intercept for an API response?

Use cy.intercept for HTTP traffic made by the application. Use cy.stub for an in-process JavaScript function. Intercept preserves a clearer end-to-end boundary for request and response behavior.

Can a Cypress stub resolve or reject a Promise?

Yes. Cypress stubs are Sinon stubs and support resolves and rejects. Make sure the fake behavior matches the asynchronous contract of the real dependency.

Why is my spy not recording a call?

The spy may have been installed after the call or after application code captured the original function reference. Install it before the action, often in onBeforeLoad for browser startup methods.

Related Guides