VS Code ships a native Test Explorer, but out of the box it only understands testing frameworks that already have an extension wired up. If your team runs a custom test runner, an internal DSL, or a framework the Marketplace hasn’t caught up to, the built-in Testing view stays empty. The fix is the Testing API, a part of the VS Code Extension API that lets you register a TestController, feed it discovered tests, and report pass/fail results directly into the native UI — no custom webview, no separate panel, no context-switching.
This tutorial builds a working VS Code Testing API extension from scratch: discovery, execution, cancellation, failure messages with source locations, incremental resolution for large repos, and Run/Debug/Run-with-coverage support. It also runs unmodified in Cursor, since Cursor’s extension host is a VS Code fork and consumes the same Extension API. By the end you’ll have a publishable extension and a clear map of what breaks in production test suites.
Why build a custom Testing API extension in 2026
Visual Studio Code remained the most used IDE for four years running in Stack Overflow’s 2025 Developer Survey, and 75.9% of developers reported using it, according to Stack Overflow’s 2025 Developer Survey. Cursor, built on the same extension host, was used by 18% of developers in the same survey, per Stack Overflow’s 2025 survey results. That combined footprint is the reason a Testing API extension is worth the setup time: write it once, and it runs in both editors without a fork or a compatibility shim.
VS Code 1.133, released August 12, 2026, kept the weekly release cadence the editor has held for years and extended session handling for long-running AI-assisted workflows, a detail that matters here because Cursor’s newer agent features lean on exactly this kind of long session. Cursor itself shipped Projects in beta on September 10, 2026, a coordinator-agent system that delegates work across many subagents and keeps shared context over weeks of work. When an agent is rewriting files across a repo for hours or days, a test suite that reports results natively into the editor — rather than through scraped terminal output — becomes the reliable signal both a human and an agent can trust. That is the practical case for this extension: it turns ad hoc test output into structured, navigable, machine-readable state.
The other reason to build this yourself instead of hunting for an existing extension: most Marketplace test adapters assume a mainstream framework (Jest, Mocha, pytest, JUnit). If your team runs anything else — a homegrown assertion DSL, a BDD wrapper, a hardware-in-the-loop test harness, a smoke-test script that isn’t a “real” framework — there is no adapter for you, and the Testing API is the only supported way to get native Test Explorer integration.
Testing API vs. a custom webview panel
Before writing any code, it’s worth ruling out the more common shortcut: building a webview panel that renders your own test results UI from scratch. Webviews are flexible — you control every pixel — but that flexibility is also the cost. A webview-based test runner has to reimplement navigation, keyboard shortcuts, filtering, and result-to-source-line linking that the native Testing view already provides for free. It also lives outside the Testing view entirely, so it doesn’t show up in the status bar test counts, doesn’t participate in “Run failed tests” style commands, and doesn’t compose with other testing extensions a developer might already have installed.
The Testing API trades some of that visual flexibility for integration. Your test tree renders using VS Code’s own tree widget, with the same expand/collapse behavior, the same filter box, and the same keyboard navigation every other test adapter uses. For most teams, this is the right trade: developers already know how to use the Testing view, so there’s no new UI to learn. The only real case for a webview is a highly specialized visualization — a dependency graph of test relationships, for example — that genuinely can’t be expressed as a tree of pass/fail items. If your requirement is “get our tests showing up like every other framework’s tests,” the Testing API is the documented, supported path, and it’s the one this tutorial builds.
Prerequisites and versions
Confirm these before starting. Version mismatches are the single biggest source of confusing errors in this tutorial, since the Testing API surface changed meaningfully between 2022 and 2024 and is now stable.
| Requirement | Minimum version | Notes |
|---|---|---|
| Visual Studio Code | 1.133 (Aug 2026) or later | Testing API has been stable since 1.59; 1.133 is the current release as of this tutorial |
| Node.js | 20.x LTS or later | Required by yo code and the extension bundler |
| npm | 10.x or later | Ships with Node 20 |
| Yeoman + VS Code Extension Generator | latest via npm | npm install -g yo generator-code |
| @vscode/test-cli | latest | Official CLI for running extension integration tests |
| TypeScript | 5.x | Extension API types ship as @types/vscode |
| Cursor (optional, for cross-editor testing) | latest stable | Uses the same extension host as VS Code; no code changes needed |
| Git | 2.40+ | For packaging and publishing later |
You’ll also need a Marketplace publisher account later if you plan to distribute the extension, but that isn’t required to build and test it locally.
Step 1: Scaffold the extension project
Install the generator tooling and scaffold a TypeScript extension. The generator produces a working “Hello World” extension with a build pipeline already configured, which saves you from hand-writing a package.json activation contract from scratch.
npm install -g yo generator-code
yo code
# Answer the prompts:
# ? What type of extension do you want to create? New Extension (TypeScript)
# ? What's the name of your extension? custom-test-explorer
# ? What's the identifier of your extension? custom-test-explorer
# ? Bundle the source code with webpack? Yes
# ? Initialize a git repository? Yes
# ? Which package manager to use? npm
cd custom-test-explorer
code .
Open the generated project. You’ll see src/extension.ts with an activate function, a package.json with an activationEvents array, and a tsconfig.json already targeting ES2022. Leave the webpack config alone for now — it’s already set up to bundle the extension into a single dist/extension.js file, which matters for load time later.
Step 2: Declare the Testing contribution point
Unlike commands or views, the Testing API doesn’t require a contributes block in package.json to appear — the Testing view is always present in the editor, and any extension can populate it by registering a TestController at activation. What you do need is an activation event so your extension loads early enough to register tests before the user opens the Testing view.
{
"name": "custom-test-explorer",
"displayName": "Custom Test Explorer",
"description": "Native Test Explorer integration for a custom test runner",
"version": "0.1.0",
"engines": { "vscode": "^1.133.0" },
"categories": ["Testing"],
"activationEvents": [
"workspaceContains:**/*.spec.ct.js"
],
"main": "./dist/extension.js",
"contributes": {
"configuration": {
"title": "Custom Test Explorer",
"properties": {
"customTestExplorer.testFilePattern": {
"type": "string",
"default": "**/*.spec.ct.js",
"description": "Glob pattern used to discover test files"
}
}
}
}
}
The workspaceContains activation event is important for performance: it means your extension only activates when a matching test file actually exists in the workspace, instead of loading on every VS Code startup regardless of project type.
Step 3: Create the TestController
The TestController is the root object that owns everything: the test tree, the run profiles, and the resolve handler. Create it once, during activation, and register it with the extension context so it’s disposed cleanly when the extension deactivates.
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const controller = vscode.tests.createTestController(
'customTestExplorer',
'Custom Test Explorer'
);
context.subscriptions.push(controller);
// Populate the tree lazily; see Step 5 for resolveHandler
controller.resolveHandler = async (item) => {
if (!item) {
await discoverAllFiles(controller);
} else {
await parseTestsInFile(controller, item);
}
};
// Refresh handler lets users click the refresh icon in Test Explorer
controller.refreshHandler = async () => {
await discoverAllFiles(controller);
};
}
At this point the Testing view will show up empty but active — VS Code knows an extension owns a test controller, it just hasn’t been asked to discover anything yet. That happens the moment the user expands the tree or clicks refresh, which triggers resolveHandler.
Step 4: Discover test files and build TestItems
Discovery has two levels: finding files that contain tests, and parsing each file to find individual test cases. Keep these separate so you can resolve them incrementally instead of parsing every file in the workspace up front, which is what makes this approach usable on large repositories.
async function discoverAllFiles(controller: vscode.TestController) {
const pattern = vscode.workspace
.getConfiguration('customTestExplorer')
.get<string>('testFilePattern', '**/*.spec.ct.js');
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**');
for (const uri of files) {
getOrCreateFile(controller, uri);
}
}
function getOrCreateFile(controller: vscode.TestController, uri: vscode.Uri) {
const existing = controller.items.get(uri.toString());
if (existing) return existing;
const item = controller.createTestItem(
uri.toString(),
uri.path.split('/').pop()!,
uri
);
item.canResolveChildren = true;
controller.items.add(item);
return item;
}
async function parseTestsInFile(
controller: vscode.TestController,
fileItem: vscode.TestItem
) {
const content = await vscode.workspace.fs.readFile(fileItem.uri!);
const text = Buffer.from(content).toString('utf8');
// Naive regex parser for demonstration; swap for a real AST parser
const testRegex = /test(['"](.+?)['"]/g;
let match;
let line = 0;
const lines = text.split('n');
for (line = 0; line < lines.length; line++) {
testRegex.lastIndex = 0;
match = testRegex.exec(lines[line]);
if (match) {
const id = `${fileItem.uri}::${match[1]}`;
const testItem = controller.createTestItem(id, match[1], fileItem.uri);
testItem.range = new vscode.Range(line, 0, line, lines[line].length);
fileItem.children.add(testItem);
}
}
}
Swap the regex parser for a real AST parser (Babel, ts-morph, or your framework’s own parser) before shipping this to a team. Regex-based test discovery breaks on multiline test names, template literals, and dynamically generated test cases — it’s fine for a tutorial, unreliable in production.
Step 5: Add file watching for live updates
Without a file watcher, the test tree goes stale the moment someone adds, renames, or deletes a test file. VS Code’s FileSystemWatcher handles this cleanly and integrates with the same TestItem tree you built in Step 4.
function setupWatcher(controller: vscode.TestController, context: vscode.ExtensionContext) {
const pattern = vscode.workspace
.getConfiguration('customTestExplorer')
.get<string>('testFilePattern', '**/*.spec.ct.js');
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate((uri) => getOrCreateFile(controller, uri));
watcher.onDidChange((uri) => {
const item = controller.items.get(uri.toString());
if (item) {
item.children.replace([]);
parseTestsInFile(controller, item);
}
});
watcher.onDidDelete((uri) => controller.items.delete(uri.toString()));
context.subscriptions.push(watcher);
}
Call setupWatcher(controller, context) at the end of activate(). This is the piece most tutorials skip, and it’s the reason a lot of homegrown test integrations feel unreliable — the tree only updates on manual refresh instead of reacting to file changes the way the native JavaScript and Python test adapters do.
Step 6: Register a run profile and execute tests
A run profile tells VS Code what “Run” means for your controller. You can register multiple profiles — Run, Debug, Coverage — each with its own handler. This is where you shell out to your actual test runner and translate its output into TestRun API calls.
import { spawn } from 'child_process';
function registerRunProfile(controller: vscode.TestController) {
controller.createRunProfile(
'Run',
vscode.TestRunProfileKind.Run,
async (request, token) => {
const run = controller.createTestRun(request);
const queue = collectTests(controller, request);
for (const test of queue) {
if (token.isCancellationRequested) {
run.skipped(test);
continue;
}
run.started(test);
const result = await execTest(test);
if (result.passed) {
run.passed(test, result.durationMs);
} else {
const message = new vscode.TestMessage(result.errorText);
message.location = new vscode.Location(test.uri!, test.range!);
run.failed(test, message, result.durationMs);
}
}
run.end();
},
true // isDefault
);
}
function execTest(test: vscode.TestItem): Promise {
return new Promise((resolve) => {
const start = Date.now();
const proc = spawn('node', ['run-single-test.js', test.id], { shell: true });
let stderr = '';
proc.stderr.on('data', (d) => (stderr += d.toString()));
proc.on('close', (code) => {
resolve({
passed: code === 0,
durationMs: Date.now() - start,
errorText: stderr || 'Test failed with no error output',
});
});
});
}
function collectTests(controller: vscode.TestController, request: vscode.TestRunRequest): vscode.TestItem[] {
const tests: vscode.TestItem[] = [];
const walk = (item: vscode.TestItem) => {
if (item.children.size === 0) {
tests.push(item);
} else {
item.children.forEach(walk);
}
};
if (request.include) {
request.include.forEach(walk);
} else {
controller.items.forEach(walk);
}
return tests;
}
Call registerRunProfile(controller) in activate(). Note the message.location assignment: that’s what makes a failed test clickable in the Test Explorer, jumping the user straight to the source line instead of leaving them to hunt through terminal output.
Step 7: Add a Debug run profile
Debug support reuses VS Code’s existing Debug Adapter Protocol integration rather than reimplementing anything. You register a second run profile with TestRunProfileKind.Debug and, inside its handler, call vscode.debug.startDebugging with a debug configuration pointed at the specific test.
controller.createRunProfile(
'Debug',
vscode.TestRunProfileKind.Debug,
async (request, token) => {
const run = controller.createTestRun(request);
const tests = collectTests(controller, request);
for (const test of tests) {
run.started(test);
await vscode.debug.startDebugging(undefined, {
type: 'node',
request: 'launch',
name: `Debug: ${test.label}`,
program: '${workspaceFolder}/run-single-test.js',
args: [test.id],
console: 'integratedTerminal',
});
run.passed(test); // Debug sessions don't report pass/fail automatically
}
run.end();
}
);
In practice, debug runs are usually single-test invocations triggered by clicking the debug icon next to one test in the tree, not a full-suite batch run — the loop above will work for both, but expect most real usage to be one test at a time with a breakpoint already set.
Step 8: Use resolveHandler for lazy, incremental discovery
This is the step that separates a toy extension from one that scales to a real monorepo. If discoverAllFiles parses every test file at activation, a repository with a few thousand test files will freeze the extension host on startup. The resolveHandler pattern from Step 3 already defers per-file parsing until a user expands that specific file in the tree — but you can go further and defer file-level discovery itself until the Testing view is opened.
controller.resolveHandler = async (item) => {
if (!item) {
// Root resolve: only list files, don't parse contents yet
const pattern = vscode.workspace
.getConfiguration('customTestExplorer')
.get<string>('testFilePattern', '**/*.spec.ct.js');
const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**');
for (const uri of files) {
getOrCreateFile(controller, uri); // cheap: creates item, no parsing
}
return;
}
// Per-file resolve: parse only this file, only when expanded
await parseTestsInFile(controller, item);
};
With this structure, opening the Testing view on a 5,000-file repository costs one findFiles glob scan, not 5,000 file reads and parses. Parsing only happens as the user expands nodes, which is the same lazy pattern VS Code’s own built-in test adapters use.
Step 9: Test the extension in the Extension Development Host
Press F5 inside the extension project. VS Code launches a second window — the Extension Development Host — with your extension loaded. Open a folder that contains files matching your test pattern, then open the Testing view from the Activity Bar.
# Sample output in the Debug Console of the host window:
[Extension Host] Activating extension 'custom-test-explorer'
[Extension Host] Test controller 'customTestExplorer' registered
[Extension Host] Discovered 3 test files matching **/*.spec.ct.js
[Extension Host] Resolved 12 tests across 3 files
In the Testing view sidebar, you should see your test files as expandable nodes, each containing individual test cases with a play icon. Click the play icon at the root to run everything, or hover an individual test to run just that one. A green check or red X appears inline next to each test as results come in.
Step 10: Verify it works identically in Cursor
Because Cursor’s editor shell is a VS Code fork sharing the same extension host and Extension API surface, no code changes are required. Package the extension with vsce package, then install the resulting .vsix file manually in Cursor.
npm install -g @vscode/vsce
vsce package
# Produces custom-test-explorer-0.1.0.vsix
# In Cursor:
# Cmd/Ctrl+Shift+P -> "Extensions: Install from VSIX..."
# Select custom-test-explorer-0.1.0.vsix
Open the same workspace in Cursor and check the Testing view. Discovery, run, and debug should behave identically, since Cursor doesn’t modify the Testing API surface — it inherits it wholesale from the upstream VS Code codebase it’s built on. This is also why the same extension works whether developers on your team use Cursor’s agent features, GitHub Copilot in VS Code, or neither.
Step 11: Add coverage reporting
The Testing API supports a third profile kind, TestRunProfileKind.Coverage, which surfaces per-file coverage percentages directly in the editor gutter. This requires your test runner to emit coverage data (Istanbul/nyc’s JSON format is the most common) that you then translate into FileCoverage objects.
const coverageProfile = controller.createRunProfile(
'Run with Coverage',
vscode.TestRunProfileKind.Coverage,
async (request, token) => {
const run = controller.createTestRun(request);
// ...execute tests as in Step 6, then...
const coverageData = await readCoverageJson('./coverage/coverage-final.json');
for (const [filePath, data] of Object.entries(coverageData)) {
const uri = vscode.Uri.file(filePath);
const fileCoverage = new vscode.FileCoverage(
uri,
new vscode.TestCoverageCount(data.covered, data.total)
);
run.addCoverage(fileCoverage);
}
run.end();
}
);
coverageProfile.loadDetailedCoverage = async (testRun, fileCoverage) => {
// Return per-line StatementCoverage[] for the gutter indicators
return loadLineCoverage(fileCoverage.uri);
};
Once wired up, VS Code shows a coverage summary in the Testing view and colors the editor gutter green or red per line for the currently open file — the same UI used by the built-in Jest and pytest coverage integrations.
Step 12: Handle large monorepos with test tags
In a monorepo with multiple packages, users often want to run “just this package’s tests” or “just unit tests, not integration tests.” TestTag objects let you group tests across the tree and filter run profiles to a subset.
const unitTag = new vscode.TestTag('unit');
const integrationTag = new vscode.TestTag('integration');
// When creating a TestItem, tag it based on its file path or naming convention
testItem.tags = filePath.includes('.integration.') ? [integrationTag] : [unitTag];
// Register a run profile scoped to only unit-tagged tests
const unitOnlyProfile = controller.createRunProfile(
'Run Unit Tests Only',
vscode.TestRunProfileKind.Run,
runHandler,
false,
unitTag
);
Tags appear as filter chips in the Testing view’s filter box, so users can toggle “unit” or “integration” without hunting through a deeply nested tree. This scales far better than folder-based filtering once a monorepo passes a few hundred test files.
Step 13: Package and publish to the Marketplace
With discovery, execution, debug, coverage, and tagging working, package and publish. This is separate from publishing to Open VSX (used by some VS Code forks) — the official Visual Studio Code Marketplace is what both stock VS Code and Cursor read from by default.
# Create a publisher (one-time, via https://marketplace.visualstudio.com/manage)
vsce create-publisher your-publisher-name
# Login with a Personal Access Token from Azure DevOps
vsce login your-publisher-name
# Package and publish
vsce package
vsce publish
# Or publish a specific version bump directly
vsce publish minor
Publishing typically takes a few minutes to propagate to the Marketplace search index. Once live, both VS Code and Cursor users can install it with ext install your-publisher-name.custom-test-explorer from the command palette.
Writing integration tests for the extension itself
There’s a specific irony in shipping a testing extension with no tests of its own, so before packaging, wire up the official @vscode/test-cli tooling. Unlike unit-testing plain TypeScript functions, extension integration tests run inside a real, automated instance of VS Code, which means you can assert against the actual Testing view state rather than mocking the vscode module.
// .vscode-test.mjs
import { defineConfig } from '@vscode/test-cli';
export default defineConfig({
files: 'out/test/**/*.test.js',
workspaceFolder: './test-fixtures/sample-project',
});
// test/extension.test.ts
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('Custom Test Explorer', () => {
test('discovers test files in the fixture workspace', async () => {
const ext = vscode.extensions.getExtension('your-publisher-name.custom-test-explorer');
await ext?.activate();
// Give the resolveHandler a moment to run against the fixture workspace
await new Promise((resolve) => setTimeout(resolve, 500));
const controller = (ext?.exports as any)?.controller;
assert.ok(controller, 'controller should be exported for testing');
assert.strictEqual(controller.items.size > 0, true, 'should discover at least one test file');
});
test('run profile reports at least one pass or fail', async () => {
// Trigger a run programmatically and assert on TestRun state
// via a test-only export, or by inspecting diagnostics emitted
// during the run for a known-good fixture file.
});
});
Two details matter here. First, the fixture workspace referenced in .vscode-test.mjs needs its own small set of sample test files matching your glob pattern — treat it as a miniature repo purpose-built for exercising discovery, not a real project. Second, exporting internal state like controller from your activate() function purely for test assertions is a reasonable trade-off in a testing extension, even though it isn’t something you’d normally do in a production extension’s public API.
npx vscode-test
# Sample output:
# Custom Test Explorer
# ✓ discovers test files in the fixture workspace (612ms)
# ✓ run profile reports at least one pass or fail (890ms)
#
# 2 passing (1.5s)
Run this suite in CI on every commit, the same way you’d gate any other extension change. A broken Testing API extension fails silently from the user’s perspective — the Testing view just looks empty or stuck — so automated coverage here is doing work that manual QA is unlikely to catch consistently.
When to use an existing adapter instead
Building a custom Testing API extension only makes sense when no existing adapter fits. Before committing to the work in this tutorial, check whether your framework already has Marketplace coverage — mainstream JavaScript, Python, and .NET test runners generally do, and reinventing that integration wastes effort that could go into your actual product.
| Situation | Recommended approach |
|---|---|
| Framework is Jest, Mocha, pytest, JUnit, or another mainstream runner | Install the existing Marketplace adapter; skip building your own |
| Internal DSL or homegrown assertion library with no adapter | Build a custom Testing API extension, following this tutorial |
| Hardware-in-the-loop or long-running integration suite | Custom extension, with the continuous run and cancellation token handling emphasized |
| Need a highly specialized results visualization (graphs, timelines) | Consider a companion webview panel alongside a minimal Testing API registration, not instead of it |
| Just want a single “run all tests” button with no tree navigation | A simple tasks.json task may be enough; the Testing API is overkill |
Common pitfalls
These are the mistakes that show up most often when teams build their first Testing API extension.
- Parsing every file at activation instead of lazily. This is the single biggest cause of a sluggish extension. Always defer per-file parsing to
resolveHandleras shown in Step 8, not to the initialdiscoverAllFilespass. - Forgetting to call
run.end(). If a run handler throws before reachingrun.end(), the Testing view shows a permanently spinning progress indicator with no way to cancel it short of reloading the window. - Using regex instead of an AST parser for anything beyond a demo. Regex-based discovery silently misses tests with multiline names, template literals, or programmatically generated test cases — and silent misses are worse than a visible crash because nobody notices the gap.
- Not respecting the cancellation token. Long test runs need to check
token.isCancellationRequestedinside the loop, not just at the start. Users expect the Stop button in the Testing view to actually stop execution mid-run. - Hardcoding the test file glob instead of reading it from configuration. Every team’s file naming convention differs; a hardcoded pattern means your extension only works for the one repo you tested it on.
- Skipping the file watcher. Without it, the tree only updates on manual refresh, which trains users to distrust it and go back to running tests from the terminal.
- Not setting
message.locationon failures. Without a location, a failed test shows an error message but doesn’t let the user click through to the source line — a small omission that erases most of the UX benefit of native integration.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Testing view never activates | activationEvents glob doesn’t match any file in the workspace |
Check the pattern with workspace.findFiles in the Debug Console, or add onStartupFinished temporarily to confirm activation logic works at all |
| Tests appear but never run | Run profile handler throws before calling run.started() |
Wrap the handler body in try/catch and log to the Output panel; check the extension host log via “Developer: Show Logs” -> Extension Host |
| Spinner never stops after a run | An exception was thrown after createTestRun() but before run.end() |
Add a try/finally block around the run loop so run.end() always executes |
| Failed test message has no clickable location | message.location was never set |
Set it explicitly with new vscode.Location(uri, range) before calling run.failed() |
| Extension works in VS Code but not Cursor | Usually a hardcoded absolute path to the VS Code binary or a VS Code-only proposed API | Check for proposed APIs in package.json‘s enabledApiProposals — these are often not enabled in Cursor’s build |
| Coverage gutter colors don’t appear | loadDetailedCoverage was never implemented, only file-level counts |
Implement loadDetailedCoverage to return line-by-line StatementCoverage data |
| File watcher fires but tree doesn’t visually refresh | Calling children.replace([]) without re-adding new items synchronously |
Await the reparse call before returning from the watcher callback |
| Discovery is slow on a large monorepo | Root resolveHandler parses file contents instead of just listing files |
Split root-level and per-file resolution as shown in Step 8; root should only call findFiles, never read file contents |
| Debug run profile opens a debugger but doesn’t stop at breakpoints | Debug configuration’s program path doesn’t match the actual entry point your test runner uses |
Verify the program field against how you’d manually invoke a single test from the terminal |
Advanced tips
Once the core extension works, a few refinements make it production-ready rather than demo-ready.
Batch test execution instead of one process per test. Spawning a new Node process per test, as shown in Step 6 for clarity, doesn’t scale past a few dozen tests. In production, pass a list of test IDs to a single runner invocation and parse structured JSON output (most frameworks support a --reporter json or equivalent flag), then map results back to the corresponding TestItem objects.
Use TestRunRequest.continuous for watch-mode integration. If your test runner supports a watch mode, wire it to the Testing API’s continuous run request so the play icon can toggle “run on save” behavior natively, instead of requiring a separate terminal window running in watch mode alongside the editor.
Cache parse results keyed by file mtime. Re-parsing a file every time it’s expanded, even when it hasn’t changed, adds unnecessary latency on large files. Store a hash or modification timestamp alongside cached TestItem children and skip reparsing when it’s unchanged.
Surface stderr output as test-run output, not just failure messages. Use run.appendOutput() to stream raw stdout/stderr into the Test Results output channel, formatted with ANSI codes preserved. This gives developers the same detail they’d get from the terminal, without leaving the Testing view.
Respect workspace trust. If your extension executes arbitrary code from the workspace (which any test runner integration does by definition), check vscode.workspace.isTrusted before running and prompt the user if the workspace is untrusted, matching the security model VS Code’s built-in extensions follow.
Complete working project structure
Putting every step together, the finished extension’s file layout looks like this:
custom-test-explorer/
├── package.json # manifest, activation events, configuration schema
├── tsconfig.json
├── webpack.config.js
├── src/
│ ├── extension.ts # activate() wires up controller, watcher, profiles
│ ├── discovery.ts # discoverAllFiles, parseTestsInFile, getOrCreateFile
│ ├── execution.ts # registerRunProfile, execTest, collectTests
│ ├── coverage.ts # coverage profile + FileCoverage mapping
│ └── tags.ts # TestTag definitions and assignment logic
├── test/
│ └── extension.test.ts # integration tests via @vscode/test-cli
└── .vscodeignore
Run the extension’s own test suite with the official CLI before packaging, since a broken Testing API extension is a uniquely bad look:
npm install --save-dev @vscode/test-cli @vscode/test-electron
npx vscode-test
VS Code vs Cursor: what actually differs for extension authors
Since this extension is meant to run in both editors, it’s worth being precise about where the two diverge, since the differences are narrower than most developers assume.
| Aspect | VS Code | Cursor |
|---|---|---|
| Extension host | Native | Forked from the same upstream codebase |
Testing API (vscode.tests.*) |
Fully supported | Fully supported, no known gaps as of Cursor’s September 2026 changelog |
| Marketplace source | Official VS Code Marketplace | Reads from the same Marketplace by default |
| Proposed (unstable) APIs | Enabled via enabledApiProposals for Insiders builds |
Support for proposed APIs can lag; avoid relying on them for cross-editor extensions |
| Latest relevant release | 1.133 — Aug 12, 2026 | Projects (beta) — Sep 10, 2026; Rollouts and Security Review — Sep 23, 2026 |
The practical takeaway: if you stick to stable, documented Testing API calls — everything used in this tutorial — you get cross-editor compatibility for free. The risk only shows up if you reach for a proposed API that hasn’t stabilized upstream yet.
Frequently asked questions
Does the Testing API require a specific test framework?
No. The API is framework-agnostic by design. You define how tests are discovered and how they’re executed, so it works equally well with Jest, pytest, a custom DSL, or a hardware test harness, as long as you can shell out to it and parse its output or exit code.
Will this extension work in Cursor without any code changes?
Yes, for anything built on stable Testing API calls. Cursor’s extension host is forked from the same VS Code codebase, and the Testing API namespace (vscode.tests) is fully supported. Only proposed, unstable APIs risk a gap.
How is this different from writing a Debug Adapter Protocol extension?
They solve different problems and often work together. The Testing API populates the Testing view with discoverable, runnable test items. The Debug Adapter Protocol handles the actual debugging session — breakpoints, stepping, variable inspection. Step 7 shows them working together: a Debug run profile that uses vscode.debug.startDebugging under the hood.
Do I need to publish to both the VS Code Marketplace and Open VSX?
Only if you want to support VS Code forks that don’t read from the official Marketplace, such as VSCodium. Cursor reads from the standard Marketplace by default, so a single vsce publish covers both VS Code and Cursor users.
Why does my Testing view stay stuck on a spinner after a run?
This almost always means an exception was thrown inside your run handler after createTestRun() was called but before run.end() executed. Wrap the run loop in try/finally so run.end() always fires, even on error.
Can this scale to a monorepo with thousands of tests?
Yes, if you follow the lazy resolution pattern in Step 8. Root-level resolution should only enumerate files via findFiles; parsing individual test files should happen only when a user expands that specific node. Skipping this step is the most common reason custom test extensions feel slow on large codebases.
Does the Testing API support code coverage out of the box?
The API provides the plumbing (TestRunProfileKind.Coverage, FileCoverage, loadDetailedCoverage) but you still need your test runner to emit coverage data, typically as Istanbul-format JSON, which you then translate into the API’s coverage objects as shown in Step 11.
What VS Code version should I target for engines.vscode in package.json?
Set it to the minimum version that includes every Testing API method you use. All calls in this tutorial have been stable since 1.68, but targeting a current release like 1.133 avoids edge cases in older, unmaintained VS Code installs.
Should I build a custom Testing API extension if my framework already has a Marketplace adapter?
No. Check the Marketplace first. Mainstream frameworks like Jest, Mocha, pytest, and JUnit already have maintained adapters, and duplicating that work adds maintenance burden without adding value. Reach for a custom extension only when no adapter exists for your specific runner.
How do I test the extension itself before publishing?
Use the official @vscode/test-cli package, which launches a real, scripted instance of VS Code against a fixture workspace so you can assert against actual Testing view state, rather than mocking the vscode module. Run it in CI on every commit, since a broken Testing API extension tends to fail silently from the user’s point of view.