The Problem: Your React Tests Are Either Slow, Brittle, or Both
Snapshot tests are a trap. I know because I walked straight into it. You write a toMatchSnapshot() call, feel productive, commit it, and three weeks later you’re approving a 400-line snapshot diff because someone changed a button’s aria-label. The test isn’t telling you anything broke — it’s just screaming about change. And because updating snapshots is one --updateSnapshot flag away, you start rubber-stamping diffs without reading them. That’s not testing. That’s theater.
E2E flakiness is a separate wound. Locally, your Cypress or Playwright suite is green. In CI it fails on the same test three runs in a row, passes on the fourth, and nobody knows why. Usually it’s a timing issue — an animation that’s 20ms slower on a GitHub Actions runner, a network request that resolves in a different order, or a findByRole that works against your local dev server but hits a loading state on the containerized build. The test isn’t wrong and the app isn’t broken, but you’ve now trained your whole team to re-run failed CI jobs instead of investigating them.
Then there’s the Jest + Vite situation. If you’ve migrated a React project from Create React App to Vite, you’ve almost certainly hit this: Jest doesn’t understand ES modules, Vite doesn’t use Babel the way Jest expects, and your jest.config.js that worked fine for two years suddenly throws a SyntaxError: Cannot use import statement outside a module on the first test run. The fix involves juggling transformIgnorePatterns, swapping in babel-jest, or ditching Jest entirely for Vitest — and the right answer depends on your specific dependency tree in ways no blog post fully covers.
This guide covers five frameworks I’ve personally configured, shipped, broken in production, and debugged at 11pm: Vitest, React Testing Library, Playwright, Cypress, and Storybook with the test addon. That spans unit, integration, and E2E layers. I’ll show you actual config files, the gotchas that aren’t in the README, and the specific situations where each one earns its place — or doesn’t. If you’re also trying to figure out how testing tooling fits into the broader infrastructure decisions for your project, the Essential SaaS Tools for Small Business in 2026 guide covers the production stack picture worth having alongside this.
One thing I want to establish before diving in: the goal isn’t 100% coverage. The goal is a test suite you trust enough to act on. A failing test should mean “something is actually broken,” not “someone changed a classname.” Every framework recommendation below is filtered through that lens — does it help you write tests that fail for the right reasons, and does it get out of your way the rest of the time?
My Current Testing Stack at a Glance
Enzyme’s death by a thousand cuts finally ended for me when React 17 dropped and the official adapter never got a stable release. The community adapter existed, but I spent two days debugging bizarre hook behavior that traced back to Enzyme’s internals not playing nicely with the new JSX transform. I cut my losses, ripped it out, and haven’t looked back. If you’re still on Enzyme for a greenfield project, that’s a sign something went wrong upstream in your tech decisions.
My day-to-day split looks like this: Vitest + React Testing Library for anything living inside a Vite project, and Jest + React Testing Library for the legacy Create React App codebases I maintain. The CRA stuff will migrate eventually, but the cost-benefit math doesn’t work right now — Jest runs fine there, the tests pass, and rewriting config for a marginal speed improvement isn’t where the engineering hours should go. Vitest, though, is genuinely fast. On a mid-sized component library I work on, the full unit suite dropped from ~40 seconds under Jest to under 8 seconds. That’s not a benchmark, that’s just what I observed after swapping the runner.
# Vitest setup inside an existing Vite project
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
# vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
})
# src/test/setup.ts
import '@testing-library/jest-dom'
The thing that caught me off guard with Vitest early on: vi.mock() hoisting behaves slightly differently from jest.mock(). Jest hoists mock calls to the top of the file at the Babel transform level. Vitest does it too, but if you’re using ESM natively without a transformer, you’ll hit cases where your mock doesn’t apply because the import already resolved. The fix is usually adding vi.mock() before any imports that depend on the mocked module, or using vi.doMock() with dynamic imports. It bites almost everyone once.
For E2E, I default to Playwright. The multi-browser support out of the box, the network interception API, and the built-in test runner make it the obvious choice if your team is comfortable with async/await. Here’s the honest trade-off: Playwright’s auto-waiting is more aggressive than Cypress’s and that’s mostly a good thing, but it means when something times out, the error messages can be harder to read — you get a wall of expected/received DOM state. Cypress stays in my stack for teams where developers are newer to JavaScript or where someone froze up every time they saw await page.locator() chains. Cypress’s synchronous-looking API (even though it’s not really synchronous under the hood) has a lower learning curve. The moment the team is comfortable, I’d steer them toward Playwright.
- Vitest + RTL — any Vite-based project, new or recent; native ESM support means no config gymnastics
- Jest + RTL — CRA projects where migration risk outweighs the speed gain; also anywhere you need a very mature ecosystem of third-party matchers
- Playwright — E2E default; especially strong if you need cross-browser coverage or want to intercept and assert on network requests
- Cypress — E2E when the team struggles with async mental models, or when you want the time-travel debugger during active development; the component testing mode is also surprisingly usable now
- Enzyme — nothing in 2024; archive your old tests, rewrite them with RTL, move on
1. Vitest — What I Switched to From Jest and Haven’t Looked Back
My jest.config.js for a Vite-based React project had grown to 60 lines. I was maintaining a custom moduleNameMapper to handle CSS modules, a Babel transform config to stop Jest from choking on ESM packages, and a separate setupFiles array just to polyfill things that Vite handled automatically. Every time I added a dependency that shipped as ESM-only, I’d spend an afternoon figuring out which transformIgnorePatterns regex needed updating. That’s the actual reason I switched — not hype, not benchmarks. The maintenance burden had become a part-time job.
Getting Vitest running takes about two minutes if you’re already on Vite:
npm install -D vitest @vitest/ui jsdom
Then add this block to your existing vite.config.ts — no separate config file needed:
/// <reference types="vitest" />
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
That’s genuinely it. Vitest reuses your Vite config, which means all your existing aliases, plugins, and module resolution just work. The first time I ran npx vitest on a project that had been giving me grief with Jest, it passed on the first attempt with zero config changes beyond those six lines.
The watch mode speed difference is real and it’s noticeable. On a suite of ~400 tests, Jest’s watch mode was taking 8–12 seconds to re-run after a file change. Vitest’s equivalent is closer to 1–3 seconds on the same machine — it only re-runs tests affected by the changed module graph, and since it’s already running inside Vite’s dev server process, the module resolution is cached. The --ui flag gives you a browser-based dashboard at localhost:51204 that’s surprisingly useful for visualizing which tests failed and why, though I mostly stick to the terminal.
Here’s the gotcha that will bite you: the jsdom environment is not enabled by default. If you skip the environment: 'jsdom' line in your config, your tests run in Node. You won’t see a helpful error about jsdom being missing — you’ll just get ReferenceError: document is not defined on the first line that touches the DOM, and your first instinct will be to blame your component or your testing-library setup. I burned about 20 minutes on this before realizing the environment defaulted to node. You can also set it per-file with a comment at the top — // @vitest-environment jsdom — which is useful if most of your tests are pure logic and only a few need the DOM.
One honest caveat: don’t migrate to Vitest just for the speed if you’re not on Vite. If your project uses Webpack or Turbopack, you’d need to introduce Vite solely as a test runner, and the config overhead of maintaining two bundler setups completely erases the benefit. Vitest is the right call when it’s a zero-friction addition to a stack that already uses Vite — which describes most greenfield React projects in 2024. If you’re on Create React App or a custom Webpack setup, Jest with @swc/jest as the transformer is a better path to faster test runs without the architectural change.
2. Jest — Still the Default for a Reason, Even If I’m Moving Away
Jest still owns the majority of React codebases not because it’s the best tool anymore, but because it was the default tool for so long that it’s baked into muscle memory, CI configs, and onboarding docs across thousands of teams. If you’re on a large team and everyone already knows Jest, or you’re in a Nx or Turborepo monorepo where Jest presets are preconfigured and working — don’t touch it. The context-switching cost of migrating mid-project almost never pays off. I made the mistake of migrating a mid-sized codebase to Vitest six months in, and while the end result was faster, the two weeks of untangling config, updating mocks, and fixing subtle import differences absolutely was not worth it.
If you’re setting up Jest on a non-CRA React project, here’s the actual install command — and yes, all of this is required, it’s not optional fluff:
npm install -D jest babel-jest @babel/preset-env @babel/preset-react jest-environment-jsdom
Then your babel.config.js needs at minimum:
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
The part that catches people out — and this isn’t obvious until you run your first test and watch it blow up — is moduleNameMapper. CSS imports crash Jest because Node can’t parse them. SVGs crash it for the same reason. Path aliases like @/components are invisible to Jest unless you map them manually. Here’s the config block that handles all three at once:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
moduleNameMapper: {
// CSS modules
'\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
// Plain CSS
'\\.(css|sass|scss)$': '/__mocks__/styleMock.js',
// SVGs
'\\.svg$': '/__mocks__/svgMock.js',
// Path aliases — mirror your tsconfig.paths here
'^@/(.*)$': '/src/$1',
},
};
Your __mocks__/styleMock.js is just module.exports = {}; and svgMock.js exports a React component stub. You’ll also need npm install -D identity-obj-proxy for the CSS modules line. Every time you add a new file type or alias, you’re back in this config file. That’s the tax.
One concrete thing to check before upgrading: Jest 29 dropped Node 12 and Node 14 support. If your CI is running on an older Node image — say, a pinned node:14-alpine in your Dockerfile — upgrading Jest will silently break your pipeline or throw cryptic errors that don’t point at the real problem. Check your CI image version before you run npm install -D jest@29. I’ve seen this waste entire afternoons.
My honest take: the config overhead is real and I wouldn’t start a new greenfield project on Jest today. Vitest handles all of the above with near-zero config if you’re already using Vite, and the API is close enough to Jest that the learning curve is shallow. But if your project already has Jest wired up, tests passing, and a team that knows it — leave it alone. The framework isn’t your bottleneck.
3. React Testing Library — Not a Framework, But You’re Already Using It Wrong
RTL is not a test runner. I keep having to say this because half the job postings I see list “React Testing Library” alongside Jest as if they’re the same category of tool. RTL is an assertion and querying layer — it gives you utilities to find elements and interact with them. Jest or Vitest is what actually runs the tests and reports pass/fail. You can swap the runner; the RTL API stays the same. That’s why the migration from Jest to Vitest in a large codebase usually takes a day, not a week — almost nothing about your actual test logic changes.
Here’s the mistake your team is almost certainly making: querying by data-testid. I did it too. It feels safe because the selector is explicit and won’t break if someone renames a class. But it’s a false safety. getByTestId('submit-btn') will happily find a div with that attribute even if you accidentally removed the type="submit", made it non-focusable, or swapped it for a styled span. Your test is green. A keyboard user is now stuck. getByRole('button', { name: /submit/i }) queries the accessibility tree, not the DOM. If the element doesn’t have the right role and accessible name, the query fails — which is exactly what you want, because the UI is broken.
Walk through the difference concretely. Say you have a form and someone refactors the submit button to a <div onClick={handleSubmit}>Submit</div> because they wanted custom styling and didn’t think about semantics:
// This still passes. Your CI is green. Screen reader users are blocked.
const btn = screen.getByTestId('submit-btn');
fireEvent.click(btn);
expect(mockSubmit).toHaveBeenCalled();
// This fails immediately. You catch the regression before it ships.
const btn = screen.getByRole('button', { name: /submit/i });
userEvent.click(btn);
expect(mockSubmit).toHaveBeenCalled();
The first test doesn’t know or care that the element is no longer a button. The second one does, because ARIA roles are baked into the query. Reserve getByTestId for things that genuinely have no accessible role or label — custom canvas elements, third-party widgets you can’t control. Everywhere else, use role-based queries. The full priority order RTL recommends is: getByRole, then getByLabelText, then getByPlaceholderText, then getByText, then getByTestId as a last resort.
fireEvent vs userEvent is another place where the wrong choice produces tests that lie to you. fireEvent.click() dispatches a single synthetic click event. A real user clicking a button triggers pointerover, pointerenter, mouseover, mouseenter, pointermove, mousemove, pointerdown, mousedown, focus events, pointerup, mouseup, then finally click. If your component has any logic gated on those intermediate events — a tooltip that shows on hover, a button that only enables after mousedown, validation that fires on blur — fireEvent misses it entirely. Switch to @testing-library/user-event v14+, which rewrote the entire event simulation engine:
import userEvent from '@testing-library/user-event';
// v14+ setup — do this once per test or in beforeEach
const user = userEvent.setup();
it('submits the form', async () => {
render(<MyForm />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(mockSubmit).toHaveBeenCalledWith({ email: '[email protected]' });
});
Note the async/await. Every userEvent method in v14 returns a promise because the event simulation now runs through a proper queue with configurable delays. Use fireEvent only when you need to trigger a very specific low-level event and the full browser chain would actually get in your way. That situation is rare.
The act() warning is the one that makes junior devs wrap everything in act() until the console shuts up, which is exactly the wrong fix. The warning — “Warning: An update to X inside a test was not wrapped in act(…)” — means React processed a state update after your assertion already ran. There are two legitimate reasons to use act() manually. First, when you’re directly triggering a state update outside of RTL’s own utilities (RTL wraps its interactions in act() for you, but if you’re calling a store dispatch or timer manually, you need to). Second, when you’re using React.act to flush a specific batch of updates in a controlled way. If you’re seeing the warning with normal RTL interactions, the real problem is almost always that you have an async operation — a fetch, a setTimeout, a state update in a useEffect — that resolves after your test ends. The fix isn’t act(); it’s waiting for the UI to reflect the async result:
// Wrong: suppresses the warning but doesn't actually wait
await act(async () => {
fireEvent.click(submitButton);
});
// Right: wait for the outcome you actually care about
await user.click(submitButton);
await screen.findByText(/success/i); // findBy* is async and retries
findByRole, findByText, and the rest of the findBy* variants poll the DOM for up to 1000ms by default (configurable via waitForOptions). Use them whenever you’re waiting for something async to resolve. If you’re still getting act() warnings after that, look at what’s happening after your test function returns — unmounted component updates are the usual culprit, and the fix is cleaning up your async subscriptions in useEffect return functions, not wrapping tests in act().
4. Playwright — My Go-To for E2E Since I Stopped Trusting Cypress in CI
npm init playwright@latest
That single command scaffolds your entire Playwright setup — playwright.config.ts, an e2e/ folder with working example tests, and a .github/workflows/playwright.yml file ready to push. I’ve set up Cypress projects and it takes 20-30 minutes before you have CI running. With Playwright, I had a green GitHub Actions run in under 10 minutes the first time. The install experience is genuinely rare in JS tooling — it asks you four questions (browsers, TypeScript, test folder location) and then just works.
The feature I reach for almost every debugging session is page.pause(). Drop it anywhere in a test, run with npx playwright test --headed, and the browser freezes mid-execution with Playwright’s inspector attached. You can hover elements, click around, and copy selectors directly from the panel. Before I knew about this, I was doing what everyone does — running tests, watching them fail, squinting at screenshots, guessing. Now I stick page.pause() right before the failing line, poke the live DOM, and fix the locator in two minutes. It’s the closest E2E testing has gotten to actual debugging.
Codegen is the other time-saver I don’t see talked about enough. Run npx playwright codegen http://localhost:3000, click through a user flow in the browser, and Playwright writes the test code in real time in a side panel. The output isn’t production-ready — it leans heavily on getByText and sometimes generates brittle selectors — but it’s a solid first draft. I use it to generate the skeleton of a new test, then clean it up to use getByRole and getByTestId where it matters. Saves me the 10 minutes I’d spend writing locators from scratch while referencing the DOM inspector.
Here’s the rough edge I want to be upfront about: running tests across Chromium, Firefox, and WebKit in parallel is slow. On a mid-complexity React app with ~60 E2E tests, tripling the browser matrix can push CI from 4 minutes to 12+. My actual solution is gating Firefox and WebKit runs behind a separate workflow that only triggers on scheduled nightly runs or PR labels like pre-release. Chromium covers 95% of your coverage needs on every PR. The multi-browser value is real, but you need to make a deliberate call about when to pay the CI cost — don’t just accept the default config and wonder why CI bills went up.
The API I think most people sleep on is test.step(). Instead of a flat list of 15 assertions in a single test, you group them:
test('checkout flow', async ({ page }) => {
await test.step('add item to cart', async () => {
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
await test.step('complete payment', async () => {
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4242424242424242');
await expect(page.getByText('Payment confirmed')).toBeVisible();
});
});
The HTML report that Playwright generates will show each step separately — pass/fail, timing, screenshots attached to the exact step that failed. When a test breaks in CI, you don’t have to guess which of 15 assertions fired. You see “complete payment” failed at step 2, and the screenshot is right there. It’s a small API surface but it changes how readable your reports are at 2am when something breaks before a deploy.
5. Cypress — Still Worth It for Teams New to E2E Testing
The time-travel debugger is the single reason I still point certain teams toward Cypress. When a test fails, you get a step-by-step visual replay of every command — hover over any step in the left panel and the right panel snaps to exactly what the DOM looked like at that moment. Playwright’s trace viewer does something similar, but you have to deliberately enable it, know where to find the .zip artifact, and open it in a separate UI. For a QA engineer who codes twice a week, that friction kills adoption. Cypress surfaces the debugger by default, right there in the browser window, without any extra config. That difference matters more than people admit.
The component testing mode deserves more attention than it gets. Run cypress open --component and you’re mounting React components in a real Chromium instance — not jsdom, not a simulated DOM, an actual browser. That means CSS rendering, ResizeObserver, scroll behavior, canvas elements — all of it works exactly as it would in production. I’ve caught layout bugs this way that Jest + Testing Library missed entirely because jsdom doesn’t compute CSS. Your cypress/component config looks like this:
// cypress.config.ts
import { defineConfig } from 'cypress'
import react from '@vitejs/plugin-react'
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
},
})
Then your component test file:
// Button.cy.tsx
import { Button } from './Button'
it('renders disabled state correctly', () => {
cy.mount(<Button disabled label="Submit" />)
cy.get('button').should('be.disabled')
cy.get('button').should('have.css', 'opacity', '0.5')
})
That CSS assertion on opacity would silently pass in jsdom regardless of your actual styles. In Cypress component mode, it fails if your CSS isn’t applied — which is the correct behavior.
Now the stuff that will bite you. Cypress runs everything inside a single browser tab. That’s an architectural choice, not a bug they’re going to fix. If your app calls window.open() — OAuth flows, PDF previews, anything that spawns a new tab — you’re immediately in workaround territory. The usual hack is stubbing window.open and asserting on the URL it was called with, which tests nothing about what actually happens in that new tab. Multi-origin flows are also painful; Cypress historically blocked cross-origin navigation, and while experimentalOriginSupport improved things, Playwright handles multi-domain scenarios out of the box without any flags.
On pricing: Cypress Cloud (they rebranded from Dashboard) has a free tier, but it caps the number of recorded test results per month. The specific limit changes — I’ve seen it shift between plan updates — so check cypress.io/pricing directly before you design your CI strategy around it. If you have a large team running tests on every PR, you’ll hit that ceiling faster than you expect. Self-hosted recording isn’t officially supported the way it is with some alternatives, so you’re either paying or trimming which runs get recorded.
My actual decision rule: if your team has dedicated QA engineers who aren’t writing code full-time, pick Cypress. The visual debugger and the Cypress Studio UI (which lets you generate tests by clicking through your app) dramatically lower the barrier to writing useful tests for non-developers. If your team is all engineers comfortable reading async traces and configuring CI pipelines, Playwright will pay off faster despite the steeper initial setup. Cypress CI runs are genuinely slower — parallelization requires Cypress Cloud or significant custom infrastructure — so if build time is a constraint and your team can handle Playwright’s tooling, make that call early rather than migrating later.
Side-by-Side: Which Framework for Which Situation
Pick the wrong framework and you’ll spend more time fighting your test setup than writing tests. I’ve made that mistake — spent two days migrating a mid-sized project off Jest onto Vitest only to realize the team’s muscle memory and existing CI scripts were worth more than the speed gains. Don’t do that. Use this table as your first filter, then follow the decision tree below it.
| Framework | Test Type | Vite-Native | CI Speed (Relative) | Biggest Dealbreaker |
|---|---|---|---|---|
| Vitest | Unit / Integration | Yes | Fast | Not worth it without Vite |
| Jest | Unit / Integration | No | Moderate | Config overhead on modern toolchains |
| React Testing Library | Integration layer | Works with both | N/A | Misuse of selectors undermines test value |
| Playwright | E2E | Yes | Moderate (multi-browser) | Parallel browser matrix inflates CI time |
| Cypress | E2E / Component | Yes | Slower in CI | Multi-tab and cross-origin limitations |
A few things in that table need unpacking. The “Vite-Native” column isn’t about whether a framework runs with Vite — it’s about whether it shares Vite’s transform pipeline. Vitest does. That means your vite.config.ts aliases, environment variables, and plugins all work in tests without a separate Babel config. Jest doesn’t get that for free. You’ll end up writing something like this just to handle ESM modules in a Vite project:
// jest.config.ts
export default {
transform: {
"^.+\\.(t|j)sx?$": ["@swc/jest", {}],
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
};
That config works, but every alias you add to vite.config.ts needs a matching moduleNameMapper entry in Jest. It’s a maintenance tax you pay forever. Vitest eliminates it entirely by reading your Vite config directly. The thing that caught me off guard was how much time I was spending keeping those two configs in sync before I realized I didn’t have to.
The React Testing Library row deserves special attention because RTL is not a standalone runner — it’s a querying and rendering layer that sits on top of either Jest or Vitest. Its biggest dealbreaker isn’t a bug or a missing feature, it’s a usage pattern. Teams that write getByTestId('submit-button') everywhere are essentially writing implementation tests with a nicer API. The whole point of RTL is to query the DOM the way a user would — getByRole('button', { name: /submit/i }) — so your tests break when behavior changes, not when someone renames a CSS class or a data attribute. Get that wrong and RTL gives you false confidence at the cost of test maintenance.
For Playwright, the CI time concern is real but controllable. Running Chromium, Firefox, and WebKit in parallel on a standard GitHub Actions runner is slower than running a single browser, but you probably don’t need all three on every commit. A setup that’s worked well for me: run Chromium only on pull requests, run the full browser matrix nightly or before releases. You configure this with Playwright projects:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: process.env.CI_FULL_MATRIX
? [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
]
: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
Cypress’s multi-tab limitation is one of those things you hit after three weeks of investment, not on day one. If your app opens a new tab for OAuth or opens a PDF in a new window, Cypress cannot follow it. Full stop. Playwright handles this natively with context.waitForEvent('page'). If your app does anything cross-origin — like a third-party payment iframe or an SSO redirect to a separate domain — Cypress’s limitations will surface. That’s the conversation to have before you pick it, not after.
The Decision Tree, Made Explicit
- Starting a new project with Vite? Go Vitest + RTL + Playwright. Zero config friction, fast feedback loop, and Playwright covers your E2E without the cross-origin gotchas.
- Already running Jest on an established project? Stay on Jest + RTL and add Playwright specifically for E2E. Migrating to Vitest mid-project is almost never worth the disruption unless your Jest config has become genuinely painful. Treat it as a future “nice to do” item, not urgent work.
- Team brand new to testing? Jest + RTL + Cypress. Cypress’s interactive test runner and time-travel debugging make it the most forgiving E2E tool for people learning how to write meaningful browser tests. The visual feedback loop accelerates learning faster than staring at Playwright’s trace files during onboarding.
FAQ
Do I need both Jest and Vitest in the same project?
No. Running both in the same project is a sign something went wrong during a migration, not a deliberate architecture decision. I’ve seen codebases where someone added Vitest to a new package inside a monorepo while the root still used Jest — that’s the one legitimate exception. The mental model that helps: Vitest is essentially Jest’s API reimplemented on top of Vite’s module resolution. If your project already uses Vite (meaning you’re on a modern React setup with vite.config.ts), just use Vitest from day one. You get the same describe, it, expect surface area, but transforms run through Vite’s pipeline instead of Babel, which means no separate babel.config.js to maintain and noticeably faster cold starts on large test suites.
If you’re on Create React App or a webpack-based setup, Jest is the path of least resistance. Migrating to Vitest when you’re not on Vite means you’d also need to migrate your bundler, and that’s rarely worth the disruption mid-project. Pick one based on your bundler, not based on which one has more GitHub stars this month.
Why do my React Testing Library tests pass but the feature is still broken?
This one burned me badly on a form validation feature. The answer almost always comes down to testing implementation proximity instead of user behavior. RTL tests that query by data-testid and assert on state variables will pass even when the actual user flow is broken, because you’ve bypassed how a real person would interact with the UI. The library even has an eslint-plugin-testing-library rule that discourages getByTestId for exactly this reason.
Here’s the fix: force yourself to write tests the way a user would navigate your feature. Use getByRole, getByLabelText, and userEvent instead of fireEvent. The difference matters — fireEvent.click dispatches a synthetic event, while userEvent.click simulates the full browser sequence including focus, pointer events, and keyboard interactions. A broken focus trap that blocks accessibility will slip past fireEvent but get caught by userEvent. Install @testing-library/user-event version 14+ and use the async API:
import userEvent from '@testing-library/user-event'
test('submits the form', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText(/email/i), '[email protected]')
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
The other culprit is missing network mocks. If your component fetches data and you haven’t mocked the API, the test might pass because the component renders its loading state without errors — but the actual success path never gets exercised. Use msw (Mock Service Worker) to intercept real fetch/XHR calls at the network level. When you mock at the network layer rather than mocking your own modules, you catch broken API integrations that module mocks would hide entirely.
Playwright vs Cypress: which one runs faster in GitHub Actions?
Playwright wins on raw speed in CI, but the gap depends heavily on how you configure it. The key lever is parallelization. Playwright’s --shard flag lets you split your test suite across multiple GitHub Actions jobs without any paid plan:
# In your GitHub Actions matrix:
strategy:
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
# Then in your step:
npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
Cypress parallelization through their Cypress Cloud service requires a paid plan once you exceed a certain usage threshold — check their current pricing at cypress.io/pricing because it changes. You can run Cypress in parallel by managing your own test splitting with cypress-parallel or a custom matrix, but it’s more setup than Playwright’s built-in sharding. The thing that caught me off guard with Playwright in CI was the Docker image size — the mcr.microsoft.com/playwright images are large. Use --only-shell combined with npx playwright install --with-deps chromium to only download the Chromium browser if you’re trying to keep your CI layer cache lean. That alone can shave a meaningful chunk off your job startup time.
Can I use React Testing Library without Jest?
Yes, and it works cleanly with Vitest. RTL has no hard dependency on Jest — it just needs a DOM environment and an assertion library. With Vitest, you point it at jsdom in your config and import the jest-dom matchers explicitly:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
},
})
// src/test-setup.ts
import '@testing-library/jest-dom'
The matchers like toBeInTheDocument() and toHaveValue() all work exactly the same way. You can also use RTL with Mocha or even with plain Node test runner if you set up a DOM environment via happy-dom or jsdom manually — but at that point you’re doing more plumbing than it’s worth unless you have a specific constraint against Jest/Vitest. For almost every React project I’d say the practical answer is: RTL plus Vitest if you’re on Vite, RTL plus Jest if you’re not. Don’t overthink it.