Introduction
Almost every modern web app talks to a server. You click Login, the app sends your username and password to an API, and the server replies with "welcome" or "wrong password". In this article, we'll learn how to test all of these conversations using one of Cypress's most powerful commands: cy.intercept().
This article is part 4 of our Cypress Tutorial Series, designed to help beginners master Cypress step by step. If you're new here, we recommend reading the previous articles in order:
- A Beginner's Guide to Cypress: End-to-End Testing Made Easy
- Understanding Cypress Basics: Core Features and Syntax Explained
- Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
We'll keep building on the same LoginForm demo project from parts 2 and 3.
Why Mock Network Requests?
Imagine testing a login page against a real server. You'd run into problems very quickly:
- The backend may not exist yet. Frontend and backend teams often work in parallel.
- Real servers are slow and unpredictable. A test that passes today can fail tomorrow because the server was busy.
- Errors are hard to create on demand. How do you make a real server return "500 Internal Server Error" exactly when you want it?
- Real data changes. A test that expects a user called "testuser" breaks the moment someone deletes that user.
cy.intercept() solves all of this. It sits between your app and the network, and lets you watch requests, check what was sent, and send back any response you like. Your app never knows the difference.
Understanding cy.intercept()
Think of cy.intercept() as a security guard standing at the door of your app. Every request that leaves the app has to pass the guard. The guard can do two things:
- Just watch: note down the request and let it go to the real server. This is like the spy from part 3.
- Answer it directly: stop the request at the door and hand back a fake response. The real server never hears about it. This is like the stub from part 3.
The Basic Syntax
cy.intercept(method, url, response);- method: the HTTP method, such as
'GET','POST','PUT', or'DELETE'. - url: the address to watch, such as
'/api/login'. - response (optional): what to send back. If you leave it out, the request goes to the real server and Cypress only watches it.
Here are both modes side by side:
// Mode 1: Just watch the request (like a spy)
cy.intercept('GET', '/api/users').as('getUsers');
// Mode 2: Send back a fake response (like a stub)
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [{ id: 1, name: 'Bhavik' }],
}).as('getUsers');The .as('getUsers') part should look familiar. Just like with stubs, we give the intercept an alias, so we can refer to it later as @getUsers.
Note: In this article, we'll mostly use Mode 2, because our demo app has no real backend. That's not a hack. Testing a frontend before its backend exists is one of the biggest reasons teams use cy.intercept().
Updating the Demo Project
Right now, our LoginForm doesn't talk to any server. It just shows an alert with whatever username you type. Let's make it behave like a real login form. It will send the username and password to /api/login and react to the server's reply.
Step 1: Update the LoginForm Component
Replace the contents of src/components/LoginForm.tsx with the code below. Everything from part 3 is still here: the console log, the Reset button, and the 3-second message.
import React, { useEffect, useState } from 'react';
type LoginResponse = {
token: string;
user: { username: string };
};
const LoginForm: React.FC = () => {
const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [message, setMessage] = useState<string>('');
const [error, setError] = useState<string>(''); // New
const [isLoading, setIsLoading] = useState<boolean>(false); // New
// Hide the success message 3 seconds after it appears
useEffect(() => {
if (!message) return;
const timer = setTimeout(() => setMessage(''), 3000);
return () => clearTimeout(timer);
}, [message]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('Login attempted:', username);
setError('');
setIsLoading(true);
try {
// New: send the login details to the server
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
// The server said no (for example 401 or 500)
if (!response.ok) {
setError(data.message ?? 'Login failed. Please try again.');
return;
}
// The server said yes
const { user } = data as LoginResponse;
alert(`Welcome, ${user.username}!`);
setMessage(`Logged in as ${user.username}`);
} catch {
// The request never reached the server (no internet, server down)
setError('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
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">{isLoading ? 'Logging in...' : 'Login'}</button>
<button type="button" id="reset" onClick={handleReset}>
Reset
</button>
{message && <p id="message">{message}</p>}
{error && <p id="error">{error}</p>}
</form>
);
};
export default LoginForm;Let's understand the new parts:
- fetch('/api/login', ...) sends a POST request with the username and password as JSON.
- response.ok is
truefor successful status codes (200 to 299). For anything else, we show the server's error message. - catch runs when the request fails completely, for example when there's no internet.
- isLoading changes the button text to "Logging in..." while we wait for the server.
- finally always runs at the end, so the button text goes back to "Login" whatever happens.
Step 2: Try It in the Browser
Open http://localhost:5173, type a username, and click Login. You'll see an error message. That's expected! There's no /api/login on our Vite dev server, because we don't have a backend. Instead of building one, we'll let Cypress play the role of the server.
Step 3: Create a Fixture File
A fixture is a file with fake data that you reuse across tests. Instead of typing the same response in every test, you write it once and load it by name.
When you first ran npx cypress open, Cypress created a cypress/fixtures folder for you. Create a new file in it called cypress/fixtures/login-success.json:
{
"token": "fake-jwt-token-123",
"user": {
"username": "testuser"
}
}This is exactly what our imaginary server sends back after a successful login.
Step 4: Keep the Part 3 Tests Passing
Our part 3 tests in stubs-spies-clocks.cy.ts click Login too, so they now need a fake server as well. Open that file and add one line at the top of every beforeEach, in all three describe blocks:
beforeEach(() => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }); // New
cy.visit('/');
// ...the rest stays the same
});In the Clocks block, place it before cy.clock(). Run the file again, and all six tests should be green. Don't worry about what this line means yet. We'll break it down in the next section.
Tip: This is a very common situation in real projects. When an app starts calling an API, older tests suddenly need a mock. Fixing them early keeps your whole test suite trustworthy.
Mocking a Successful Login
Create a new test file at cypress/e2e/network-requests.cy.ts and open it in the Cypress runner. We'll mock a successful login in two ways: first with an inline response, then with our fixture.
Step 1: Mock with an Inline Response
The simplest way is to write the fake response directly in the test:
describe('Network Requests', () => {
it('should log in with an inline mocked response', () => {
// 1. Tell Cypress how to answer POST /api/login
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: {
token: 'fake-jwt-token-123',
user: { username: 'testuser' },
},
}).as('login');
// 2. Use the app like a real user
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
// 3. The app shows the success message from our fake server
cy.get('#message').should('have.text', 'Logged in as testuser');
});
});Save the file. The test passes, even though there is no backend at all! Cypress caught the request and answered it.
Now look at the Command Log on the left side of the runner. You'll see a row for the request marked with your alias login. Click it, and Cypress prints the full request and response in the browser's DevTools console. This is a lifesaver when debugging.
Note: Always call cy.intercept() before the action that triggers the request. In our case, that's before the click. Cypress can only catch requests it's already waiting for.
Step 2: Mock with a Fixture
Inline responses are fine for one test, but copying the same object into ten tests gets messy. This is where our fixture comes in. Replace the whole body with one line:
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');Cypress looks for login-success.json inside cypress/fixtures and sends its contents as the response body. The status code is 200 by default.
Add this test inside the same describe block:
it('should log in with a fixture', () => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
cy.visit('/');
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');
});Tip: Name your fixtures after the situation they represent, like login-success.json, login-invalid.json, or users-empty.json. Six months later, you'll instantly know what each file is for.
Waiting on Requests and Checking What Was Sent
Our tests so far check what the user sees. But what about what the app sends? A login form that shows "Logged in" but sends an empty password to the server is still broken. Let's fix that blind spot.
Step 1: Wait for the Request with cy.wait('@alias')
In part 3, we said cy.wait(3000) is a bad habit. But cy.wait('@login') is completely different. Instead of waiting a fixed time, it waits until the request with that alias actually happens, and then moves on immediately.
cy.get('button[type="submit"]').click();
cy.wait('@login'); // Waits for POST /api/login, no more, no lessIf the request never happens, Cypress fails the test after 5 seconds with a clear error. That's exactly the kind of test we want: one that fails when the feature breaks.
Step 2: Check the Request Body
cy.wait('@login') gives us back an interception object. It holds everything about the request and the response. Let's use it to check that the app sent the right data.
Add this test to the describe block:
it('should send the username and password to the server', () => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
// Wait for the request and check what the app sent
cy.wait('@login').its('request.body').should('deep.equal', {
username: 'testuser',
password: 'password123',
});
});Let's break it down:
cy.wait('@login')waits for the request and yields the interception..its('request.body')picks the body of the request from the interception..should('deep.equal', {...})checks that the body matches our object exactly.
Note: We use deep.equal instead of equal because we're comparing objects. equal checks if two objects are the very same object in memory, which they never are. deep.equal compares their contents.
Step 3: Check More Than One Thing
When you want to check several details, use .then() to get the whole interception:
it('should send a proper JSON login request', () => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login').then((interception) => {
expect(interception.request.method).to.equal('POST');
expect(interception.request.headers['content-type']).to.include('application/json');
expect(interception.request.body.username).to.equal('testuser');
expect(interception.response?.statusCode).to.equal(200);
});
});Tip: Header names in interception.request.headers are always lowercase, so write 'content-type', not 'Content-Type'. The ?. after response is TypeScript's way of saying "the response might not exist", because the request could fail before getting one.
Simulating Errors
Happy paths are easy. The real value of cy.intercept() is testing what happens when things go wrong. With a real server, these cases are almost impossible to create on demand. With Cypress, each one takes a single line.
Step 1: Wrong Password (401 Unauthorized)
A 401 status code means "you're not allowed in". Servers usually send it with a message explaining why. Let's pretend the user typed the wrong password:
it('should show an error for invalid credentials', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { message: 'Invalid username or password' },
}).as('login');
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('wrongpassword');
cy.get('button[type="submit"]').click();
cy.wait('@login');
cy.get('#error').should('have.text', 'Invalid username or password');
cy.get('#message').should('not.exist'); // No success message
});We check two things here: the error appears, and the success message doesn't. Checking what should not happen is just as important as checking what should.
Step 2: Server Crash (500 Internal Server Error)
A 500 status code means something broke on the server. Let's make sure our app handles it gracefully:
it('should show an error when the server fails', () => {
cy.intercept('POST', '/api/login', {
statusCode: 500,
body: { message: 'Something went wrong on our side' },
}).as('login');
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login');
cy.get('#error').should('have.text', 'Something went wrong on our side');
});Step 3: No Internet (Network Error)
Sometimes the request never reaches the server at all. Maybe the user's Wi-Fi dropped, or the server is completely down. Cypress can simulate this with forceNetworkError:
it('should show an error when the network fails', () => {
cy.intercept('POST', '/api/login', { forceNetworkError: true }).as('login');
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login');
cy.get('#error').should('have.text', 'Network error. Please try again.');
});This time, there's no status code and no response body. The fetch call throws an error, so our catch block runs and shows the network error message.
What's the Difference Between These Errors?
Situation | What Cypress sends | Which part of our code runs |
|---|---|---|
Wrong password |
|
|
Server crash |
|
|
No internet |
|
|
A good login form must handle all three. Now you can prove yours does, in less than a second each.
Simulating Slow Responses
On your laptop, a mocked response comes back in a few milliseconds. On a real phone with a weak signal, a login can take several seconds. During that time, the user needs to see that something is happening. That's why our button changes to "Logging in...". Let's test it.
Step 1: Add a Delay
Add delay to any mocked response, in milliseconds:
cy.intercept('POST', '/api/login', {
fixture: 'login-success.json',
delay: 1000, // The fake server takes 1 second to answer
}).as('login');Step 2: Test the Loading State
Add this test to the describe block:
it('should show a loading state while the request is in progress', () => {
cy.intercept('POST', '/api/login', {
fixture: 'login-success.json',
delay: 1000,
}).as('login');
cy.visit('/');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
// While waiting: the button shows the loading text
cy.get('button[type="submit"]').should('have.text', 'Logging in...');
// After the response: the button goes back to normal
cy.wait('@login');
cy.get('button[type="submit"]').should('have.text', 'Login');
cy.get('#message').should('have.text', 'Logged in as testuser');
});Watch the runner closely while this test runs. You'll actually see the button say "Logging in..." for one second before the success message appears.
Without the delay, this test would be unreliable. The response would arrive so fast that the loading text might disappear before Cypress gets a chance to check it.
Tip: Keep delays small, around 500 to 1000 milliseconds. You only need enough time for Cypress to see the loading state. Long delays just make your test suite slower.
Note: Wondering why we didn't use cy.clock() from part 3 here? cy.clock() controls timers inside your app, like setTimeout. The delay happens inside Cypress's fake server, outside your app, so the clock doesn't affect it.
Practical Example: The Complete Test File
Combining everything, here's the full cypress/e2e/network-requests.cy.ts. To keep it short, we moved cy.visit('/') into a beforeEach. This is safe because each cy.intercept() is still registered before the click that sends the request.
describe('Network Requests', () => {
beforeEach(() => {
cy.visit('/');
});
it('should log in with an inline mocked response', () => {
cy.intercept('POST', '/api/login', {
statusCode: 200,
body: { token: 'fake-jwt-token-123', user: { username: 'testuser' } },
}).as('login');
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');
});
it('should log in with a fixture', () => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
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');
});
it('should send the username and password to the server', () => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login').its('request.body').should('deep.equal', {
username: 'testuser',
password: 'password123',
});
});
it('should send a proper JSON login request', () => {
cy.intercept('POST', '/api/login', { fixture: 'login-success.json' }).as('login');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login').then((interception) => {
expect(interception.request.method).to.equal('POST');
expect(interception.request.headers['content-type']).to.include('application/json');
expect(interception.request.body.username).to.equal('testuser');
expect(interception.response?.statusCode).to.equal(200);
});
});
it('should show an error for invalid credentials', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { message: 'Invalid username or password' },
}).as('login');
cy.get('#username').type('testuser');
cy.get('#password').type('wrongpassword');
cy.get('button[type="submit"]').click();
cy.wait('@login');
cy.get('#error').should('have.text', 'Invalid username or password');
cy.get('#message').should('not.exist');
});
it('should show an error when the server fails', () => {
cy.intercept('POST', '/api/login', {
statusCode: 500,
body: { message: 'Something went wrong on our side' },
}).as('login');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login');
cy.get('#error').should('have.text', 'Something went wrong on our side');
});
it('should show an error when the network fails', () => {
cy.intercept('POST', '/api/login', { forceNetworkError: true }).as('login');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.wait('@login');
cy.get('#error').should('have.text', 'Network error. Please try again.');
});
it('should show a loading state while the request is in progress', () => {
cy.intercept('POST', '/api/login', {
fixture: 'login-success.json',
delay: 1000,
}).as('login');
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
cy.get('button[type="submit"]').should('have.text', 'Logging in...');
cy.wait('@login');
cy.get('button[type="submit"]').should('have.text', 'Login');
cy.get('#message').should('have.text', 'Logged in as testuser');
});
});Run it, and you should see all eight tests pass. Run the part 3 file too, to confirm those six tests are still green.
cy.intercept() Cheat Sheet
What you want | Code |
|---|---|
Watch a request only |
|
Mock with inline data |
|
Mock with a fixture |
|
Return an error |
|
Simulate no internet |
|
Simulate a slow server |
|
Wait for a request |
|
Check the request body |
|
Common Beginner Mistakes
- Calling
cy.intercept()after the click. The request has already left by then, so Cypress misses it. Always set up the intercept first. - Using the wrong HTTP method.
cy.intercept('GET', '/api/login', ...)will never catch a POST request. Check the method in your app's code or in the browser's Network tab. - Forgetting the
@incy.wait().cy.wait('login')doesn't work. It must becy.wait('@login'). - Using
equalinstead ofdeep.equalfor objects. Objects must be compared withdeep.equal. - Mocking everything forever. Mocked tests prove your frontend works with the responses you expect. Keep a few tests that hit a real test server too, so you know the real API still matches your mocks.
Conclusion
In this article, we learned how to take control of the conversation between our app and the server with cy.intercept(). We mocked successful logins with inline data and fixtures, used cy.wait('@login') to check exactly what the app sent, and simulated wrong passwords, server crashes, network failures, and slow responses. All of that without writing a single line of backend code.
In the next article, we'll learn how to pick the right selectors and design tests that don't break every time your UI changes. We'll look at data-cy attributes, Cypress's retry behavior, and the most common causes of flaky tests. Stay tuned, and happy testing!
