Vitest takes its transform from Vite, and Vite uses esbuild, which strips types on the fly and turns TypeScript and JSX into runnable JavaScript. So running TypeScript tests needs neither a separate ts-jest nor a manual Babel setup: the code runs through the same pipeline as the application. This removes a whole class of divergences between how a module is built for production and how it is built for a test. On large projects it is also noticeably faster: esbuild transforms an order of magnitude quicker than a full compiler, and you run type checking as a separate step anyway.
It is important to understand the boundary: stripping types is not type checking. esbuild throws away annotations without verifying them, for speed. So a test with a type error - a nonexistent field, a wrong signature - may well pass green, because type checking simply never happens. The runner is responsible for runtime behavior, not for type correctness.
Hence a simple separation of duties: type checking goes into a separate tsc --noEmit step placed in CI next to the tests. Running tests does not replace the compiler, and the compiler does not run tests - they are two independent guards. In package.json it helps to keep test, test:run and typecheck as separate scripts, so the pipeline calls each explicitly.
Vitest understands ESM natively. With type: module in package.json, tests and code use import and export with no CommonJS wrappers and no require. This is the same module format as in a modern application, so import behavior, execution order and tree-shaking match production rather than being approximated.
The key convenience is that the same Vite plugin that transforms production code is available to tests too. Aliases, tsconfig paths (via vite-tsconfig-paths), JSX and the project's virtual modules work in tests with no second configuration. You do not have to describe and sync two sets of rules - the transform source is one. If a plugin declares a virtual module or an asset replacement, tests see them exactly as the application does, and there is no need to redefine them a second time.
A single source of truth for the build means fewer surprises. If the application is built by a specific plugin, tests see exactly the same result of its work. The classic 'works in tests, breaks in production' is most often born precisely from two different transform configs that drifted apart over time; the Vite-native approach closes that crack by construction.
In practice, keep one vitest.config that inherits or reuses the application's Vite config, and move typecheck into a separate script. Do not duplicate the transform in the test configuration and do not add a parallel compiler for tests - that is exactly the extra configuration that later diverges from the main one.
The typical failure is taking green tests as proof that the types are fine. They do not prove it: with broken types the suite stays green, and the error surfaces later in the build or in production. Add tsc --noEmit to the pipeline, and where possible to pre-commit, so a signature regression is caught before, not after, the merge.
{
"type": "module",
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"typecheck": "tsc --noEmit"
}
}import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'
// The same transform and aliases as the application.
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: 'node' },
})