Deterministic E2E tests instead of flaky ones
Testing Magento checkout flows only against the real API gives you slow, flaky end-to-end tests that turn red on every backend hiccup. With cy.intercept() you stub and observe network requests on purpose, load fixtures for realistic responses, and test error and loading states deterministically, without ever touching a real server. That keeps your tests fast, reproducible, and independent of the current database state.
Table of Contents
- 1. Why network stubbing is essential for E2E testing Magento stores
- 2. cy.intercept() fundamentals: syntax, matching, and aliasing
- 3. Fixture-based response mocking with cypress/fixtures/*.json
- 4. Deterministic waiting: cy.wait('@alias') instead of fixed timeouts
- 5. Testing error and loading states deterministically
- 6. Asserting on request and response payloads
- 7. req.continue() and req.reply(): spying vs. stubbing in detail
- 8. Aliasing Magento GraphQL requests by operation name
- 9. When to stub, when to hit the real backend: contract test tradeoffs
- 10. Summary
- 11. FAQ
1. Why network stubbing is essential for E2E testing Magento stores
End-to-end tests that run exclusively against a real Magento backend are structurally unstable: stock levels change, prices get recalculated, third-party APIs for payment or shipping respond at different speeds, and every test environment drifts over time. A test that's green today can turn red tomorrow for no reason other than a product disappearing from the catalog. cy.intercept() solves this by intercepting network requests at the browser level before they reach the backend, returning controlled, reproducible responses instead.
This is deliberately distinct from plain PHPUnit unit testing: while unit tests check individual PHP classes in isolation, Cypress tests the whole system from the browser's point of view, including Alpine.js interactions, GraphQL calls, and the actual rendering logic in the Hyvä theme. Network stubbing is not a replacement for backend tests, but a targeted way to control the one variable that makes E2E tests unstable most often: the server's response. Once you control that variable, you can reliably force edge cases like empty carts, failed payments, or sold-out products without manually preparing the test database.
2. cy.intercept() fundamentals: syntax, matching, and aliasing
cy.intercept() comes in several signature variants: cy.intercept(url) for pure observation, cy.intercept(method, url, response) for simple stubbing, and cy.intercept(routeMatcher, handler) for full control over the request and response through a callback function. Two approaches are available for URL matching: glob patterns like '**/rest/V1/products*' are quick to write and sufficient for most REST endpoints, while RegExp objects like /\/rest\/V1\/products\/\d+$/ allow precise matching when several endpoints would otherwise overlap.
Every intercept should get a descriptive alias via .as('alias'). Without an alias, an intercepted request can neither be awaited nor referenced in assertions later on. Order matters too: Cypress evaluates multiple matching cy.intercept() definitions in reverse registration order, so the most recently registered pattern wins on overlap. That lets you set a global default stub for a test and override it selectively inside individual it() blocks, for example for a single error case.
// Basic GET intercept with fixture-based response mocking
cy.intercept('GET', '/rest/V1/products*', {
fixture: 'products/list.json'
}).as('getProducts');
cy.visit('/catalogsearch/result/?q=hyva');
cy.wait('@getProducts');
// Glob pattern matches any query string after products
cy.intercept('GET', '**/rest/V1/products**').as('getProductsGlob');
// RegExp pattern for precise path matching, no accidental overlap
cy.intercept({
method: 'GET',
url: /\/rest\/V1\/products\/\d+$/
}).as('getSingleProduct');
3. Fixture-based response mocking with cypress/fixtures/*.json
Fixtures are static JSON files under cypress/fixtures/ that simulate realistic backend responses. Instead of defining a response inline in the test, you reference a file via { fixture: 'products/list.json' }, or load it explicitly with cy.fixture('products/list.json').then((data) => ...) when the data still needs to be modified before use. The advantage over inline objects: fixtures are version-controlled, reusable across multiple spec files, and can be named clearly, for example empty.json, out-of-stock.json, or 500-error.json for the respective edge cases.
A good fixture structure mirrors the actual shape of the Magento REST or GraphQL response, including every field the frontend accesses. Incomplete fixtures are a common source of bugs: if a field like extension_attributes, which the component actually expects, is missing, the test can stay falsely green even though the real backend returns a different shape. It's worth regularly diffing fixtures against a real API response, or generating them automatically from a staging system, rather than writing them once by hand and never updating them.
{
"items": [
{
"id": 1042,
"sku": "MS-HOODIE-BLK-M",
"name": "Mironsoft Hoodie Black",
"price": 59.9,
"status": 1,
"extension_attributes": {
"stock_item": {
"qty": 24,
"is_in_stock": true
}
}
},
{
"id": 1043,
"sku": "MS-CAP-GRN",
"name": "Mironsoft Cap Green",
"price": 24.5,
"status": 1,
"extension_attributes": {
"stock_item": {
"qty": 0,
"is_in_stock": false
}
}
}
],
"total_count": 2
}
4. Deterministic waiting: cy.wait('@alias') instead of fixed timeouts
A classic anti-pattern in E2E tests is cy.wait(2000): a fixed wait that's either too short and makes the test flaky, or too long and needlessly slows down the entire suite. cy.wait('@alias') solves this structurally by waiting exactly as long as it takes for the aliased request to actually complete, whether that's 50 milliseconds or 3 seconds. Only then does Cypress run the next command, ruling out race conditions between UI updates and network responses.
cy.wait() also accepts an array of aliases, for example cy.wait(['@getProducts', '@getCategories']), when a page fires several parallel requests and the test should only proceed once all of them have completed. On top of that, cy.wait('@alias') returns an interception object that you can use directly for further assertions, for example .its('response.statusCode').should('eq', 200). Worth noting: a timeout on cy.wait() is itself a valuable test result, it reliably shows that an expected request was never fired, which can point to a real frontend bug.
5. Testing error and loading states deterministically
Error states like a 500 from the server or a timeout are hard to reproduce reliably against a real backend, but trivial with cy.intercept(), a single line does the job. Using { statusCode: 500, body: {...} } you simulate a backend failure exactly when the test needs it, then check whether the UI shows the expected error message instead of simply hanging. For a complete network outage, forceNetworkError: true is the right tool, it simulates an aborted request rather than a regular error response.
For loading states, the delay option inside a req.reply() handler is key: it artificially delays the response, say by 3 seconds, so a test can specifically check that a loading spinner is visible during that window and correctly disappears afterward. Without this artificial delay, loading states are practically impossible to test reliably, since local test environments usually respond too fast for the intermediate state to ever become visible.
// Simulate a hard backend failure
cy.intercept('GET', '**/rest/V1/products*', {
statusCode: 500,
body: { message: 'Internal Server Error' }
}).as('getProductsError');
cy.visit('/catalog/category/view/id/5');
cy.wait('@getProductsError');
cy.get('[data-testid="error-banner"]').should('be.visible');
// Simulate a slow response to test loading spinners deterministically
cy.intercept('GET', '**/rest/V1/products*', (req) => {
req.reply({ delay: 3000, fixture: 'products/list.json' });
}).as('getProductsSlow');
cy.get('[data-testid="loading-spinner"]').should('be.visible');
cy.wait('@getProductsSlow');
cy.get('[data-testid="loading-spinner"]').should('not.exist');
6. Asserting on request and response payloads
Network stubbing isn't just about faking responses, it's also about checking what the frontend actually sends to the backend. Inside a cy.intercept() callback handler, the incoming req is fully available, including req.body, req.headers, and req.url. That lets you assert directly in the handler, for example that a checkout request contains the correct payment method before any response is even returned: expect(req.body.paymentMethod.method).to.eq('checkmo').
Alternatively, the interception object returned by cy.wait('@alias') exposes the same request afterward for inspection, for example via cy.wait('@placeOrder').its('request.body.cartId').should('exist'). This deferred style works particularly well when several assertions should read cleanly one after another in the test chain. For more complex checks, such as verifying that a cart request contains exactly the expected line items in the right order, a combination of deep.equal and targeted property checks keeps failure messages meaningful when a test breaks.
// Intercept POST checkout request and assert on the outgoing payload
cy.intercept('POST', '**/rest/V1/carts/*/payment-information', (req) => {
expect(req.body).to.have.property('paymentMethod');
expect(req.body.paymentMethod.method).to.eq('checkmo');
req.reply({ statusCode: 200, body: 12345 });
}).as('placeOrder');
cy.get('[data-testid="place-order"]').click();
cy.wait('@placeOrder').its('response.statusCode').should('eq', 200);
7. req.continue() and req.reply(): spying vs. stubbing in detail
A cy.intercept() without a defined response acts as a pure spy: the request goes to the real backend unchanged, Cypress just observes it and makes it assertable through cy.wait('@alias') and the interception object. That's useful for verifying that a tracking event or an analytics call actually fires, without altering its behavior. Inside a handler, the same behavior can be forced explicitly with req.continue(), optionally with a callback function that inspects or modifies the response once it arrives.
req.reply(), on the other hand, fully stubs the response and prevents the request from ever reaching the real backend. Both functions can be combined: req.continue((res) => { res.body.items = res.body.items.slice(0, 1); }) lets the request run for real but modifies the returning response, for instance to artificially trim a large product list for the test. This middle ground is particularly valuable for snapshot-style tests where the real backend logic should still apply, but the data volume in the test needs to stay controllable.
8. Aliasing Magento GraphQL requests by operation name
Magento's GraphQL API runs through a single endpoint, usually /graphql, which makes plain URL matching useless for cy.intercept(): every query and every mutation lands on the same route. The solution lives in the request body: every GraphQL request carries an operationName field that you can evaluate inside the callback handler to alias individual operations selectively. That lets a test wait precisely for ProductDetailQuery without accidentally reacting to a completely different query like MegaMenu that happens to fire around the same time.
Dynamically setting req.alias inside the handler is the most reliable approach here, since it still works when several GraphQL operations hit the same URL within a short window. Without this targeted aliasing, you're left with a generic cy.intercept('POST', '**/graphql') catch-all that intercepts everything but can't distinguish between individual operations, making tests imprecise and brittle, especially on pages with many parallel GraphQL calls like the Magento product detail page.
// Alias Magento GraphQL requests by operation name, not by URL
cy.intercept('POST', '**/graphql', (req) => {
if (req.body.operationName === 'ProductDetail') {
req.alias = 'productDetailQuery';
}
if (req.body.operationName === 'AddSimpleProductsToCart') {
req.alias = 'addToCartMutation';
}
});
cy.visit('/simple-product.html');
cy.wait('@productDetailQuery');
cy.get('[data-testid="add-to-cart"]').click();
cy.wait('@addToCartMutation').its('response.body.data').should('exist');
9. When to stub, when to hit the real backend: contract test tradeoffs
Network stubbing makes tests fast and deterministic, but it carries a real risk: false confidence. If a fixture no longer matches the actual API shape because the backend changed, the Cypress test can stay green even though the integration has long since broken in production. That's why critical paths like completing checkout or processing payment should run at least once, in a nightly suite, against a real staging backend, while UI states like errors, empty lists, or loading animations get stubbed deliberately.
A sensible addition is dedicated contract testing, for example with Pact or OpenAPI schema validation, run separately from the UI tests to ensure the real backend still matches the shape the fixtures assume. That keeps responsibilities cleanly separated: Cypress with cy.intercept() verifies frontend behavior given a response, contract tests verify that response still matches reality. The table below summarizes when each pattern is the better choice.
| Scenario | Naive pattern | Risk | Recommended pattern |
|---|---|---|---|
| Waiting on requests | cy.wait(2000) with a fixed delay | Flaky on a slow CI runner | cy.wait('@alias') on an intercept |
| Checkout & payment | Stub the payment gateway entirely | A broken contract goes unnoticed | Test the critical path against staging |
| Error states | Not tested at all | 500 handling silently breaks in prod | Stub statusCode 500 deliberately |
| GraphQL queries | Match by URL only | Alias collisions, wrong interception | Match on operationName |
| Empty result lists | Only the happy path with data | Empty state stays untested | Stub a fixture with an empty array |
In practice, a hybrid strategy works best: most of the suite runs fast and stubbed against fixtures, a lean set of smoke tests covers the business-critical paths against a real backend, and contract tests close the gap in between. That keeps the suite fast without sacrificing confidence in the flows that matter most.
Mironsoft
Cypress E2E testing and test automation for Magento and Hyvä stores
Want stable E2E tests for your Magento store?
We build resilient Cypress test suites for your Magento and Hyvä stores, from the first fixture strategy to full CI/CD integration, so your critical flows stay reliably green.
Cypress test setup
Intercepts, fixtures, and alias strategies built cleanly from the ground up
GraphQL test coverage
Operation-based aliasing for Magento's GraphQL API
CI/CD integration
Cypress wired into your pipeline, with stable, fast runs
10. Summary
cy.intercept() solves the core problem behind unstable E2E tests: dependence on a real, constantly changing backend. Fixture-based stubbing lets you reproducibly force loading states, error cases, and empty states, while cy.wait('@alias') structurally rules out race conditions between UI and network response, instead of merely papering over them with fixed timeouts. For Magento's GraphQL API, operation-based aliasing through the request body is the only reliable way to target individual queries and mutations, since URL matching fails against the single /graphql endpoint.
The decisive point isn't technical skill, though, it's strategy: stubbing is powerful, but only as trustworthy as the fixtures it's built on. Critical business processes like checkout and payment belong additionally in a suite that runs against a real backend, complemented by separate contract tests that guard the API shape. That keeps the test suite fast and meaningful at the same time, rather than just fast and blindly green.
Cypress Network Stubbing with cy.intercept() - The Essentials at a Glance
cy.intercept() fundamentals
Glob or RegExp matching, always tag with .as('alias'), later definitions override earlier ones.
Fixtures
JSON files under cypress/fixtures/, version-controlled and reusable for edge cases like empty states.
Deterministic waiting
cy.wait('@alias') instead of fixed timeouts, structurally prevents race conditions.
Stub vs. real backend
Test critical paths against staging too, guard against API drift with contract tests.