Simulating Server Errors In UI Tests

Part of verifying that an application works means checking how it behaves when things go wrong. You do not have to wait for a production outage to see how your UI responds to a server error. Mirage JS lets you override a route handler inside a test and force the exact failure state you want to exercise, so you can build and verify your error states ahead of time.

To run a test where the products request fails, import the Response class from Mirage and return a 500 status code for the /api/products route. The handler below replaces the default route during that test block:

homepage.test.js
import { Response } from "miragejs"

it('shows an error when fetching products fails', function() {
  server.get('/products', () => {
    return new Response(
      500,
      {},
      { error: "Can’t fetch products at this time" }
    );
  });

  cy.visit('/');

  cy.get('div.error').should('contain', "Can’t fetch products at this time");
});

One thing to note here is that this modified route handler exists only inside the test that defines it. Development mode still runs the original makeServer route logic unchanged, so the two environments retain separate server behavior.

After adding the error test, the full homepage.test.js file contains tests for both the successful products load and the error state:

// homepage.test.js
import { Response } from 'miragejs';
import { makeServer } from 'path/to/server';

let server;

beforeEach(() => {
  server = makeServer({ environment: 'test' });
});

afterEach(() => {
  server.shutdown();
});

it('shows the products', function() {
  server.createList('product', 5);

  cy.visit('/');

  cy.get('li.product').should('have.length', 5);
});

it('shows an error when fetching products fails', function() {
  server.get('/products', () => {
    return new Response(
      500,
      {},
      { error: "Can’t fetch products at this time" }
    );
  });

  cy.visit('/');

  cy.get('div.error').should('contain', "Can’t fetch products at this time");
});

Running the test suite now shows both tests passing. A caveat worth remembering: the HTML elements being queried in Cypress tests must actually exist in your front-end application. Cypress does not fabricate markup; it only finds and interacts with what your app renders.

Testing The Product Detail Page

The final UI flow to cover is the product detail page. The acceptance scenario here is that a user should be able to see the name of a product when visiting that product’s detail route. A new test is needed to verify this user flow:

it("shows the product’s name on the detail route", function() {
  let product = this.server.create('product', {
    name: 'Korg Piano',
  });

  cy.visit(`/${product.id}`);

  cy.get('h1').should('contain', 'Korg Piano');
});

With this in place, homepage.test.js contains the error simulation test, the homepage products test, and the product detail test, forming a small but representative suite of consumer-facing behavior:

// homepage.test.js
import { Response } from 'miragejs';
import { makeServer } from 'path/to/server;

let server;

beforeEach(() => {
  server = makeServer({ environment: 'test' });
});

afterEach(() => {
  server.shutdown();
});

it('shows the products', function() {
  console.log(server);
  server.createList('product', 5);

  cy.visit('/');

  cy.get('li.product').should('have.length', 5);
});

it('shows an error when fetching products fails', function() {
  server.get('/products', () => {
    return new Response(
      500,
      {},
      { error: "Can’t fetch products at this time" }
    );
  });

  cy.visit('/');

  cy.get('div.error').should('contain', "Can’t fetch products at this time");
});

it("shows the product’s name on the detail route", function() {
  let product = server.create('product', {
    name: 'Korg Piano',
  });

  cy.visit(`/${product.id}`);

  cy.get('h1').should('contain', 'Korg Piano');
});

Once run, all three tests should pass.

Test Setup in Practice

Now that tests are running on the Cypress side, let's look at how Mirage fits into a real testing workflow. Setting up the test environment requires understanding how the two tools interact during a test run.

In a typical Cypress test, Mirage is loaded through a support file that runs before every test suite. This file imports the Mirage server factory and configures it with the same routes, models, and factories you use in development.

The key difference between development and testing is how Mirage is seeded. In development, you might use scenario to load a rich set of mock data. In Cypress tests, you want precise control over the data state for each test case.

Per-Test Data Setup

Cypress's beforeEach hook is the natural place to define the data your test needs. Here's an example of creating a user before testing the dashboard:

js beforeEach(() => { cy.visit('/dashboard', { onBeforeLoad(window) { window.mirage = new Mirage({ models: { user: Model }, routes() { this.get('/api/users/:id'); this.post('/api/users', (schema, request) => { let attrs = JSON.parse(request.requestBody); return schema.users.create(attrs); }); } }); } }); });

Using onBeforeLoad ensures Mirage starts before any application code makes an API call. This approach also lets you access the Mirage server directly via window.mirage outside of the onBeforeLoad context.

When you need to examine or manipulate the server data mid-test, access it through a Cypress task or the window object. For example:

js cy.window().then(window => { let user = window.mirage.schema.users.find(1); expect(user.attributes.name).to.equal('Test User'); });

Testing Error States

Mirage's response blocks are not only for setting normal responses—they're the key to testing error handling in your UI. To simulate a failed request, configure a route to return error codes and payloads:

js this.get('/api/user/:id', (schema, request) => { return new Response(500, {}, { errors: ['Server error'] }); });

This mirrors the behavior you'd get from a real backend, allowing you to verify the user sees your error message and can recover when the service comes back.

Timing and Loading Tests

The timing option becomes essential when writing tests that involve loading states. By adding latency to your Mirage responses, you can assert that spinners and disabled states appear before data arrives. Set a delay on the server instance or on individual routes to control test execution time.

When Not to Use Cypress with Mirage

While the combination is powerful, not every test belongs in this toolset. If you are writing detailed component-level tests with no interaction depth or need to execute the entire component tree in isolation, Cypress's whole-page approach may be overkill.

Alternatively, if the UI only consumes JSON data without inter-module interaction, a simple mock like MSW (Mock Service Worker) backed by unit or integration tests might be lighter and faster. Cypress shines when your tests involve real user flows—clicking, typing, navigating—across multiple components or pages.

Decision Points for Your Testing Stack

Before you adopt Mirage and Cypress for your project, consider these factors:

  • Focus on user flows: If your testing goals are centered on user-facing behaviors, Cypress provides the environment for true UI testing that unit or component tests cannot.
  • Development parity: Using the same Mirage configuration in development and Cypress means your UI tests use identical data shapes and routes as development.
  • Seed data speed: Using factories instead of manually crafting payloads speeds up test creation and reduces inconsistencies between tests.
  • Consider parallel use: You do not have to choose between Cypress and component testing frameworks. Some teams use Cypress for end-to-end flows and Jest for unit or component logic.

Part Series Recap

This article concludes the four-part series on Mirage JS. The first part covered models and associations, which define the relationship between your mock data. The second part introduced factories and serializers to generate consistent data quickly. The third explored timing and response customization for simulating network conditions. The final part now shows how to combine all those features into testable, robust UI with Cypress.

Using Mirage JS throughout your development cycle—not just for testing—gives you a consistent tool to simulate a backend while you build and verify the front end. The result is a front-end development experience that does not depend on a server team's timeline or a stateful backend's availability.

With a solid setup, writing acceptance and UI tests for your applications becomes less of a chore and more of a structured process that catches regressions early, keeps application data predictable, and lets you develop front-end features with more confidence and speed.