DEV Community

Alex
Alex

Posted on

Testing a VS Code extension without starting VS Code (and what broke when I did start it)

The official way to test a VS Code extension is to launch a real VS Code with your extension loaded and run tests inside it. It works, and it's slow: download an editor build, start Electron, wait for the window. Too slow to run on every save.

For Slashi, the extension I maintain, I ended up with three layers instead, each catching something the others can't. Numbers below are from Slashi 0.6.0 on an Apple M2 Pro, October 2026.

Layer 1: keep most logic out of reach of vscode

The cheapest test is one that doesn't need an editor at all. Slashi's ranking, query parsing, project detection and validation of commands and packs live in src/core, which never imports the vscode module. That rule is what makes the folder testable with plain Vitest:

$ npx vitest run
 Test Files  15 passed (15)
      Tests  203 passed (203)
   Duration  369ms
Enter fullscreen mode Exit fullscreen mode

The rule costs something: code that needs an editor value has to receive it as a plain argument. Detection, for example, takes a scanned list of file names instead of calling vscode.workspace.findFiles itself. The extension does the I/O and hands core the data. That split also let the same ranking code run inside the webview, which was the original reason for it.

Layer 2: activate the real bundle against a fake vscode

Unit tests can't tell you whether the extension activates: whether every command registers, whether the webview protocol round-trips, whether settings are read correctly. In my experience, that's where a lot of real bugs hide.

vscode is a module the editor injects at runtime; it doesn't exist on npm. In plain Node you can supply your own by intercepting require:

// test/smoke/run.cjs
const Module = require('node:module');
const mock = require('./vscode-mock.cjs');
const originalLoad = Module._load;
Module._load = function (request, ...rest) {
  return request === 'vscode' ? mock : originalLoad.call(this, request, ...rest);
};

const extension = require('../../dist/extension.js'); // the real esbuild bundle
const api = extension.activate(context);
Enter fullscreen mode Exit fullscreen mode

The mock is about 430 lines: an EventEmitter, Uri, an in-memory configuration store, fake terminals and webview panels that record what was posted to them. It loads the built bundle, so it also catches bundling mistakes.

One detail paid for itself several times: the mock reads setting defaults from the extension's own package.json, not from a hand-written list:

const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
function defaults() {
  const out = {};
  for (const section of pkg.contributes.configuration)
    for (const [key, value] of Object.entries(section.properties)) out[key] = value.default;
  return out;
}
Enter fullscreen mode Exit fullscreen mode

If someone adds a setting in code but forgets to declare it in contributes.configuration, the smoke test sees undefined where the real editor would too, and fails.

The script then drives the real flows against the repository itself: open the popup, send the webview's ready message, check the init payload, run a live file search, run a command, install a pack. 61 checks, including a final "nothing was logged as an error". The whole thing, including the esbuild build, takes about 3.6 seconds, and CI runs it on Linux, macOS and Windows.

Layer 3: the real editor, for what a fake can't know

A mock only knows what I told it. It can't tell me that a terminal command actually ran with the right arguments, or that Git integration behaves like the real Git extension. For that there's an end-to-end suite using @vscode/test-electron: it creates a throwaway Git repository with a package.json, launches a real VS Code on it with other extensions disabled, and runs scenarios.

To check what the terminal really ran, VS Code's shell integration events are the reliable source:

vscode.window.onDidStartTerminalShellExecution((e) => events.push(`ran: ${e.execution.commandLine.value}`));
vscode.window.onDidEndTerminalShellExecution((e) => events.push(`exit code: ${e.exitCode}`));
Enter fullscreen mode Exit fullscreen mode

Getting that suite to start at all is where the gotchas were:

  1. ELECTRON_RUN_AS_NODE breaks the launch. If it's set in your environment (some tools set it for their own child processes), VS Code's Electron binary starts as plain Node and the tests never run. The runner deletes it before launching.
  2. The user-data path must be short. VS Code opens a socket inside --user-data-dir, and on macOS socket paths are limited to about 103 characters. A temp folder under a long user path silently fails; the runner uses /tmp/….
  3. You can't stub another extension's dialogs. Each extension gets its own vscode API object, so a test can't replace showWarningMessage for the extension under test. Avoid code paths that open dialogs (for example by setting git.enableSmartCommit), and leave dialog behavior to the smoke test, where you control the mock.
  4. A modal that waits for a click hangs the run. A "no AI configured" warning that waits for a button meant one test never finished. Either configure what the code expects or test that path in the smoke layer.
  5. Use a real Git repo, not a folder. Git features check for a repository and a commit; the runner runs git init, sets a local user and commits before launching.

What each layer is for

Layer Runs in Catches Misses
Unit (Vitest) Node, ~0.4 s Logic bugs in ranking, parsing, detection Anything touching the editor
Smoke (mock vscode) Node, ~3.6 s with build Activation, registration, webview protocol, settings declared in package.json Where the mock differs from the real API
End-to-end (real VS Code) Electron, the slowest Real terminals, real Git, real command execution Dialogs; anything needing other extensions

The real cost of the middle layer is keeping the mock honest. When the extension starts using a new API, the mock needs it too, and a mock that's wrong in a convenient way can pass tests the real editor would fail. That's why the end-to-end layer exists, even if it only runs a handful of scenarios.

Takeaway

If your extension's tests all run inside a real VS Code, try moving the logic that doesn't need vscode into a folder that can't import it, and add one Node script that activates your built bundle against a small fake. It catches most of what the slow suite catches, in seconds.

All three are in the repo: test/unit, test/smoke, test/e2e.


I'm the author of Slashi. It's free and MIT-licensed: Marketplace · Open VSX · GitHub.

Top comments (0)