skip to content

Why does a k6 script's import of './helpers' fail when './helpers.js' works?

level: juniorimportance: must knowfreq 72%

answer

  1. browser-like, not Node-like
  2. the loader guesses nothing
  3. no index.js, no node_modules walk
  4. write the suffix yourself

basics

~20 s

k6 resolves import specifiers the way a browser does, not the way Node does: it never appends .js, never falls back to index.js and never searches node_modules. The extension-less path names a file that does not exist, so the load fails.

solid answer

~40 s

k6 has its own loader and does not implement the Node.js module resolution algorithm. A specifier starting with `.` or `/` is joined onto the importing file's URL and read from disk **exactly as written** — no extension probing, no `index.js` fallback for a directory, no walk up through `node_modules`. So `./helpers` resolves to a path with no extension, nothing is there, and k6 aborts before the test starts with `The moduleSpecifier "./helpers" couldn't be found on local disk.` The k6 docs call this browser-like module resolution and state that file names for imports must be fully specified. The same applies to TypeScript: k6 transpiles with esbuild purely on the `.ts` filename suffix, so that extension has to be written out too.

code

javascript · 6 lines
javascript
// tests/smoke.js -- helpers.js sits in the same directory
import { login } from './helpers.js';

export default function () {
  login();
}

go deeper

for a junior

Remember the one-line rule: in k6 you always write the file extension, and relative paths start with ./ or ../. If an import fails, read whether the message says the file was not found or the specifier was not recognised.

for a middle

Be able to explain the loader's branches: k6-prefixed names go to the built-in registry, dot or slash means a literal file path, :// means a URL, and everything else is refused. Say plainly that there is no node_modules lookup.

for a senior

Show that you diagnose from the exact error text, and that you know the fix for npm dependencies is a bundler that emits one self-contained file. Mention that this failure happens at load time, before any virtual user starts.

for a principal

The tradeoff is whether the team accepts a bundling step at all. Vendoring plain files keeps k6 runs dependency-free and debuggable; a bundler unlocks the npm ecosystem but adds a build artefact that must be versioned and kept in step with the tests.

## What k6 does with a module specifier Every `import` and every `require()` in a k6 script hands a string — the **module specifier** — to k6's own resolution step, whose only job is to turn that string into a module or a URL. In k6 v2 that step has exactly four outcomes, and only one of them ever touches your filesystem: - a specifier that is `k6` or begins with `k6/` goes to the built-in and extension module registry and never reaches disk at all; - a specifier whose first character is `.` or `/` (or a Windows drive letter such as `C:`) is treated as a **file path**; - a specifier containing `://` is treated as a **URL**, and only the `file` and `https` schemes are accepted; - anything else is refused outright with `The moduleSpecifier "lodash" couldn't be recognised as something k6 supports.` The file branch is deliberately unclever. k6 makes sure the importing module's URL ends in a slash, joins your specifier onto it as a URL, and reads that resulting path byte-for-byte off disk. There is no candidate list, no probing loop, no directory scan. That is what the k6 documentation means when it says k6 adopts **browser-like module resolution** and that file names for imports must be fully specified, such as `./helpers.js`. ## Why the missing suffix is fatal `./helpers` is a perfectly valid specifier, so the first stage succeeds: it resolves to something like `file:///srv/k6/tests/helpers`. The second stage then tries to read that exact path, finds nothing, and returns k6's local-disk error: ``` The moduleSpecifier "./helpers" couldn't be found on local disk. Make sure that you've specified the right path to the file. ``` This happens while k6 is loading the main module, before a single virtual user or iteration exists, so the run never starts. The message continues with a hint about mounting volumes when running in the Docker image, which is the other common cause of the same error. ## What Node does that k6 does not | Node.js resolution step | k6 v2 | |---|---| | Append `.js`, `.json`, `.node` to a path with no suffix | Never | | Fall back to `<dir>/index.js` when the path is a directory | Never | | Read `main` or `exports` from a nearby `package.json` | Never | | Walk up parent directories looking inside `node_modules` | Never | | Resolve a bare name such as `lodash` to an installed package | Never | There is no `node_modules` code path anywhere in k6, and no `package.json` is ever consulted. That is why installing a package with npm next to your script changes nothing: k6 cannot see it. The supported way to use an npm dependency is to run a bundler such as webpack or rollup and import the single self-contained file it emits. ## Two different failures that look alike Beginners often conflate two errors that come from different branches of the loader: 1. **`./helpers`** reaches the file branch and fails at read time, reporting `couldn't be found on local disk`. The fix is to write the suffix. 2. **`helpers`** or **`lodash`** never reaches the file branch, because the specifier does not start with a dot or a slash. It fails at resolve time, reporting `couldn't be recognised as something k6 supports`. The fix is to write a relative path, an absolute path, or an `https://` URL. Reading which of the two messages you got tells you immediately whether the problem is a typo in a path or a mistaken belief that k6 installs packages. ## Consequences for a shared helper file Say `smoke.js`, `load.js` and `soak.js` all pull in one `helpers.js`. Every one of the three has to spell the suffix out: - `import { login } from './helpers.js';` - `import { login } from '../lib/helpers.js';` - `import { login } from '/srv/k6/lib/helpers.js';` All three forms are legal. An absolute path works but ties the script to one machine's layout; k6 warns on Windows that an absolute import is not cross-platform and will not survive being moved or run with `k6 cloud`, and recommends the `file://` form if you insist on one. ## TypeScript does not relax the rule k6 transpiles with esbuild for files whose name ends in `.ts`, and that decision is made from the filename alone. A TypeScript helper is therefore imported as `./helpers.ts`. k6 will not try `.ts` and then `.js` for a bare `./helpers`, because there is still no probing anywhere in the loader — only the literal path you supplied.

  • Does k6 resolve a directory import such as './lib' to './lib/index.js'?
    No. k6 has no directory or index resolution at all, so `./lib` is treated as a filename and the read fails. Point the specifier at the file itself, for example `./lib/helpers.js`. The only specifiers k6 special-cases are `k6` and anything under `k6/`, which go to the built-in and extension registry instead of the filesystem.
  • What error does a bare specifier such as 'lodash' produce instead?
    A different one: `The moduleSpecifier "lodash" couldn't be recognised as something k6 supports.` The file branch is never entered, because k6 only treats a specifier as a path when it begins with a dot, a slash or a drive letter, and only treats it as remote when it contains `://`.
  • If npm installed the package next to my k6 script, why can k6 still not import it?
    Because k6 never looks in `node_modules` — there is no such lookup in the binary. To use an npm dependency you run a bundler such as webpack or rollup, which inlines the dependency into one self-contained file, and then import that emitted file by a relative path with its suffix written out.

A k6 import behaves like the src attribute on an HTML image tag: the exact path you typed is fetched, and nothing decides that you probably meant logo.png when you wrote logo. Node's resolver is a search; k6's is a literal fetch.

saying these in an interview costs you the question

  • k6 will find helpers.js if I just write ./helpers
  • npm install the package and k6 picks it up
  • an index.js in the folder makes ./lib importable
  • k6 runs on Node, so Node's resolver applies
  • a package.json main field tells k6 the entry file