Bhavik Bamania

Programming

Mocking Network Requests in Cypress with cy.intercept()

Bhavik Bamania

15 min read

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X
cypress-network-mocking-tutorial-cover
Mocking Network Requests in Cypress with cy.intercept()

Learn how to mock API responses in Cypress with cy.intercept(). Fake a backend with fixtures, check what your app sends, and simulate errors, network failures, and slow responses, step by step.

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:

  1. A Beginner's Guide to Cypress: End-to-End Testing Made Easy
  2. Understanding Cypress Basics: Core Features and Syntax Explained
  3. 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:

  1. Just watch: note down the request and let it go to the real server. This is like the spy from part 3.
  2. 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 true for 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 less

If 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

statusCode: 401 + message

if (!response.ok)

Server crash

statusCode: 500 + message

if (!response.ok)

No internet

forceNetworkError: true

catch

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

cy.intercept('GET', '/api/users').as('users')

Mock with inline data

cy.intercept('POST', '/api/login', { statusCode: 200, body: {...} })

Mock with a fixture

cy.intercept('POST', '/api/login', { fixture: 'login-success.json' })

Return an error

cy.intercept('POST', '/api/login', { statusCode: 401, body: {...} })

Simulate no internet

cy.intercept('POST', '/api/login', { forceNetworkError: true })

Simulate a slow server

cy.intercept('POST', '/api/login', { fixture: '...', delay: 1000 })

Wait for a request

cy.wait('@login')

Check the request body

cy.wait('@login').its('request.body').should('deep.equal', {...})

Common Beginner Mistakes

  1. Calling cy.intercept() after the click. The request has already left by then, so Cypress misses it. Always set up the intercept first.
  2. 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.
  3. Forgetting the @ in cy.wait(). cy.wait('login') doesn't work. It must be cy.wait('@login').
  4. Using equal instead of deep.equal for objects. Objects must be compared with deep.equal.
  5. 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!

CypressTypeScriptTSXEnd-to-End TestingCypress TutorialAPI Mocking

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.