skip to content

When a k6 helper module imports './config.js', which file's directory is that relative to?

level: middleimportance: should knowfreq 40%

answer

  1. anchored to the file, not the run
  2. URL join, like a browser
  3. the caller's depth never leaks in
  4. import.meta.resolve settles it

basics

~20 s

The directory of the file the specifier is written in. k6 resolves a relative import against the importing module's own URL, never against the entry script, the command line argument, or the shell's working directory.

solid answer

~40 s

k6 joins a relative specifier onto the URL of the module that contains it, the way a browser joins a relative href onto the page it appears in. So `./config.js` inside `lib/helpers.js` always means `lib/config.js`, no matter which test script imported `helpers.js` and no matter where you ran `k6` from. The corollary bites in the other direction: three scripts at different depths must each write their own path to the same shared file — `./helpers.js`, `../lib/helpers.js`, `../../lib/helpers.js`. If a helper is moved, every specifier inside it stays valid while every specifier pointing at it must change. `import.meta.resolve('./config.js')` returns the absolute URL k6 would produce for that specifier from the file it is written in.

code

javascript · 6 lines
javascript
// lib/helpers.js -- ./config.js always means lib/config.js
import { BASE_URL } from './config.js';

export function login() {
  return BASE_URL + '/login';
}

go deeper

for a junior

Count directories from the file you are editing, not from the script you will run. A helper's own imports are written as if that helper's folder were the whole world.

for a middle

Explain that k6 performs a URL join against the importing module's URL, so a nested helper's specifiers are independent of which entry script pulled it in, and that absolute and file:// specifiers skip the join entirely.

for a senior

Diagnose a not-found specifier by identifying which module contained the string, and use import.meta.resolve() rather than trial and error. Argue for a flat tests directory so every entry script shares one path to the shared library.

for a principal

The layout is the decision. A repository where every entry script sits at the same depth and every helper is anchored to its own folder scales to dozens of scripts; ad hoc nesting turns each new test into a path negotiation and pushes people towards absolute paths that break in CI.

## The rule k6 resolves a relative module specifier against the **URL of the module the specifier is written in**. Not the entry script. Not the path you typed after `k6 run`. Not the shell's current directory. k6 takes the importing module's URL, makes sure it ends in a slash, and performs an ordinary URL join, so `.` and `..` behave exactly as they do in a browser resolving a relative link on a page. This is the same rule for `import` and for `require()`, because both go through the same resolution step. ## Why the base matters in a suite with several entry points Take a repository laid out like this, with one shared helper: ``` lib/helpers.js lib/config.js tests/smoke.js tests/load.js tests/nightly/soak.js ``` Each script has to spell out its own way to the same file: | File doing the importing | Specifier for lib/helpers.js | |---|---| | `tests/smoke.js` | `../lib/helpers.js` | | `tests/load.js` | `../lib/helpers.js` | | `tests/nightly/soak.js` | `../../lib/helpers.js` | Meanwhile `lib/helpers.js` writes `./config.js` **once**, and that specifier is correct for all three runs. It is anchored to `lib/`, not to whichever script started the run. ## The failure this rule produces The mistake is to write a helper's own import as though it will be resolved from the caller. Suppose `lib/helpers.js` needs `config.js` which sits one level up, and someone writes `./config.js` because that is what the calling script would have needed. k6 resolves it against `lib/`, finds nothing, and reports: ``` The moduleSpecifier "./config.js" couldn't be found on local disk. ``` The message names the specifier that failed but not the file that contained it, which is what makes this error slow to diagnose. Three steps settle it quickly: 1. **Find the file the string was written in**, by searching the repository for that exact specifier rather than guessing from the entry script. 2. **Count directories from that file**, not from the script named on the command line. 3. **Confirm with `import.meta.resolve('./config.js')`** in the init stage: it returns the absolute URL k6 would produce for that specifier **from the file the call appears in**, so it answers the question instead of leaving you to add and remove `..` segments until the run stops failing. Note also what is *not* happening. k6 never searches upward for a missing file, never retries a specifier against another directory, and never falls back to the entry script's folder — so a not-found error means one join produced one wrong path, and there is exactly one place to look. ## Absolute specifiers opt out of the base A specifier beginning with `/`, a Windows drive letter, or the `file://` scheme is not joined to anything — it names a location on the machine outright: - `import { login } from '/srv/k6/lib/helpers.js';` - `import { login } from 'file:///srv/k6/lib/helpers.js';` Both load. Both also pin the script to one machine's directory layout, which is why k6 warns on Windows that an absolute import is not cross-platform, will not work if you move the script between machines or run it with `k6 cloud`, and that the `file://` form is marginally more portable if you must use one. For a suite that also runs in the official k6 Docker image, where your files only exist at whatever mount point you chose, a relative path inside the mounted tree is far easier to keep working than an absolute one. ## One neighbouring behaviour that does not follow this rule `open()`, which reads a data file rather than a module, is currently resolved against the module that is being loaded at that moment rather than against the file the call is written in. k6 emits a warning when those two differ, saying that the behaviour will be aligned with how `require` and imports work in a future version and that `import.meta.resolve()` future-proofs the call today. Worth knowing so that you do not assume a data path and a module path behave identically inside the same helper file. ## Practical rules for a shared helper 1. **Anchor helpers to their own directory.** Every specifier inside `lib/` is written as if `lib/` were the only thing that exists, because from the loader's point of view it is. 2. **Keep entry scripts at one depth.** If `tests/smoke.js`, `tests/load.js` and `tests/soak.js` all sit in the same folder, they share one specifier for the helper, and a new script is a copy-paste rather than a path puzzle. 3. **Prefer relative to absolute.** Relative paths survive a checkout in another location, a Docker mount and a cloud run; absolute paths survive none of those. 4. **Use `import.meta.resolve()` when the answer is not obvious**, rather than adding and removing `..` segments until the run stops failing.

  • Does running k6 from a different working directory change how a relative import resolves?
    No. The base is the importing module's own URL, which k6 derives from the resolved path of that file, so the shell's directory never enters the calculation. It does affect the argument you type after `k6 run`, since that path is resolved against the working directory, but once the entry script is located every import inside the graph is anchored to its own file.
  • What does import.meta.resolve() return in a k6 script?
    The absolute URL string that the given specifier resolves to from the file the call is written in, for example `file:///srv/k6/lib/config.js`. It applies k6's own resolution rules, so it also reports an error for a specifier k6 would reject, and its string result can be passed straight to `require()`.

saying these in an interview costs you the question

  • relative imports resolve from the entry script
  • relative imports resolve from the shell's working directory
  • moving a helper only breaks its own imports
  • absolute paths are the portable choice
  • k6 searches parent directories for a missing helper