Transitioning Your Testing Framework: Jest to Vitest

Okan AslanOkan Aslan
5 min read

In backend development, testing is more than just a best practice; it's a safeguard for stability, data integrity, and system resilience. While unit tests help catch issues in isolated functions, they often fall short in revealing real world integration problems that arise when multiple components interact. This is where end-to-end (E2E) testing shines. It not only validates logic, but checks behavior across authentication, APIs, databases, and even external services.

At Space Runners, we’ve embraced comprehensive end-to-end testing as a pillar of our engineering culture. Our E2E test suite covers over 90% of the backend logic, helping us catch regressions early, ensure contract stability, and build with confidence. But as our systems and team scaled, so did the need for faster, more efficient testing infrastructure without compromising on coverage or reliability.

Why We Considered a Change

While our test suite was comprehensive, it came at a cost: our CI pipeline was taking nearly 15 minutes to complete. This delay significantly slowed down our development feedback loop, especially painful during hotfixes or urgent releases. We found ourselves either waiting unproductively for builds to finish or, worse, skipping tests to save time. Neither option was sustainable.

After evaluating alternatives, we decided to migrate our backend tests from Jest to Vitest. The transition was appealing for several reasons: Vitest shares a nearly identical API with Jest, making it easy to swap out without massive refactoring. It's also built on top of the Vite ecosystem, which continues to gain traction in the frontend and backend communities alike. With strong community adoption, active development, and detailed migration guides, Vitest provided a smooth and well supported path forward.

Our CI pipeline durations when we were using Jest which is almost 15 minutes

Configuring Vitest for our Stack

Getting Vitest up and running in our backend wasn’t entirely plug-and-play, but the process was manageable thanks to our existing setup. Since we were already running Jest with SWC, we had most of the necessary infrastructure in place. The primary additions were a new vitest.config.ts file and an update to our tsconfig.json to include Vitest’s global types via "types": ["vitest/globals"].

{
  "compilerOptions": {
    ...
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    ...
    "types": ["vitest/globals"]
  }
}

By default, Vitest uses ESBuild to transform code. However, because our NestJS + TypeORM backend relies heavily on metadata (especially for decorators), we needed a transformer that supports it properly. ESBuild lacks full metadata support, so we opted to use SWC instead. The unplugin-swc package made this integration straightforward, allowing us to plug SWC into Vitest without needing to change our existing build pipeline.

// vitest.config.ts
import swc from 'unplugin-swc'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: true
      }
    },
  },
  plugins: [swc.vite()]
})

To ensure correct metadata handling, our .swcrc configuration includes "legacyDecorator": true and "decoratorMetadata": true. These flags are essential in projects that use TypeORM entities and decorators, like ours.

// .swcrc
{
  "$schema": "https://swc.rs/schema.json",
  "sourceMaps": true,
  "jsc": {
    "parser": {
      "syntax": "typescript",
      "decorators": true,
      "dynamicImport": true
    },
    "transform": {
      "legacyDecorator": true,
      "decoratorMetadata": true
    },
  },
}

Lastly, due to some setup constraints with our current test structure, we opted to run tests in a single-threaded pool. While Vitest supports parallel test execution, we deferred that optimization to avoid introducing flaky test behavior before stabilizing the new setup.

Challenges in the Migration

At first glance, migrating from Jest to Vitest looked relatively simple. Both frameworks share nearly identical testing APIs. However, once we began running tests in our actual backend environment, deeper issues started to surface. The core challenges came down to the specifics of how Vitest handles module resolution, metadata, and ESM mocking.

1. TypeORM Compatibility

Our backend is built on NestJS and TypeORM, which heavily depend on decorators and runtime metadata. This became one of the more stubborn issues in the migration. When configuring TypeORM in tests, our original approach was to pass file path patterns (e.g., **/*.entity.ts) to define the entity list. However, Vitest’s transformation layer struggled to preserve metadata information when resolving those files dynamically.

To resolve this, we switched to importing every entity manually and passing them as an array to the TypeORM configuration. While this is slightly more verbose and rigid, it ensured that decorator metadata was preserved and the ORM could correctly reflect on the entities. We also tried referencing compiled JavaScript paths, but this too resulted in missing metadata issues.

One caveat with this approach is that every entity must be explicitly included. Missing even one results in confusing errors or unexpected runtime failures. We’ve since made this part of our setup more maintainable by centralizing entity imports in a shared module.

2. Mocking ESM Modules

Another tricky area was mocking third-party ESM modules. In our case, Vitest wasn’t reliably able to mock certain ESM-only packages, like potrace. Direct mocks would either fail silently or break at runtime.

Our workaround was to introduce internal wrapper functions around these modules. Instead of importing potrace directly in our tests or application logic, we created utility helpers that internally used potrace, and then mocked those helpers in tests. This added a small layer of indirection, but it allowed us to keep tests deterministic and compatible with Vitest’s mocking system. Thankfully, the number of affected modules was small, and we were able to refactor them without much disruption.

Results of the Migration

Despite some initial hiccups during the migration, the results have been undeniably worth it. Our CI runtime dropped from 15 minutes to just 4, and local test runs now complete in around one minute. This dramatic improvement in speed has made testing a frictionless part of development. We are no longer hesitant to run the full test suite locally, and urgent deployments or hotfixes are no longer bottlenecked by slow CI feedback.

Another key benefit was test monitoring support. Vitest integrates smoothly with tools that allow us to track test performance over time and gather insights on memory leaks and coverage changes. This gives us visibility we previously lacked, helping us proactively improve test quality.

Our current CI pipeline durations with Vitest which is under 5 minutes

What’s Next?

The migration from Jest to Vitest was absolutely worth it. Although, there were technical hurdles mainly around TypeORM and ESBuild quirks the performance gains and improved developer experience have made a measurable difference. Our tests are faster, lighter, and better integrated with modern tooling.

We’re now exploring parallel test execution, which could further reduce CI times. However, running E2E tests in parallel introduces complexity, particularly around database state isolation. Solving these challenges is our next focus as we continue to scale our infrastructure.

0
Subscribe to my newsletter

Read articles from Okan Aslan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Okan Aslan
Okan Aslan