cypress is an end-to-end (E2E) testing framework that runs in a real browser, allowing developers to test the entire application flow from the user's perspective. enzyme was a popular utility for React component testing that provided shallow rendering and deep inspection capabilities, but it is now deprecated and incompatible with modern React versions. jest-dom is a lightweight library providing custom DOM matchers (like toBeInTheDocument) to make assertions more readable and focused on accessibility. react-testing-library (now part of @testing-library/react) is the industry standard for component testing, encouraging tests that mimic user interactions rather than implementation details.
Testing a React application requires a layered approach. You need fast unit tests to verify component logic, integration tests to ensure components work together, and end-to-end (E2E) tests to validate the full user journey. The tools cypress, enzyme, jest-dom, and react-testing-library each occupy specific layers in this strategy. However, the landscape has shifted dramatically in recent years, with one major tool becoming obsolete and others rising as the definitive standard.
Before diving into implementation, we must address the elephant in the room. enzyme is deprecated.
For years, enzyme was the go-to solution for React testing because it allowed "shallow rendering." This meant you could test a component in isolation without rendering its children. It provided powerful APIs to dig into internal state and instance methods.
However, enzyme relies on accessing React's internal data structures. With the release of React 17 and especially React 18 (which introduced concurrent rendering), these internals changed fundamentally. The enzyme maintainers have stated they cannot support React 18 without a complete rewrite that contradicts the library's original design philosophy.
Consequence: If you start a new project today, do not install enzyme. It will not work with modern React. Your tests will fail, and you will be stuck on an old version of React.
// ❌ AVOID: Enzyme is deprecated and incompatible with React 18+
import { shallow } from 'enzyme';
// This approach encourages testing implementation details
const wrapper = shallow(<MyComponent />);
wrapper.instance().someInternalMethod(); // Bad practice: coupling to class internals
Instead, the community has unified around react-testing-library, which works seamlessly with React 18 and future versions.
The core philosophical difference between the old way (enzyme) and the new way (react-testing-library) is what you test.
enzyme encouraged testing implementation details. You would check if a specific class name existed, if a state variable was set, or if a child component was rendered with specific props. This made tests brittle; if you refactored the component's internal structure but the UI looked the same, the test would fail.
react-testing-library operates on a simple principle: Test what the user sees and does. It discourages accessing internal state. Instead, you query the DOM by text content, accessibility roles (like button or heading), or labels. This ensures your tests verify the actual experience.
react-testing-library renders the component into a real DOM node (using jsdom by default) and provides queries that mimic how a user finds elements.
// ✅ PREFERRED: React Testing Library
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { Counter } from './Counter';
test('increments count when button is clicked', async () => {
render(<Counter />);
// Query by role (accessibility-first)
const button = screen.getByRole('button', { name: /increment/i });
// Simulate user interaction
await userEvent.click(button);
// Assert based on visible output
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
In contrast, an enzyme test would have looked like this (which you should no longer write):
// ❌ DEPRECATED: Enzyme approach
import { shallow } from 'enzyme';
import Counter from './Counter';
test('increments count', () => {
const wrapper = shallow(<Counter />);
// Accessing internal state - brittle!
expect(wrapper.state('count')).toBe(0);
// Finding by CSS class or component type
wrapper.find('.increment-btn').simulate('click');
expect(wrapper.state('count')).toBe(1);
});
While react-testing-library handles rendering and querying, it relies on jest-dom for assertions. Standard Jest matchers like toBe() or toEqual() are often too low-level for DOM testing. You don't care if an element is null; you care if it is visible or disabled.
jest-dom extends Jest's expect with semantic matchers. These matchers check not just existence, but visibility and accessibility states.
jest-dom MattersWithout jest-dom, you might write verbose and unclear checks:
// ❌ Without jest-dom: Verbose and unclear intent
const button = container.querySelector('button');
expect(button).not.toBeNull();
expect(button.disabled).toBe(true);
expect(button.style.display).toBe('none');
With jest-dom, the intent is obvious and the code is shorter:
// ✅ With jest-dom: Clear and semantic
import '@testing-library/jest-dom'; // Enables custom matchers
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).not.toBeVisible();
react-testing-library automatically includes jest-dom in its setup in many configurations, but understanding that they are separate packages is key. jest-dom can also be used with cypress or vanilla DOM tests.
While react-testing-library excels at component logic, it runs in a Node.js environment (via jsdom). It cannot test real browser behaviors like network throttling, actual browser rendering quirks, or complex interactions across multiple tabs.
This is where cypress shines. Cypress runs inside a real browser (Chrome, Firefox, Electron, etc.). It tests your application exactly as a user experiences it, from the URL bar to the final pixel.
react-testing-library tests isolated components or small trees. cypress tests the full running application.react-testing-library tests are milliseconds fast. cypress tests take seconds because they boot a browser.Using react-testing-library (Integration Test):
You mock the API call. You verify the component handles the success state.
// ✅ React Testing Library: Fast, isolated, mocks network
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import Login from './Login';
test('shows success message after login', async () => {
// Mock the API
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ token: 'abc' }) })
);
render(<Login />);
await userEvent.type(screen.getByLabelText(/username/i), 'user123');
await userEvent.type(screen.getByLabelText(/password/i), 'pass123');
await userEvent.click(screen.getByRole('button', { name: /log in/i }));
// Verify UI update
expect(await screen.findByText('Welcome back!')).toBeInTheDocument();
});
Using cypress (E2E Test):
You hit the real API (or a seeded test database). You verify the URL changes and the full flow works.
// ✅ Cypress: Real browser, real network (or intercepted)
describe('Login Flow', () => {
it('successfully logs in and redirects', () => {
// Visit the real URL
cy.visit('/login');
// Cypress automatically retries finding these elements
cy.get('input[name="username"]').type('user123');
cy.get('input[name="password"]').type('pass123');
cy.get('button').contains('Log In').click();
// Assert URL change and content
cy.url().should('include', '/dashboard');
cy.contains('Welcome back!').should('be.visible');
});
});
Both libraries handle async UI (like loading spinners), but they do it differently.
react-testing-library provides explicit helpers like findBy queries that return a promise and wait for the element to appear.
// React Testing Library: Explicit async/await
// getBy... throws immediately if not found
// findBy... waits up to 1 second (default) for the element
const submitButton = screen.getByRole('button', { name: /submit/i });
await userEvent.click(submitButton);
// Wait for the success message to appear
const successMsg = await screen.findByText('Saved successfully');
expect(successMsg).toBeInTheDocument();
cypress handles waiting implicitly. Almost every command retries until the condition is met or the timeout is reached. You rarely need to write async/await in Cypress tests.
// Cypress: Implicit waiting
cy.get('button').contains('Submit').click();
// Cypress automatically waits for the text to appear
cy.contains('Saved successfully').should('be.visible');
| Feature | enzyme | react-testing-library | jest-dom | cypress |
| :--- | :--- | :--- | :--- :--- |
| Status | ❌ Deprecated | ✅ Industry Standard | ✅ Essential Utility | ✅ E2E Leader |
| Environment | Node (jsdom) | Node (jsdom) | Node (jsdom) | Real Browser |
| Philosophy | Internal State | User Behavior | Semantic Assertions | User Journey |
| Speed | Fast | Fast | N/A (Assertions) | Slow |
| React 18 | ❌ No Support | ✅ Full Support | ✅ Compatible | ✅ Compatible |
| Best For | Legacy Maintenance | Unit/Integration Tests | Writing Assertions | Critical Flows |
For a modern, robust testing architecture, combine these tools as follows:
react-testing-library for 90% of your tests. Write unit and integration tests for your components. Focus on accessibility roles and user interactions.jest-dom to make your assertions readable and semantic. It turns vague checks into clear statements of intent.cypress for a small suite of critical end-to-end tests. Cover the "happy paths" (e.g., sign up, purchase, login) to ensure the whole system integrates correctly.enzyme tests, treat them as technical debt. Do not write new ones. Gradually rewrite them using react-testing-library as you touch those files for feature work.By shifting away from internal implementation details (enzyme) and toward user-centric testing (react-testing-library + jest-dom) while reserving heavy browser tests (cypress) for high-value scenarios, you create a test suite that is fast, reliable, and resilient to refactoring.
Choose cypress when you need to verify critical user journeys across your entire application, such as login flows, checkout processes, or cross-browser compatibility. It is essential for catching integration bugs that unit tests miss, but it is slower to run and should be reserved for high-value scenarios rather than every component.
Do NOT choose enzyme for new projects. It is officially deprecated, no longer maintained, and fundamentally incompatible with React 18+ due to its reliance on internal React APIs. If you inherit a legacy codebase using it, plan a migration strategy to @testing-library/react immediately.
Choose jest-dom whenever you are writing tests that interact with the DOM, regardless of whether you use React, Vue, or vanilla JS. It pairs naturally with react-testing-library to replace generic assertions with semantic ones (e.g., toBeInTheDocument), making your test intent clearer and more robust against minor DOM structure changes.
Choose react-testing-library as your primary tool for unit and integration testing of React components. It forces you to write tests that behave like real users (querying by text, role, or label) rather than checking internal state or class names, resulting in tests that refactor safely and verify accessibility by default.
Fast, easy and reliable testing for anything that runs in a browser.
Cypress comes packaged as an npm module, which is all you need to get started testing.
After installing you'll be able to:
require Cypress as a modulePlease check our system requirements.
npm install --save-dev cypress
Please visit our documentation for a full list of commands and examples.