Stubbing replaces real API responses with data you control. The browser sends the request, but instead of reaching the server, Cypress intercepts it and returns your predefined response instantly. This makes tests blazingly fast (no network latency), completely deterministic (same response every time), and independent of backend state (works even when the server is down). Stubbing is the foundation of isolated front-end testing.
Stubbing Responses — Total Control Over API Data
Cypress offers three ways to define stubbed responses: inline objects, fixture files, and dynamic response functions.
// ── METHOD 1: Inline response object ──
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: 1, name: 'Stubbed Backpack', price: 29.99, inStock: true },
{ id: 2, name: 'Stubbed Bike Light', price: 9.99, inStock: false },
],
headers: {
'X-Total-Count': '2',
},
}).as('stubbedProducts');
cy.visit('/shop');
cy.wait('@stubbedProducts');
cy.get('.product-card').should('have.length', 2);
cy.contains('Stubbed Backpack').should('be.visible');
// ── METHOD 2: Fixture file response ──
// cypress/fixtures/api/products.json contains the response body
cy.intercept('GET', '/api/products', { fixture: 'api/products.json' }).as('fixtureProducts');
// Fixture with status code
cy.intercept('GET', '/api/products', {
statusCode: 200,
fixture: 'api/products.json',
}).as('fixtureProducts');
// ── METHOD 3: Dynamic response function ──
cy.intercept('GET', '/api/products', (req) => {
// Generate response dynamically based on request
const category = new URL(req.url, 'http://localhost').searchParams.get('category');
if (category === 'electronics') {
req.reply({
statusCode: 200,
body: [{ id: 10, name: 'USB Cable', price: 4.99, category: 'electronics' }],
});
} else {
req.reply({ statusCode: 200, body: [] });
}
}).as('dynamicProducts');
// ── Edge case stubs — testing difficult-to-reproduce scenarios ──
// Empty list (no products)
cy.intercept('GET', '/api/products', { body: [] }).as('emptyProducts');
// Single item
cy.intercept('GET', '/api/products', {
body: [{ id: 1, name: 'Only Product', price: 99.99 }],
}).as('singleProduct');
// Very large dataset (pagination edge case)
cy.intercept('GET', '/api/products', {
body: Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
price: parseFloat((Math.random() * 100).toFixed(2)),
})),
}).as('manyProducts');
// Null or missing fields (defensive rendering test)
cy.intercept('GET', '/api/products', {
body: [
{ id: 1, name: null, price: 29.99 }, // null name
{ id: 2, name: 'Normal', price: null }, // null price
{ id: 3, name: '', price: 0 }, // empty string, zero price
],
}).as('edgeCaseProducts');
// ── Stub with delay (simulating slow network) ──
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [{ id: 1, name: 'Slow Product', price: 9.99 }],
delay: 3000, // 3 second delay — test loading spinner
}).as('slowProducts');
cy.visit('/shop');
// Verify loading state appears
cy.get('[data-cy="loading-spinner"]').should('be.visible');
// Wait for the delayed response
cy.wait('@slowProducts');
// Verify loading state disappears and data shows
cy.get('[data-cy="loading-spinner"]').should('not.exist');
cy.contains('Slow Product').should('be.visible');
// ── Stub with error status codes ──
// Server error
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal Server Error', message: 'Database connection failed' },
}).as('serverError');
// Not found
cy.intercept('GET', '/api/products/999', {
statusCode: 404,
body: { error: 'Product not found' },
}).as('notFound');
// Rate limited
cy.intercept('GET', '/api/products', {
statusCode: 429,
body: { error: 'Too many requests', retryAfter: 60 },
headers: { 'Retry-After': '60' },
}).as('rateLimited');
delay option to test loading states and spinners: { body: data, delay: 2000 }. Without a delay, stubbed responses arrive so fast that loading spinners flash for a single frame — too quick for your test to assert on them. A 2-3 second delay gives the loading state time to render, letting you verify that the spinner appears, the skeleton screens display, and the transition to loaded content works correctly.Common Mistakes
Mistake 1 — Stubbing all API calls without any real integration tests
❌ Wrong: Every test stubs every API call — the backend team renames a field and all tests still pass because they test against stale fixtures.
✅ Correct: Stub for edge cases, error scenarios, and performance-sensitive tests. Use spy (real API) for at least one happy-path integration test per feature to verify the contract is intact.
Mistake 2 — Not testing error responses because the real API rarely fails
❌ Wrong: “The API almost never returns 500, so we do not need to test error handling.”
✅ Correct: Stubbing a 500 response is trivial: cy.intercept('GET', '/api/products', { statusCode: 500 }). Test that the UI shows an error message, hides the loading spinner, and offers a retry button. These scenarios are impossible to reproduce with real APIs but easy with stubs.