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
alertandconfirmwith 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 |
| Yes | Check a function was called, without changing its behavior |
Stub |
| No | Replace a function and control what it returns |
Clock |
| 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:
- It logs every login attempt to the console (perfect for spies).
- It gets a Reset button that asks "Are you sure?" with
window.confirm(perfect for stubs). - 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 devOpen 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 openChoose 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'swindowobject.cy.stub(win, 'alert')swaps the realalertwith 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 CancelAdd 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 |
|---|---|
| Called at least once |
| Never called |
| Called exactly once (also |
| Called exactly 5 times |
| Called with these arguments at least once |
| Called exactly once, with these arguments |
Common Beginner Mistakes
- Stubbing before
cy.visit()withcy.window(). Everycy.visit()loads a fresh window, so a stub added before it gets thrown away. Always add yourcy.window().then(...)stub after the visit. - Calling
cy.clock()after the page loads. Timers started during page load won't be under your control. Callcy.clock()beforecy.visit(). - Forgetting the
@in aliases. You create an alias with.as('alertStub'), but you read it withcy.get('@alertStub'). - Passing arguments as one string. For
console.log('Login attempted:', 'testuser'), writecalledWith('Login attempted:', 'testuser'), notcalledWith('Login attempted: testuser'). - Using
cy.wait(ms)for timers. It makes tests slow and still flaky. Usecy.clock()andcy.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!
