DEV Community

Cover image for Solved: How to not require “.js” extension when writing vitest tests?
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: How to not require “.js” extension when writing vitest tests?

🚀 Executive Summary

TL;DR: Vitest tests often fail due to Node.js ESM requiring explicit “.js” extensions for local imports, causing MODULE_NOT_FOUND errors. This can be resolved by configuring package.json for ESM, tsconfig.json with nodenext or bundler module resolution, or vite.config.ts with resolve.extensions to allow extensionless imports.

🎯 Key Takeaways

  • Node.js ESM’s default behavior requires explicit file extensions (e.g., .js, .ts) for local imports, leading to MODULE\_NOT\_FOUND errors in Vitest tests if omitted.
  • The most robust solution involves declaring ”type”: “module” in package.json and setting ”module”: “nodenext” and ”moduleResolution”: “nodenext” in tsconfig.json for holistic modern ESM compatibility.
  • A targeted fix for Vitest is to configure resolve.extensions in vite.config.ts or vitest.config.ts, explicitly listing extensions Vite should try for module resolution.
  • For bundler-centric projects, setting ”moduleResolution”: “bundler” in tsconfig.json aligns TypeScript’s import understanding with how bundlers typically resolve modules, often combined with ”allowImportingTsExtensions”: true.
  • The optimal solution depends on project context: full ESM for new projects, resolve.extensions for quick Vitest fixes, or bundler resolution for web applications heavily relying on a bundler.

Navigating module resolution in modern JavaScript and TypeScript projects can be a headache, especially when tooling like Vitest adds another layer. If you’re tired of manually appending .js to every local import in your Vitest tests, this guide is for you. We’ll explore the common causes and provide actionable solutions to streamline your development workflow.

Problem Symptoms: The “.js” Extension Frustration

You’re writing modern JavaScript or TypeScript, aiming for clean, extensionless imports. However, when you run your Vitest tests, you encounter errors that look something like this:

  • Error: Cannot find module './my-utility' imported from '.../src/test.ts'
  • MODULE_NOT_FOUND when trying to import a local file without its .js or .ts extension.
  • Vitest tests fail unless you explicitly change import { foo } from './my-module' to import { foo } from './my-module.js' or import { foo } from './my-module.ts'.

This problem primarily stems from how Node.js resolves ESM modules, requiring explicit extensions for local file imports. While bundlers often abstract this away for web applications, testing environments (like Vitest, which runs on Node.js) need careful configuration to achieve that same seamless experience.

Solution 1: Embracing Modern ESM with Node.js & TypeScript (nodenext / node16)

The most robust and recommended long-term solution is to fully align your project with modern Node.js ESM practices. This involves configuring your package.json and tsconfig.json to tell Node.js and TypeScript exactly how to handle modules.

Concept

By declaring your project as an ESM module in package.json and instructing TypeScript to use Node.js’s native ESM resolution (nodenext or node16), you create a consistent environment where extensionless imports often resolve as expected. Vitest, leveraging this environment, benefits directly.

Configuration Example

package.json:

{
  "name": "my-project",
  "version": "1.0.0",
  "type": "module",  // Crucial: Declares the package as an ES module
  "scripts": {
    "test": "vitest"
  },
  "devDependencies": {
    "vitest": "^1.0.0",
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "nodenext",           // Or "node16": Tells TypeScript to emit ESM compatible with Node.js
    "moduleResolution": "nodenext", // Or "node16": Tells TypeScript to resolve modules like Node.js ESM
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "lib": ["es2020", "dom"],
    "jsx": "react-jsx",
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "vitest.config.ts"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • "type": "module" in package.json: This is the fundamental switch that tells Node.js (and by extension, Vitest) to treat .js files as ES modules by default. It changes how Node.js resolves imports, making it more aligned with browser-like ESM behavior.
  • "module": "nodenext" / "node16" and "moduleResolution": "nodenext" / "node16" in tsconfig.json: These settings instruct the TypeScript compiler to produce ESM output that conforms to Node.js’s native module resolution algorithm. This means TypeScript will understand that import 'my-module' should look for my-module.js, my-module/index.js, etc., and apply Node.js’s specific rules for path resolution, including handling imports without explicit extensions for packages (but still requiring them for local files unless resolve.extensions is used). However, when combined with type: “module”, the module resolution becomes more consistent.

Caveats

  • Transitioning to full ESM can be complex if your project heavily relies on CommonJS (CJS) dependencies or has a mixed codebase. You might need to use dynamic import() for CJS modules or use tools like esm for mixed environments.
  • This solution affects your entire project, not just Vitest. Ensure all parts of your application are compatible with ESM.

Solution 2: Direct Vite/Vitest Resolver Configuration

If a full ESM migration (Solution 1) is not immediately feasible or desired, you can directly instruct Vite (which Vitest uses) on how to resolve file extensions. This is a more targeted approach specifically for your testing environment.

Concept

Vite provides a resolve.extensions option in its configuration. By explicitly listing the extensions Vite should try when an import path doesn’t specify one, you can allow extensionless imports within your Vitest tests.

Configuration Example

vite.config.ts or vitest.config.ts:

// vite.config.ts or vitest.config.ts
import { defineConfig } from 'vite';
import { configDefaults } from 'vitest/config'; // Import if creating vitest.config.ts

export default defineConfig({
  resolve: {
    // Specify the extensions to be tried for modules without a file extension.
    // Order matters: Vite will try these in order.
    extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
  },
  test: { // Vitest specific configuration (if using vitest.config.ts)
    environment: 'jsdom', // or 'node'
    globals: true,
    exclude: [...configDefaults.exclude, 'e2e/*'],
    // ... other Vitest configurations
  },
});
Enter fullscreen mode Exit fullscreen mode

Explanation

  • resolve.extensions: This array tells Vite’s resolver to automatically append these extensions (in order) when it encounters an import path without a file extension. For example, import { func } from './utils' will first try to resolve to ./utils.mjs, then ./utils.js, ./utils.ts, and so on.
  • This configuration is specific to Vite’s bundler and development server, meaning it directly impacts how Vitest resolves modules during test execution.

Caveats

  • This solution primarily addresses module resolution within Vite/Vitest. It doesn’t necessarily fix issues if you have other tooling (like a separate TypeScript compilation step without a bundler) that might still require explicit extensions.
  • Ensure the order of extensions in the array matches the common extensions in your project to optimize resolution performance.

Solution 3: Bundler-Oriented TypeScript Configuration (moduleResolution: “bundler”)

For projects tightly coupled with a bundler (like Vite for web applications), configuring TypeScript to emulate a bundler’s module resolution can be highly effective. This approach is distinct from Node.js native ESM (nodenext) as it focuses on the bundler’s perspective.

Concept

When TypeScript’s moduleResolution is set to "bundler", it interprets imports similarly to how popular bundlers (like Webpack, Rollup, Vite) would. Bundlers are generally more forgiving about extensionless imports and often handle them seamlessly. Combining this with allowImportingTsExtensions further streamlines the development experience for TypeScript files.

Configuration Example

tsconfig.json:

{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",                   // Target latest ES modules syntax
    "moduleResolution": "bundler",        // Key: Emulate bundler's module resolution
    "allowImportingTsExtensions": true,   // Allows imports like 'import { foo } from "./bar.ts";'
    "noEmit": true,                       // Often used with bundlers, as they handle compilation
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "lib": ["es2020", "dom"],
    "jsx": "react-jsx",
    "rootDir": "src"
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "vitest.config.ts"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • "moduleResolution": "bundler": This is a powerful setting that tells TypeScript to use a resolution strategy that mirrors how bundlers typically operate. Bundlers are designed to find modules efficiently across various extensions and path aliases without strict adherence to Node.js’s specific ESM resolution rules, making extensionless imports more likely to succeed.
  • "allowImportingTsExtensions": true: This allows you to import .ts files directly using their .ts extension in your source code (e.g., import { util } from './util.ts'). While not directly related to *removing* the extension, it’s a common companion for bundler setups, as the bundler itself (or Vite/Vitest) will transpile these. In practice, with moduleResolution: "bundler", you can often still omit the .ts extension for local TypeScript files, and the bundler resolution will pick them up.
  • "noEmit": true: Often used in projects where a bundler (like Vite) handles the actual compilation and bundling, meaning tsc itself doesn’t need to output JavaScript files.

Caveats

  • This solution is most effective for projects where a bundler is the primary method of producing deployable code. If your project involves direct Node.js execution of compiled TypeScript output without a bundler, nodenext/node16 might be more suitable.
  • While moduleResolution: "bundler" is permissive, ensuring your vite.config.ts also has appropriate resolve.extensions (as in Solution 2) can provide an additional layer of robustness.

Comparison Table: Choosing the Right Solution

Feature Solution 1: Node.js ESM & nodenext Solution 2: Vite/Vitest resolve.extensions Solution 3: TypeScript bundler Resolution
Primary Focus Holistic modern ESM compatibility across Node.js and TypeScript. Directly configures how Vite/Vitest resolves file extensions. Aligns TypeScript resolution with common bundler behavior.
Impact Scope Entire Node.js project (runtime & tooling). Primarily affects Vite/Vitest’s module resolution. Primarily affects TypeScript’s understanding of imports and bundler integration.
Configuration Files package.json, tsconfig.json vite.config.ts or vitest.config.ts tsconfig.json
Best For New ESM-first Node.js projects, libraries, or applications aiming for future compatibility. Quick fix for Vitest tests, or when package.json type cannot be changed easily. Web applications primarily using a bundler (like Vite) and TypeScript.
Potential Side Effects May require adapting CJS dependencies; stricter Node.js environment. Only addresses Vitest resolution, not other tooling (e.g., standalone tsc compilation). Often used with noEmit: true, relying on bundler for output. Less ideal for direct node execution of TS output.
Ease of Implementation Moderate (can involve wider project changes). Easy (focused configuration). Moderate (requires understanding of bundler interaction).

Conclusion

The choice of solution depends largely on your project’s architecture and future goals. For greenfield projects or those ready to fully embrace modern JavaScript, Solution 1 (Node.js ESM & nodenext) offers the most holistic and future-proof approach. If you need a quick, targeted fix for Vitest tests without broader project changes, Solution 2 (Vite/Vitest resolve.extensions) is your best bet.

Finally, for web applications where a bundler is central to your workflow, Solution 3 (TypeScript bundler Resolution) provides an elegant way to harmonize TypeScript’s understanding of modules with your bundler’s capabilities. Often, a combination of these solutions (e.g., Solution 1 and 2, or Solution 3 and 2) yields the most robust results. By carefully configuring your module resolution, you can eliminate the frustrating .js extension requirement and enjoy a smoother development experience.


Darian Vance

👉 Read the original article on TechResolve.blog

Top comments (0)