A spy lets you observe how a function is called without altering what it actually does. This lesson explains cy.spy(), how it differs from cy.stub(), and when to reach for each.
cy.spy(): Observe Without Replacing
cy.spy() wraps a function so Cypress records every call, its arguments, return value, and call count, while still letting the original implementation run normally. This differs from cy.stub(), which replaces the implementation entirely.
const calculateTotal = (items) => items.reduce((sum, i) => sum + i.price, 0);
cy.spy(window, 'calculateTotal').as('calcSpy'); // observes real calls
// later
cy.get('@calcSpy').should('have.been.called');
The real calculateTotal still runs and returns its actual value, cy.spy() only observes the call.
cy.spy() requires an existing object and method name; unlike cy.stub(), it cannot create a bare standalone function.
The wrapped method's real implementation still executes exactly as before.
Aliasing with .as() enables later assertions with cy.get('@alias').
Spies are automatically restored to the original implementation after the test.
Spy vs Stub Cheat Sheet
Deciding between cy.spy() and cy.stub() for a given scenario.
Scenario
Use
Verify a callback was called, real behavior unaffected
cy.spy()
Replace a function's actual behavior entirely
cy.stub()
Verify a callback was called with specific arguments
Either, depending on if real behavior matters
Prevent a real side effect (e.g., real API call) from happening
cy.stub()
Confirm an internal method is invoked during a real flow
cy.spy()
A Practical Example: Spying on console.error
Spying on console.error is a useful pattern for confirming your application does not silently log unexpected errors during a flow you're testing, while still letting any real logging behavior happen normally.
The deciding question is simple: do you need the real implementation to actually run? If yes, and you only need to observe the call, use cy.spy(). If the real implementation is unwanted, expensive, or has side effects you need to prevent (like a real payment call), use cy.stub() instead.
Common Mistakes
Trying to use cy.spy() to create a brand-new standalone function the way cy.stub() can.
Using cy.stub() when the real implementation's actual behavior was still needed for the test to be meaningful.
Forgetting spies observe an existing object's method, and require that method to already exist.
Not aliasing a spy, making it impossible to assert on its calls afterward.
Key Takeaways
cy.spy() observes calls to an existing function while letting its real implementation run.
cy.stub() replaces a function's implementation entirely.
Choose based on whether the real behavior of the function under test still needs to execute.
Both spies and stubs support Sinon-Chai assertions like have.been.called and calledWith.
Pro Tip
Reach for cy.spy() first by default when you just need to confirm "was this called, and how", only escalate to cy.stub() once you've identified a concrete reason the real implementation shouldn't run during that specific test.
You now understand spies and how they differ from stubs. Next, apply everything from this module to real API Testing.