Cypress vs Vitest: Which Defects Each One Catches
A browser runner and a Node runner, scored against seven defects this codebase actually shipped. Measured: 391 tests in 4.40s, and three bugs neither caught.
Cypress and Vitest are not the same kind of tool. Vitest is a Node-process test runner — it imports your modules and asserts on their return values, in milliseconds. Cypress is a browser runner — it boots your app in Chrome and drives it like a user, in seconds per test. The honest question is not which is better but which defect classes you are buying, and what each costs to keep green. This storefront runs Vitest only: 391 tests across 31 files in 4.40 seconds, and zero browser tests.
The short answer
| Vitest | Cypress | |
|---|---|---|
| Runs in | Node (optionally jsdom/happy-dom) | A real browser |
| Natural unit | A function, a module | A page, a user flow |
| Sees | Return values, thrown errors, state | Layout, CSS, real events, cookies, network |
| Speed here | 391 tests / 4.40s total | Seconds per test, plus app boot |
| CI install | One dev dependency | Browser binaries + the app running |
| Config | vitest.config.ts, 11 lines | Config, baseUrl, fixtures, CI service |
| Flake surface | Fake timers, module mocks | Timing, animation, network, viewport |
| Debugging | Stack trace at the failing call | Time-travel snapshots, video, DOM at each step |
| Component tests | Yes, with a DOM environment | Yes, in a real browser |
| What it cannot see | Anything rendering- or browser-dependent | Anything with no UI path to it |
They are complements, and if you only read one row read the last: each is blind to exactly what the other is for.
What 391 Node tests actually cover
vitest.config.ts is the whole configuration, and what it leaves out matters more than what it sets:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
test: { include: ["src/**/__tests__/**/*.test.ts"] },
});
No environment: "jsdom". No setup file. No @testing-library/react. The suite is 31 files in three places:
| Where | Files | What it asserts |
|---|---|---|
src/data/__tests__/ | 5 | Registry invariants — catalog, blog, hub coverage, the keyword pipeline |
src/lib/__tests__/ | 23 | Entitlements, download authorization, webhook signatures, refund eligibility, filters, error hygiene |
src/lib/release/__tests__/ | 3 | Version manifest and repo mapping for the release pipeline |
Test Files 31 passed (31)
Tests 391 passed (391)
Duration 4.40s (transform 2.65s, setup 0ms, import 4.02s, tests 1.54s)
setup 0ms is the tell. Nothing boots. The 1.54s of actual test time is spent on things a browser cannot check anyway: whether a slot kind covers a download target, whether a webhook HMAC validates, whether every blog post has a registered MDX loader. What those tests assert is its own post; this one is about the boundary.
Fakes where Cypress would use the real thing
The webhook suite is the clearest contrast. A Cypress test for "a paid order grants an entitlement slot" would post a signed payload at a running app and then assert on the dashboard. The Vitest version implements the database contract instead:
// src/lib/__tests__/webhook.test.ts
class FakeWebhookDb implements WebhookDb {
orders = new Map<string, { id: string; status: "paid" | "refunded"; userId: string | null }>();
slots: { id: string; orderId: string; kind: SlotKind; /* … */ }[] = [];
async createOrderWithSlots(order: NewOrderInput, slotKinds: string[]) {
const existing = this.orders.get(order.lsOrderId);
if (existing) return { orderId: existing.id, created: false };
// ...
}
}
implements WebhookDb means the fake stops compiling the day the real interface changes. The trade is explicit: these tests prove the decision logic — idempotent replays, foreign-key fallback, refund re-revocation — and prove nothing about whether the route is wired up in production. They run in milliseconds and never flake.
The defects this codebase actually shipped, scored
This is the part a feature table cannot give you. Every row is a real defect from this repo's history, and which runner would have caught it:
| Defect that shipped | Vitest | Cypress | What found it |
|---|---|---|---|
| Blog post added to the registry, loader forgotten | Yes | No | A registry test (it is npm test's job) |
| A product page listed on no category hub — 38 pages uncrawled | Yes | No | An invariant test added after Search Console |
aria-live message node mounted with its first message, so nothing was announced | No | Partly | Accessibility review; a browser test asserting text would still pass |
Hover overlay left its links tabbable while invisible | No | Yes | Manual keyboard pass — a focus-order assertion is exactly Cypress's shape |
/blog shipped with no <h1>, /docs skipped h1 → h3 | No | Partly | Lighthouse, not a test runner |
First card of a grid left loading="lazy", ~1.7s of LCP delay | No | No | Lighthouse |
A sitemap lastModified taken from the build clock | Yes | No | A test asserting every date is midnight UTC |
The pattern: data and authorization defects are Vitest's; interaction and focus defects are Cypress's; rendering and performance defects are neither's. Three of the seven rows were caught by a tool that is not a test runner at all, which is the argument against treating "we have tests" as coverage.
What a Cypress suite would cost here, honestly
Three things change the day you add one. CI has to install browser binaries and boot the app, so a 4.40s gate becomes a multi-minute one. Someone owns flake — animations, network timing, viewport differences — and a suite nobody trusts gets skipped, which is worse than not having it. And this site's riskiest flows (checkout, gated downloads, entitlement redemption) touch a payment provider and a private storage bucket, so a browser test needs a seeded test account and fixtures to be honest.
That calculus flips the moment a flow exists that can break without any function failing: a form that posts to the wrong action, a button hidden behind a transparent overlay, a logged-out user reaching a page a layout should have guarded. Those are invisible to 391 Node tests and obvious to one Cypress spec.
Picking, per repository
Pick Vitest if your risk is a wrong answer: pricing logic, entitlements, parsers, data registries, anything where the function is the product. It is also the only one of the two you can reasonably run on every save.
Pick Cypress if your risk is a broken path: multi-step checkout, auth flows, dashboards assembled from several services. Write few tests and make them the ones a human would notice immediately.
Run both if you have both risks — the usual answer for a real app. They do not overlap enough to be redundant, and nothing forces the browser suite to run on every push: a smoke set per deploy is a defensible middle. For the browser-automation half that is not testing, see Playwright vs. Vitest — this repo drives a real Chromium for release screenshots and makes no assertions in it at all. And if the pairing you are actually weighing is a runner against a component workshop, Cypress vs. Storybook is the comparison for that.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Vitest finds no tests | File is outside the include glob | Match the pattern (here: src/**/__tests__/**/*.test.ts) |
document is not defined in Vitest | No DOM environment configured | Set environment: "jsdom" for those files, or test the logic instead |
Cannot find module 'server-only' | A server module imported in a Node test | vi.mock("server-only", () => ({})), as the webhook tests do |
| Cypress test passes locally, fails in CI | Viewport, animation, or an unseeded database | Pin the viewport, disable animations, seed fixtures before each spec |
| Cypress "element not visible" on a visible element | Asserting during a transition | Assert on the post-transition state, not a timeout |
| Both suites green, the page still looks broken | Neither tool checks rendering or performance | Add Lighthouse or visual diffing to CI |
| Vitest slow on a big suite | Transform cost, not test cost | Read the breakdown — here transform is 2.65s of the 4.40s |
Frequently asked questions
Can Cypress replace Vitest? In principle, and you would not want it to. Pushing unit assertions through a browser multiplies every test's runtime by three orders of magnitude for no extra coverage of the logic itself.
Can Vitest do component tests like Cypress?
Yes, with a DOM environment and a testing library — and jsdom is an approximation. It has no layout engine, so it cannot tell you an element is covered, off-screen, or invisible but focusable. That class of bug needs a real browser.
Which is faster in CI? Vitest, by a wide margin: no browser download, no app boot. The 391 tests here finish in 4.40 seconds; the same count of browser specs would be minutes at best.
Do I need both for a static marketing site? Usually not. A static site's failure modes are content and rendering, so a build that type-checks, a small invariant suite, and Lighthouse cover more ground than a browser runner would. This codebase is exactly that case.
Templates in this post
ASoc Catalyst (an AI-automation agency site), ASoc Chain (a DeFi protocol landing page) and ASoc Cognition (an AI-consulting agency site) each ship a Next.js edition with the same typed-data shape the invariant tests above run against.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
