Spying is the simplest and most commonly used intercept mode. You register an intercept, the application makes its real request, the real server sends its real response, and you inspect both after the fact. Nothing is changed — you are simply observing. The power lies in what you can observe: request URLs, headers, query parameters, request bodies, response status codes, response bodies, and timing. Combined with cy.wait(), spying becomes the most reliable synchronisation mechanism in Cypress.
Spying — Observing Without Interfering
A spy intercept watches requests pass through unchanged, capturing everything for later inspection.
// ── Basic spy: wait for a request to complete ──
cy.intercept('GET', '/api/products').as('getProducts');
cy.visit('/shop');
// cy.wait('@getProducts') pauses until the request completes
cy.wait('@getProducts');
// Now the data has arrived — safe to assert on the UI
cy.get('.product-card').should('have.length', 6);
// ── Inspecting request details ──
cy.intercept('POST', '/api/orders').as('createOrder');
// Trigger the request via UI
cy.get('[data-cy="place-order"]').click();
cy.wait('@createOrder').then((interception) => {
// ── Request inspection ──
const req = interception.request;
expect(req.method).to.eq('POST');
expect(req.url).to.include('/api/orders');
expect(req.headers['content-type']).to.include('application/json');
expect(req.headers['authorization']).to.match(/^Bearer /);
// Verify the request body (what the app sent)
expect(req.body).to.have.property('items');
expect(req.body.items).to.be.an('array').with.length(2);
expect(req.body.items[0]).to.have.property('productId', 1);
expect(req.body.items[0]).to.have.property('quantity', 2);
expect(req.body.shippingAddress).to.deep.include({
city: 'London',
postcode: 'SW1A 1AA',
});
// ── Response inspection ──
const res = interception.response;
expect(res.statusCode).to.eq(201);
expect(res.body).to.have.property('orderId');
expect(res.body.total).to.be.a('number').and.be.greaterThan(0);
});
// ── Waiting for multiple sequential requests ──
cy.intercept('GET', '/api/categories').as('getCategories');
cy.intercept('GET', '/api/products*').as('getProducts');
cy.intercept('GET', '/api/user/profile').as('getProfile');
cy.visit('/dashboard');
// Wait for all three in order
cy.wait(['@getCategories', '@getProducts', '@getProfile']);
// All data loaded — safe to assert on the complete dashboard
cy.get('.dashboard-content').should('be.visible');
// ── Spy with URL pattern matching ──
// Match any GET to /api/products with any query string
cy.intercept('GET', '/api/products?*').as('filteredProducts');
// Match using glob patterns
cy.intercept('GET', '/api/users/*/orders').as('userOrders');
// Match using regex
cy.intercept({ method: 'GET', url: /\/api\/products\/\d+/ }).as('singleProduct');
// Match by request body content (POST requests)
cy.intercept({
method: 'POST',
url: '/api/search',
body: { query: 'backpack' }, // Only match if body contains this
}).as('backpackSearch');
// ── Counting requests ──
let requestCount = 0;
cy.intercept('GET', '/api/analytics*', () => {
requestCount++;
}).as('analytics');
cy.visit('/shop');
cy.wait('@analytics');
// After page load, verify analytics was called
cy.wrap(null).should(() => {
expect(requestCount).to.be.greaterThan(0);
});
cy.wait('@alias') is not just a wait — it is the recommended synchronisation mechanism for Cypress tests that depend on API data. Unlike cy.get('.element').should('exist') (which retries DOM queries), cy.wait('@alias') waits for the specific network request to complete. This is more deterministic because it targets the root cause (data has arrived) rather than a symptom (element has appeared). The element might appear before the data arrives (showing a loading skeleton), creating a false positive. Waiting for the request guarantees the data is in the browser.cy.wait(['@req1', '@req2', '@req3']) to wait for multiple requests when a page fires several API calls during load. This is common for dashboard pages that fetch categories, products, and user profile simultaneously. Waiting for all three ensures the page is fully loaded before you assert on any element. The array form waits for all listed requests to complete, regardless of order.cy.wait('@alias') waits for the next matching request. If the same endpoint is called multiple times (pagination, polling), each cy.wait() call catches the next occurrence. Calling cy.wait('@getProducts') twice waits for two separate requests to /api/products. If you only expect one request, only call cy.wait() once — a second call will hang until the timeout expires because no second request is coming.Common Mistakes
Mistake 1 — Not using cy.wait() for API-dependent assertions
❌ Wrong: cy.visit('/shop'); cy.get('.product-card').should('have.length', 6); — works sometimes but flakes when the API is slow because .should() might time out before the data arrives.
✅ Correct: cy.intercept('GET', '/api/products').as('products'); cy.visit('/shop'); cy.wait('@products'); cy.get('.product-card').should('have.length', 6); — wait for the data, then assert on the UI.
Mistake 2 — Not verifying request payloads in spy mode
❌ Wrong: Only checking that the request was made (cy.wait('@createOrder')) without inspecting what the application sent.
✅ Correct: Inspecting interception.request.body to verify the application sent the correct data: right product IDs, quantities, shipping address, and payment method.