Bhavik Bamania

Programming

Cypress Stubs, Spies, and Clocks: Take Control of Your Tests

Bhavik Bamania

14 min read

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X
cypress-stubs-spies-clocks-compact
Cypress Stubs, Spies, and Clocks: Take Control of Your Tests

Learn how to take full control of your Cypress tests with stubs, spies, and clocks. Fake alerts and confirm boxes, track function calls, and fast-forward timers, with step-by-step examples you can follow along.

Introduction

In the previous articles, we learned how to visit pages, select elements, type into inputs, and make assertions. But real applications do much more than show a form. They pop up alerts, ask for confirmation, write logs, and run timers. In this article, we'll learn how to take full control of all of that using three powerful Cypress tools: stubs, spies, and clocks.

This article is part 3 of our Cypress Tutorial Series, designed to help beginners master Cypress step by step. If you're new here, start with A Beginner's Guide to Cypress: End-to-End Testing Made Easy and then Understanding Cypress Basics: Core Features and Syntax Explained. We'll keep building on the same LoginForm demo project from part 2, so keep it handy.

Why Stubs, Spies, and Clocks?

Remember the alert test from the last article? We used cy.on('window:alert', ...) to check the alert text. It works, but it has a hidden problem: if the alert never fires, the test still passes. The check inside cy.on() simply never runs, and Cypress has nothing to complain about.

That's a dangerous kind of test, because it gives you a green tick even when your feature is broken. Stubs, spies, and clocks fix this. With them, you can:

  • Prove that a function was actually called, how many times, and with which values.
  • Replace browser pop-ups like alert and confirm with fake versions you control.
  • Fast-forward time, so a test for a 30-second timer runs in milliseconds.

By the end of this article, you'll write tests that fail when they should fail, which is the whole point of testing.

Understanding Spies, Stubs, and Clocks

Before writing any code, let's understand these three ideas in plain language. Don't worry about the syntax yet. Just get the picture in your head.

What Is a Spy?

A spy watches a function without changing it. The function still does its normal job, but the spy quietly takes notes: was it called, how many times, and with which arguments?

Think of a CCTV camera in a shop. It doesn't stop anyone from shopping. It just records everything that happens, so you can check later.

What Is a Stub?

A stub replaces a function with a fake one that you control. The real function never runs. The stub also takes notes like a spy, and you can tell it what to return.

Think of a stunt double in a movie. The real actor steps aside, and the double performs the scene exactly the way the director wants.

What Is a Clock?

A clock lets you control time inside your app. Cypress freezes setTimeout, setInterval, and Date, and you move time forward yourself.

Think of a TV remote with a fast-forward button. Instead of waiting 30 seconds for something to happen, you press a button and jump there instantly.

Quick Comparison

Tool

Command

Does the real code run?

Use it when you want to

Spy

cy.spy()

Yes

Check a function was called, without changing its behavior

Stub

cy.stub()

No

Replace a function and control what it returns

Clock

cy.clock() + cy.tick()

Time is frozen until you move it

Test timers and delays without waiting

Note: Cypress uses the popular Sinon.js library behind the scenes for spies, stubs, and clocks. You don't need to install anything. It's bundled with Cypress, just like Chai.

Updating the Demo Project

We'll continue with the cypress-demo project from part 2. If you skipped it, go back and complete the "Setting Up the Demo Project" section first. It takes about 10 minutes.

Our current LoginForm only shows an alert. To practice stubs, spies, and clocks, we'll give it three new behaviors:

  1. It logs every login attempt to the console (perfect for spies).
  2. It gets a Reset button that asks "Are you sure?" with window.confirm (perfect for stubs).
  3. It shows a success message that disappears after 3 seconds (perfect for clocks).

Step 1: Update the LoginForm Component

Replace the contents of src/components/LoginForm.tsx with the code below:

import React, { useEffect, useState } from 'react';

const LoginForm: React.FC = () => {
  const [username, setUsername] = useState<string>('');
  const [password, setPassword] = useState<string>('');
  const [message, setMessage] = useState<string>('');

  // Hide the success message 3 seconds after it appears
  useEffect(() => {
    if (!message) return;
    const timer = setTimeout(() => setMessage(''), 3000);
    return () => clearTimeout(timer);
  }, [message]);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log('Login attempted:', username); // New: log the attempt
    alert(`Welcome, ${username}!`);
    setMessage(`Logged in as ${username}`); // New: show a message
  };

  // New: ask for confirmation before clearing the form
  const handleReset = () => {
    if (window.confirm('Are you sure you want to clear the form?')) {
      setUsername('');
      setPassword('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        id="username"
        type="text"
        placeholder="Username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <input
        id="password"
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
      <button type="button" id="reset" onClick={handleReset}>
        Reset
      </button>
      {message && <p id="message">{message}</p>}
    </form>
  );
};

export default LoginForm;

Let's quickly understand what's new:

  • console.log runs every time the form is submitted.
  • handleReset calls window.confirm(). The form is cleared only if the user clicks OK.
  • useEffect starts a 3-second timer whenever a message appears, then clears the message.

Note: The Reset button has type="button". Without it, any button inside a form acts as a submit button, and clicking Reset would also log the user in.

Step 2: Check It in the Browser

Make sure your development server is running:

npm run dev

Open http://localhost:5173, type a username, and click Login. You should see the alert, then the message "Logged in as ..." for 3 seconds. Now type something and click Reset. The browser asks for confirmation before clearing the fields.

Step 3: Create a New Test File

Create a new file at cypress/e2e/stubs-spies-clocks.cy.ts. We'll add tests to it one by one in the next sections. Open the Cypress runner in a separate terminal and keep it open:

npx cypress open

Choose E2E Testing, pick a browser, and click on stubs-spies-clocks.cy.ts. Cypress re-runs the file every time you save it, so you'll see your results instantly.

Working with Stubs

Let's start with stubs, because they solve the alert problem from the last article.

Step 1: Stub the Alert

The basic syntax of a stub looks like this:

cy.stub(object, 'methodName');

This tells Cypress: "Replace object.methodName with a fake function and record every call to it." The alert function lives on the browser's window object, so we need to get hold of the window first. Cypress gives us cy.window() for that.

Add this to cypress/e2e/stubs-spies-clocks.cy.ts:

describe('Stubs', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('should show a welcome alert on submit', () => {
    // 1. Replace window.alert with a stub and give it a name
    cy.window().then((win) => {
      cy.stub(win, 'alert').as('alertStub');
    });

    // 2. Fill in the form and submit it
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    // 3. Assert the stub was called once, with the right text
    cy.get('@alertStub').should('have.been.calledOnceWith', 'Welcome, testuser!');
  });
});

Save the file and watch the Cypress runner. The test passes, and notice something else: no real alert pops up. Our stub took its place.

Let's break it down:

  • cy.window() gives us the app's window object.
  • cy.stub(win, 'alert') swaps the real alert with a fake one.
  • .as('alertStub') gives the stub a nickname, called an alias. We can refer to it later with @alertStub.
  • cy.get('@alertStub').should(...) checks what happened to the stub.

Step 2: Prove the Test Can Fail

A good test must fail when the feature breaks. Let's prove it. Open LoginForm.tsx and comment out the alert line:

// alert(`Welcome, ${username}!`);

Save and look at the runner. The test now fails with an error saying the stub was never called. That's exactly what the old cy.on() approach couldn't do. Now uncomment the line again before moving on.

Tip: Make this a habit. Whenever you write a new test, break the feature on purpose once and confirm the test turns red. It takes 10 seconds and saves you from tests that pass no matter what.

Step 3: Control What a Stub Returns

Stubs can do more than record calls. They can also decide what a function returns. This is very useful for window.confirm, which returns true when the user clicks OK and false when they click Cancel.

Use .returns(value) to pick the answer:

cy.stub(win, 'confirm').returns(true); // Pretend the user clicked OK
cy.stub(win, 'confirm').returns(false); // Pretend the user clicked Cancel

Add these two tests inside the same describe('Stubs', ...) block:

  it('should clear the form when the user confirms', () => {
    cy.window().then((win) => {
      cy.stub(win, 'confirm').returns(true).as('confirmStub');
    });

    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('#reset').click();

    // The confirm box was shown with the right question
    cy.get('@confirmStub').should(
      'have.been.calledOnceWith',
      'Are you sure you want to clear the form?'
    );

    // The user said OK, so both fields are empty
    cy.get('#username').should('have.value', '');
    cy.get('#password').should('have.value', '');
  });

  it('should keep the form when the user cancels', () => {
    cy.window().then((win) => {
      cy.stub(win, 'confirm').returns(false).as('confirmStub');
    });

    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('#reset').click();

    cy.get('@confirmStub').should('have.been.calledOnce');

    // The user said Cancel, so the values are still there
    cy.get('#username').should('have.value', 'testuser');
    cy.get('#password').should('have.value', 'password123');
  });

With one line, we tested both paths of the Reset button. Without a stub, we'd have no way to click Cancel on a browser pop-up.

Note: By default, Cypress automatically clicks OK on every confirm. So without a stub, you can only ever test the "OK" path.

Do I Need to Remove Stubs After a Test?

No. Cypress automatically restores every stub and spy after each test. Each test starts with the real alert and confirm again, just like the test isolation we learned in part 2.

Working with Spies

Now let's look at spies. Remember, a spy only watches. The real function still runs. This is perfect when you want to check that something happened without changing how it happens.

Step 1: Spy on console.log

Our LoginForm logs Login attempted: <username> on every submit. Let's confirm this with a spy. The syntax is almost the same as a stub:

cy.spy(object, 'methodName');

Add a new describe block below the Stubs block in the same file:

describe('Spies', () => {
  beforeEach(() => {
    cy.visit('/');

    // Watch console.log for every test in this block
    cy.window().then((win) => {
      cy.spy(win.console, 'log').as('consoleLog');
    });
  });

  it('should log the login attempt', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    cy.get('@consoleLog').should(
      'have.been.calledWith',
      'Login attempted:',
      'testuser'
    );
  });
});

Notice that calledWith takes each argument separately. Our code calls console.log('Login attempted:', username), which has two arguments, so we pass two values to the assertion.

Now open the browser's DevTools console inside the Cypress runner (right-click the app, then Inspect). You'll see the log message printed as normal. That's the key difference: a spy lets the real function run, a stub doesn't.

Step 2: Count the Calls

Spies remember every single call. Let's submit the form twice and check the count. Add this test inside the Spies block:

  it('should log every login attempt', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');

    cy.get('button[type="submit"]').click();
    cy.get('button[type="submit"]').click();

    cy.get('@consoleLog').should('have.been.calledTwice');
  });

You can also use calledOnce, calledThrice, or check any exact number with have.callCount:

cy.get('@consoleLog').should('have.callCount', 2);

Spy or Stub: Which One Should I Use?

Here's a simple rule to remember:

  • Use a spy when the real behavior is fine and you only want to confirm it happened, like logging or analytics calls.
  • Use a stub when the real behavior gets in your way or you need to control the result, like pop-ups, random values, or a payment function.

Tip: If you're not sure, start with a spy. Switch to a stub only when the real function causes problems in your test.

Controlling Time with Clocks

Our success message disappears after 3 seconds. How do we test that? The first idea most beginners have is this:

cy.wait(3000); // Wait 3 seconds
cy.get('#message').should('not.exist');

It works, but it's a bad habit. Every test with a real wait makes your test suite slower. Imagine a timer of 5 minutes for a session timeout. You don't want to wait 5 real minutes. This is where cy.clock() and cy.tick() come in.

Step 1: Freeze Time with cy.clock()

cy.clock() freezes the app's timers. After you call it, setTimeout and setInterval won't move forward on their own. Call it before cy.visit(), so the app loads with the frozen clock from the very start:

cy.clock();
cy.visit('/');

Step 2: Move Time Forward with cy.tick()

cy.tick(milliseconds) moves the frozen clock forward by the time you give it. Any timer that should have finished in that time runs immediately.

Add a third describe block to the file:

describe('Clocks', () => {
  beforeEach(() => {
    cy.clock(); // Freeze time BEFORE the page loads
    cy.visit('/');
  });

  it('should hide the success message after 3 seconds', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    // The message appears right away
    cy.get('#message').should('have.text', 'Logged in as testuser');

    // Jump forward 2.999 seconds: the message is still there
    cy.tick(2999);
    cy.get('#message').should('be.visible');

    // Jump forward 1 more millisecond: the message is gone
    cy.tick(1);
    cy.get('#message').should('not.exist');
  });
});

Save and watch the runner. The test finishes almost instantly, even though it tests a 3-second timer.

Notice how we checked just before and exactly at 3 seconds. This proves the timer is exactly 3 seconds, not 2 or 10. A real cy.wait() could never test something this precise.

Step 3: Set a Fixed Date (Bonus)

cy.clock() also freezes Date. You can pass a specific date to it, which is great for testing things like "Good morning" greetings or expiry dates:

cy.clock(new Date(2026, 0, 1, 9, 0, 0)); // 1 January 2026, 9:00 AM
cy.visit('/');

Now new Date() inside your app always returns 1 January 2026, 9:00 AM. Remember that JavaScript months start from 0, so 0 means January.

Note: Cypress's own commands, like the automatic retries in .should(), are not affected by cy.clock(). Only your app's timers are frozen. That's why the assertions above still work normally.

Practical Example: The Complete Test File

Combining everything, here's the full cypress/e2e/stubs-spies-clocks.cy.ts:

describe('Stubs', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('should show a welcome alert on submit', () => {
    cy.window().then((win) => {
      cy.stub(win, 'alert').as('alertStub');
    });

    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    cy.get('@alertStub').should('have.been.calledOnceWith', 'Welcome, testuser!');
  });

  it('should clear the form when the user confirms', () => {
    cy.window().then((win) => {
      cy.stub(win, 'confirm').returns(true).as('confirmStub');
    });

    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('#reset').click();

    cy.get('@confirmStub').should(
      'have.been.calledOnceWith',
      'Are you sure you want to clear the form?'
    );
    cy.get('#username').should('have.value', '');
    cy.get('#password').should('have.value', '');
  });

  it('should keep the form when the user cancels', () => {
    cy.window().then((win) => {
      cy.stub(win, 'confirm').returns(false).as('confirmStub');
    });

    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('#reset').click();

    cy.get('@confirmStub').should('have.been.calledOnce');
    cy.get('#username').should('have.value', 'testuser');
    cy.get('#password').should('have.value', 'password123');
  });
});

describe('Spies', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.window().then((win) => {
      cy.spy(win.console, 'log').as('consoleLog');
    });
  });

  it('should log the login attempt', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    cy.get('@consoleLog').should('have.been.calledWith', 'Login attempted:', 'testuser');
  });

  it('should log every login attempt', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();
    cy.get('button[type="submit"]').click();

    cy.get('@consoleLog').should('have.been.calledTwice');
  });
});

describe('Clocks', () => {
  beforeEach(() => {
    cy.clock();
    cy.visit('/');
  });

  it('should hide the success message after 3 seconds', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();

    cy.get('#message').should('have.text', 'Logged in as testuser');

    cy.tick(2999);
    cy.get('#message').should('be.visible');

    cy.tick(1);
    cy.get('#message').should('not.exist');
  });
});

Run it, and you should see all six tests pass in the Cypress runner.

Assertion Cheat Sheet

Keep this list handy. These assertions work on both spies and stubs:

Assertion

What it checks

should('have.been.called')

Called at least once

should('not.have.been.called')

Never called

should('have.been.calledOnce')

Called exactly once (also calledTwice, calledThrice)

should('have.callCount', 5)

Called exactly 5 times

should('have.been.calledWith', a, b)

Called with these arguments at least once

should('have.been.calledOnceWith', a)

Called exactly once, with these arguments

Common Beginner Mistakes

  1. Stubbing before cy.visit() with cy.window(). Every cy.visit() loads a fresh window, so a stub added before it gets thrown away. Always add your cy.window().then(...) stub after the visit.
  2. Calling cy.clock() after the page loads. Timers started during page load won't be under your control. Call cy.clock() before cy.visit().
  3. Forgetting the @ in aliases. You create an alias with .as('alertStub'), but you read it with cy.get('@alertStub').
  4. Passing arguments as one string. For console.log('Login attempted:', 'testuser'), write calledWith('Login attempted:', 'testuser'), not calledWith('Login attempted: testuser').
  5. Using cy.wait(ms) for timers. It makes tests slow and still flaky. Use cy.clock() and cy.tick() instead.

Tip: What if your app calls alert the moment the page loads? A stub added after cy.visit() would be too late. In that case, stub it while the page is loading with onBeforeLoad:

cy.visit('/', {
  onBeforeLoad(win) {
    cy.stub(win, 'alert').as('alertStub');
  },
});

Conclusion

In this article, we learned how to take control of our app's behavior in Cypress. We used stubs to replace alert and confirm and decide their answers, spies to watch console.log without changing it, and clocks to fast-forward a 3-second timer in milliseconds. Most importantly, we wrote tests that actually fail when the feature breaks.

So far, everything we've tested happens inside the browser. But real apps talk to servers all the time. In the next article, we'll learn how to intercept and mock network requests with cy.intercept(), so you can test loading states, API errors, and slow responses without touching a real backend. Stay tuned, and happy testing!

CypressTypeScriptTSXEnd-to-End TestingCypress TutorialStubs and Spies

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X

Written by Bhavik Bamania

Bhavik Bamania is a software engineer and writer exploring frontend engineering, web architecture, technology, history, psychology, astrology, and culture.

This site uses cookies for basic analytics (page views, no ads). You can accept or decline.