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
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);
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;
}
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}`));
Getting that suite to start at all is where the gotchas were:
-
ELECTRON_RUN_AS_NODEbreaks 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. -
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/…. -
You can't stub another extension's dialogs. Each extension gets its own
vscodeAPI object, so a test can't replaceshowWarningMessagefor the extension under test. Avoid code paths that open dialogs (for example by settinggit.enableSmartCommit), and leave dialog behavior to the smoke test, where you control the mock. - 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.
-
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)