QA How-To
Cypress cy.stub and cy.spy: Examples
Use this cypress cy.stub and cy.spy example guide to control functions, verify calls, test browser methods, handle promises, and prevent timing mistakes.
19 min read | 3,060 words
TL;DR
cy.spy records calls while preserving the original method. cy.stub replaces the method and lets the test control returns, promises, errors, and side effects. Create the double before the call, alias it, trigger the behavior, and assert both the interaction and the user outcome.
Key Takeaways
- Use cy.spy when the original function must run and cy.stub when the test must replace its behavior.
- Install a browser method stub before application code captures or calls the original function.
- Alias important test doubles and use cy.get with should after queued browser actions.
- Match synchronous returns, promises, rejections, and callback behavior to the real interface.
- Prefer stable public boundaries over spying on private implementation helpers.
- Pair call assertions with a visible or state-based product outcome.
A practical cypress cy.stub and cy.spy example starts with one decision: should the real function run? Use cy.spy() when the original behavior must continue and you only need call evidence. Use cy.stub() when the test must replace that behavior, choose its return value, simulate an error, or prevent a side effect.
Both utilities wrap Sinon APIs that Cypress exposes in the browser test environment. They are synchronous utility functions, not ordinary Cypress commands, so understanding when to keep the returned test double and when to alias it is the key to reliable assertions. This guide builds that mental model with end-to-end and component examples.
TL;DR
| Need | Choose | Original function runs? | Typical assertion |
|---|---|---|---|
| Observe a call without changing behavior | cy.spy() |
Yes | should('have.been.calledWith', value) |
| Prevent navigation, analytics, or another side effect | cy.stub() |
No | should('have.been.calledOnce') |
| Return controlled data | cy.stub().returns(value) |
No | Assert UI plus stub arguments |
| Resolve or reject a promise | cy.stub().resolves(value) or .rejects(error) |
No | Assert success or error state |
| Change behavior for selected arguments | cy.stub().withArgs(...) |
No for matching behavior | Assert the relevant branch |
Create the spy or stub before the application can call the method. Give important test doubles an alias, trigger the user behavior, then use cy.get('@alias').should(...) so the assertion participates in Cypress retrying.
1. Cypress cy.stub and cy.spy Example Mental Model
cy.spy(object, method) replaces object[method] with a wrapper that records calls and forwards each call to the original method. The return value, thrown error, mutation, network request, and any other side effect still occur. That makes a spy suitable for observing an internal boundary only when running the boundary is safe and meaningful.
cy.stub(object, method) replaces the method with a Sinon stub. By default, it returns undefined and does not call the original method. You control behavior through methods such as .returns(), .callsFake(), .resolves(), .rejects(), and .withArgs(). A standalone cy.stub() is also useful as a callback prop in a component test.
Both functions return their Sinon test double immediately. They do not enter the Cypress command queue, are not timeout-able, and do not retry. Their .as() support registers an alias that Cypress can later retrieve. This distinction explains why the following two styles have different timing:
const save = cy.stub(api, 'save')
expect(save).not.to.have.been.called
cy.get('@save').should('have.been.calledOnce')
The first assertion executes immediately in JavaScript. The second is scheduled and retried until the command timeout. Immediate assertions are correct after a direct synchronous call. Alias assertions are usually safer after clicking, mounting, visiting, or another queued action.
2. Install and Type a Minimal Test Project
Install Cypress as a development dependency and let the opening workflow create the folders for your chosen testing type:
npm install --save-dev cypress typescript
npx cypress open
A small end-to-end configuration can stay version-agnostic and use the current defineConfig API:
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
return config
},
},
})
No extra Sinon or Sinon-Chai package is required for ordinary Cypress specs. Cypress supplies its wrapped Sinon functions and the corresponding Chai assertions. Keep the spec inside cypress/e2e for an end-to-end test or inside the configured component spec pattern for a component test.
The application method must be reachable from the browser context. A Node module loaded only by cypress.config.ts cannot be replaced by a browser-side stub. Conversely, a browser object's method is not available to a Node task. Draw the process boundary before choosing the technique. Use Cypress cy.task guidance when the behavior belongs in the Node process, and use cy.intercept() when the important boundary is an HTTP exchange rather than a JavaScript function.
3. Spy on Real Behavior Without Replacing It
Suppose the page exposes a small analytics object and the real development implementation records events in memory. The test wants evidence that saving emits the correct event while preserving that real behavior.
export {}
declare global {
interface Window {
analytics: {
track(name: string, properties: Record<string, unknown>): void
events: Array<{ name: string; properties: Record<string, unknown> }>
}
}
}
it('tracks a successful profile save', () => {
cy.visit('/profile')
cy.window().then((win) => {
cy.spy(win.analytics, 'track').as('track')
})
cy.get('[name=displayName]').clear().type('Avery QA')
cy.contains('button', 'Save profile').click()
cy.get('@track').should('have.been.calledOnceWith', 'profile_saved', {
source: 'profile_form',
})
cy.window().its('analytics.events').should('have.length', 1)
})
This test proves two different facts. The spy assertion checks the collaboration with analytics. The final application-state assertion proves that the original method still ran. If analytics sends a real third-party request, allowing it to run may be undesirable. Stub it instead, or intercept the request at the network boundary.
Avoid spying on every private helper. A test coupled to an implementation call graph can fail during harmless refactoring even though the user behavior still works. Spy at a stable boundary such as analytics, browser integration, or an injected service interface, and keep a user-visible assertion in the same scenario.
Also confirm that the real method is safe in the selected environment. A spy on production analytics, billing, or messaging can create an unintended external event even when the assertion is correct.
4. Stub a Browser Method Before Application Startup
Browser methods are often called during page initialization. Create the replacement in onBeforeLoad, which runs after Cypress obtains the new window but before the application's scripts execute. This pattern is especially useful for window.open, window.prompt, and application-owned globals.
it('opens the invoice in the approved destination', () => {
cy.visit('/orders/ord-101', {
onBeforeLoad(win) {
cy.stub(win, 'open').as('windowOpen')
},
})
cy.contains('button', 'View invoice').click()
cy.get('@windowOpen').should(
'have.been.calledOnceWithExactly',
'/invoices/inv-901',
'_blank',
)
})
The stub prevents a second browsing context from opening, while the assertion verifies the contract passed to the browser. This is more focused than trying to control another tab. It does not prove that the invoice route renders correctly, so cover that route in a separate test by visiting it directly.
Timing matters. If the application stores const openWindow = window.open during startup, stubbing window.open after cy.visit() is too late. If the method is called only after a button click, either timing may work, but onBeforeLoad makes the test robust against initialization changes.
Do not stub browser security behavior merely to make an impossible production flow pass. The replacement should isolate a boundary, not rewrite the product. For cross-origin user journeys, review Cypress cross-origin testing patterns before substituting a stub.
5. Control Returns, Arguments, and Call Order
A stub can model a deterministic collaborator. This component example uses a standalone stub as the save callback and gives different outcomes to consecutive calls.
import { ProfileEditor } from '../../src/ProfileEditor'
type SavedProfile = { id: string; displayName: string }
it('allows a retry after a temporary save failure', () => {
const saveProfile = cy.stub()
saveProfile.onFirstCall().rejects(new Error('temporary outage'))
saveProfile.onSecondCall().resolves({
id: 'user-42',
displayName: 'Avery QA',
} satisfies SavedProfile)
saveProfile.as('saveProfile')
cy.mount(<ProfileEditor onSave={saveProfile} />)
cy.get('[name=displayName]').type('Avery QA')
cy.contains('button', 'Save').click()
cy.contains('Could not save profile').should('be.visible')
cy.contains('button', 'Try again').click()
cy.contains('Profile saved').should('be.visible')
cy.get('@saveProfile').should('have.callCount', 2)
cy.get('@saveProfile').should('have.been.calledWithMatch', {
displayName: 'Avery QA',
})
})
resolves(value) returns a promise resolved with the value, and rejects(error) returns a rejected promise. Use .returns(value) for a synchronous API. Use .callsFake(fn) when behavior must be computed from inputs. Match the production interface exactly, including whether the method is synchronous.
Sequential behavior is powerful but can hide an unrealistic contract. Name the scenario and keep the sequence short. If retry behavior depends on HTTP statuses or headers, a network stub through cy.intercept() is a clearer representation than a function stub.
Resetting behavior within one test is rarely necessary because Cypress restores the sandbox afterward. If the scenario changes a stub midway, make the transition explicit and assert the call number that activates it. Hidden reconfiguration makes later failures difficult to reconstruct from the Command Log.
6. Use withArgs and callsFake for Focused Branches
withArgs() creates behavior for matching arguments while the parent stub continues to record all calls. It is useful when a collaborator serves several feature flags or configuration keys.
it('renders the compact dashboard for the enabled account', () => {
cy.visit('/dashboard', {
onBeforeLoad(win) {
const flags = {
isEnabled(_name: string, _accountId: string) {
return false
},
}
Object.assign(win, { featureFlags: flags })
const isEnabled = cy.stub(flags, 'isEnabled').returns(false)
isEnabled.withArgs('compact-dashboard', 'acct-7').returns(true)
isEnabled.as('isEnabled')
},
})
cy.get('[data-layout=compact]').should('be.visible')
cy.get('@isEnabled').should(
'have.been.calledWithExactly',
'compact-dashboard',
'acct-7',
)
})
When a fake needs to derive a result, prefer a small pure function:
const price = cy.stub(calculator, 'price').callsFake(
(quantity: number, unitPrice: number) => quantity * unitPrice,
)
Do not rebuild the full production algorithm inside the fake. That creates a second implementation and can make a broken product agree with a broken test. A fake should expose the branch needed by the scenario with the smallest credible behavior.
Argument matching also deserves restraint. calledWithMatch is good for a large object when only selected business fields matter. calledWithExactly is appropriate for a stable, narrow interface. Choose based on the contract, not on which assertion is easiest to make pass.
Default behavior should remain safe for unmatched arguments. A stub that returns success for every unknown key can conceal an incorrect feature name, tenant ID, or locale. Configure a conservative default, then opt in only the values the scenario owns.
7. Assert Calls Reliably With Sinon-Chai
Cypress includes Sinon-Chai assertions, so a stub or spy can be checked with readable call language. Common forms include called, calledOnce, calledTwice, callCount, calledWith, calledWithMatch, calledOnceWith, and calledOnceWithExactly.
cy.get('@notify').should('have.been.called')
cy.get('@notify').should('have.been.calledOnce')
cy.get('@notify').should('have.callCount', 2)
cy.get('@notify').should('have.been.calledWithMatch', {
severity: 'warning',
})
Use the assertion that describes the product rule. If duplicates would charge a customer twice, calledOnceWithExactly carries more information than calledWith. If additional metadata is allowed to evolve, calledWithMatch avoids coupling to unrelated fields.
For negative checks, be careful about timing. cy.get('@send').should('not.have.been.called') can pass immediately before a delayed call occurs. First synchronize on the state that closes the observation window, such as a completed response, a settled loading indicator, or controlled time with cy.clock() and cy.tick(). Then check that the forbidden call did not happen.
Aliases reset before each test. Keep setup inside beforeEach or the test that consumes it, not in a before hook whose alias is expected to survive the suite. Cypress automatically restores spies and stubs it creates between tests through its Sinon sandbox.
8. Choose Function Doubles or Network Intercepts
Function doubles and network intercepts operate at different boundaries. Selecting the narrower truthful boundary keeps a test maintainable.
| Question | cy.stub() or cy.spy() |
cy.intercept() |
|---|---|---|
| What is observed? | A JavaScript method call | A browser HTTP request and response |
| Can original behavior continue? | Yes with a spy, no by default with a stub | Yes as a spy, or return a static/dynamic response |
| Best for | Callbacks, browser APIs, injected services, analytics adapters | API requests, error responses, headers, latency, GraphQL |
| Process scope | Browser test context | Traffic visible to Cypress's network layer |
| Main coupling risk | Private implementation details | Endpoint and payload details |
If the user clicking Save must send POST /api/profiles, intercepting that request verifies a stable external contract. Stubbing an internal submitProfile() helper may only verify the current implementation. If a component accepts onSave, a callback stub is ideal because the prop is the component's public interface.
Use Cypress cy.intercept fundamentals for request matching and response control. Use a spy when you need the real browser or application method to run. Use a stub when the side effect is harmful, unavailable, nondeterministic, or deliberately outside the test's scope.
9. Debug Failures and Preserve Useful Evidence
Name test doubles with .as() so the Command Log identifies their purpose. Clicking a stub or spy entry in Cypress gives call information in the browser console. An alias such as @publishInvoice is more useful than @stub-3, especially in a long component scenario.
When a call assertion fails, check four facts in order:
- The method existed on the exact object that the application used.
- The wrapper was installed before the application captured or called the method.
- The user action reached the expected branch.
- The assertion described the actual argument shape and call count.
Module systems can produce reference mismatches. Stubbing an imported namespace object does not affect a function that another module copied into a local binding. Prefer dependency injection at a component or service boundary. In an end-to-end test, observe an HTTP or browser boundary rather than modifying deeply bundled modules.
For very noisy functions, .log(false) can suppress repeated call entries:
const renderSpy = cy.spy(view, 'render').log(false)
Suppress only understood noise. A frequently called function may reveal an actual rendering or polling defect. Preserve a targeted assertion on count or selected arguments even when command logging is disabled.
10. Build a Cypress cy.stub and cy.spy Example Suite
A maintainable suite uses each test double for one boundary and pairs interaction evidence with an outcome. Organize scenarios by user capability rather than by Sinon method. A profile suite might contain a real save happy path, a network-controlled server failure, a callback-stubbed component validation case, and a spy on a stable analytics adapter.
Use this review checklist:
- Is the doubled method part of a stable interface?
- Was the double installed before the first possible call?
- Does the fake return the same kind of value as production?
- Would a network intercept express the behavior more clearly?
- Is there a visible or state-based assertion beyond the call assertion?
- Does the test define when a negative observation window ends?
- Can parallel tests run without sharing a mutated global?
Do not export one global stub across specs. Cypress restores its sandbox between tests, and each browser test should own its state. For reusable setup, export a function that creates a fresh object or install the double in beforeEach.
This cypress cy.stub and cy.spy example strategy supports the broader principles in Cypress test automation best practices: control only unstable boundaries, preserve realistic behavior where it adds confidence, and make failures explain the violated contract.
11. Combine Test Doubles With Controlled Time
Spies and stubs become especially useful when behavior depends on timers. Install cy.clock() before the application schedules the timer, spy on the stable collaborator, advance time with cy.tick(), then assert the call. This avoids waiting for real seconds and creates a closed observation window for a negative assertion.
it('saves a draft once after editing stops', () => {
cy.clock()
cy.visit('/editor')
cy.window().then((win) => {
const appWindow = win as typeof win & {
drafts: { save(input: { content: string }): void }
}
cy.spy(appWindow.drafts, 'save').as('saveDraft')
})
cy.get('[contenteditable=true]').type('Release notes')
cy.tick(999)
cy.get('@saveDraft').should('not.have.been.called')
cy.tick(1)
cy.get('@saveDraft').should('have.been.calledOnceWithMatch', {
content: 'Release notes',
})
})
The illustrative 1,000 millisecond interval must match the application contract. If the real save method performs a network call, the spy permits that call. Replace it with a stub only when the scenario intentionally isolates persistence, or intercept the HTTP request when the transport contract matters.
Install the wrapper after visiting only when the collaborator is not called during startup. If initialization schedules or captures it, inject the dependency in a component test or install the browser replacement in onBeforeLoad. Controlled time does not repair late stubbing.
Restore real time only when the same test genuinely needs it, and keep that transition visible.
12. Review Test Double Scope and Maintenance
Test doubles age when teams copy them without checking the production interface. Review the doubled method whenever its input, return type, error behavior, or ownership changes. A stub that still returns an old response shape can keep a component test green while the real integration is broken. Typed factories help with compile-time drift, but runtime contract checks against the real boundary remain necessary.
Keep a small inventory of deliberate test seams. Callback props, service interfaces, analytics adapters, and browser integrations are understandable seams. Deep module mutation, global prototype replacement, and private helper interception are fragile because bundling or refactoring can change the reference without changing product behavior.
Ask what confidence would remain if the interaction assertion were removed. If the visible result already proves the requirement, the spy may add maintenance without signal. If the call carries a critical payload that the UI cannot expose, the interaction assertion has independent value. Document that reason in the test name or a short comment rather than explaining Sinon mechanics.
Run stubbed scenarios alongside at least one realistic integration path. Component tests can cover many callback outcomes cheaply, while a focused end-to-end test proves the wiring to the actual service or browser API. The layers should complement each other rather than repeat identical assertions.
Finally, treat failures as evidence about a boundary. A zero call count can mean late installation, a different object reference, blocked user action, or changed product logic. Inspect timing and object identity before weakening the assertion. A higher call count may reveal duplicate subscriptions, repeated rendering effects, or an actual business defect. Test doubles are most valuable when their failures lead directly to a narrow investigation.
Interview Questions and Answers
Q: What is the main difference between cy.spy() and cy.stub()?
A spy wraps a method, records its calls, and still invokes the original implementation. A stub replaces the method and does not call the original unless configured to do so. I choose based on whether the real side effect should remain part of the test.
Q: Are cy.stub() and cy.spy() Cypress commands?
They are synchronous utility functions backed by Sinon, not queued Cypress commands, queries, or assertions. They return the test double immediately and are not retryable or timeout-able. Their aliases can later be retrieved with cy.get() for retried assertions.
Q: Why use cy.get('@save').should(...) instead of an immediate expect(save)?
An immediate expectation runs at the current JavaScript moment. After queued browser actions, that can be before the application calls the method. Retrieving the alias and using should schedules the assertion and allows Cypress to retry it until the command timeout.
Q: How do you stub a method called during page load?
I install the stub in the onBeforeLoad option of cy.visit(). Cypress passes the new window before application scripts execute, so the replacement exists before startup code can capture or call the original method.
Q: When would you prefer cy.intercept() to cy.stub()?
I prefer cy.intercept() when the contract under test is an HTTP request or response. It is more stable than stubbing a private application helper and can model status, headers, and body. I use a function stub for callbacks, browser methods, or explicit injected service interfaces.
Q: How are stubs and spies cleaned up?
Cypress creates them in a Sinon sandbox and automatically restores and resets them between tests. I still keep their creation local to each test or beforeEach, because aliases also reset before every test.
Q: How do you test that a delayed forbidden call never occurs?
I first define and reach the end of the relevant observation window. That might mean waiting for a known response, asserting a loading state has settled, or advancing a controlled clock. Only then do I assert that the spy or stub was not called.
Common Mistakes
- Using a spy to observe a destructive or paid side effect, which allows the real action to run.
- Creating the stub after application startup has already captured the original method.
- Treating
cy.stub()like a queued, retryable Cypress command. - Returning a plain value from a stub when production returns a promise, or the reverse.
- Stubbing a private helper when
cy.intercept()or a public callback would express the contract better. - Checking
not.calledbefore a delayed action has had a chance to occur. - Using broad argument assertions that miss duplicate calls or incorrect extra arguments.
- Rebuilding production logic inside
callsFake(). - Expecting aliases created in a
beforehook to remain available in later tests. - Suppressing call logs before understanding why a function is unexpectedly noisy.
Conclusion
The reliable answer to a cypress cy.stub and cy.spy example is to decide whether original behavior belongs in the scenario. Spy when it does. Stub when the test needs control, isolation, or protection from a side effect. Install the wrapper before the call, preserve the returned type, alias important doubles, and synchronize before asserting.
Start with one stable boundary in an existing test. Add a call assertion that describes the business contract, keep a user-visible outcome beside it, and review whether a network intercept would be clearer. That small discipline turns test doubles from implementation trivia into useful diagnostic evidence.
Interview Questions and Answers
When would you choose cy.spy instead of cy.stub?
I choose cy.spy when I need call evidence and the original behavior remains safe and meaningful. The spy forwards calls and preserves the real return value and side effects. I also keep an outcome assertion so the test does not depend only on implementation interaction.
Why can an immediate expect on a stub fail after cy.click?
cy.stub returns synchronously, but cy.click is queued for later execution. A plain expect written immediately can run before the application receives the click. I alias the stub and use cy.get('@alias').should(...) after the action so Cypress schedules and retries the assertion.
How do you stub a function called during page startup?
I install the replacement inside cy.visit's onBeforeLoad callback. That callback receives the new window before application scripts execute. It prevents startup code from capturing or calling the original method first.
How do you model different results on consecutive calls?
I use onFirstCall and onSecondCall with returns, resolves, or rejects that match the real interface. I keep the sequence short and tied to a named retry scenario. If the outcomes represent HTTP behavior, I consider cy.intercept because it may describe the contract more directly.
How do you choose between calledWithMatch and calledWithExactly?
I use calledWithExactly for a small stable contract where extra arguments would be a defect. I use calledWithMatch for a larger evolving object when only selected business fields matter. The choice follows what the product promises, not which assertion is easier.
How are Cypress spies and stubs cleaned up?
Cypress places them in a Sinon sandbox and automatically restores and resets them between tests. I still create each double in the consuming test or beforeEach. That keeps ownership clear and respects the fact that aliases reset before every test.
How would you test a debounced call with a spy?
I install cy.clock before the timer is scheduled, wrap the stable collaborator with a spy, and trigger the edit. I advance to just before the debounce threshold and assert no call, then tick through the threshold and assert one matching call. This creates a deterministic observation window without real-time sleeping.
Frequently Asked Questions
What is the difference between cy.stub and cy.spy in Cypress?
cy.spy wraps a method, records its calls, and still executes the original implementation. cy.stub replaces the method and skips the original behavior unless configured otherwise. Use the decision of whether the real side effect should run to select between them.
Are cy.stub and cy.spy chainable Cypress commands?
No. They are synchronous Sinon-backed utility functions that return a test double immediately. They can be aliased, and cy.get on that alias can participate in Cypress retrying.
How do I stub window.open in Cypress?
Create the stub inside the onBeforeLoad option of cy.visit so it exists before application scripts execute. Alias the stub, trigger the link or button, and assert the expected URL and target. Cover the destination page separately because the stub prevents the new context from opening.
How do I make a Cypress stub return a promise?
Use resolves(value) for a fulfilled promise or rejects(error) for a rejected promise. Keep the return type consistent with the production method. Then assert the component or page state that handles the asynchronous result.
Does Cypress automatically restore spies and stubs?
Yes. Cypress creates cy.spy and cy.stub doubles in a Sinon sandbox and restores them between tests. Create aliases in each test or beforeEach because aliases also reset before every test.
Should I use cy.stub or cy.intercept for an API call?
Use cy.intercept when the stable contract is the browser HTTP request or response. Use cy.stub for a browser method, callback prop, or explicit injected service interface. Avoid stubbing a private helper when the network boundary expresses the behavior more clearly.
How do I assert that a Cypress spy was not called?
First reach the end of the relevant observation window through a response, settled UI state, or controlled clock. Then retrieve the alias and assert not.have.been.called. An immediate negative assertion can pass before a delayed call occurs.
Related Guides
- Cypress cy.wrap and cy.then: Examples
- How to Use Cypress cy.stub and cy.spy (2026)
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices