src/goTestExplorer: implement a test provider for the new test api

What this does:
- Implements a test provider using the new API
- Populates the test explorer with a list of modules and/or workspaces
- Populates each module/workspace/package/file as it is expanded
- Creates test entries for the current file, and package/module parents
- Runs tests!

What this does not:
- Debug tests
- Handle stretchr suites

Issues:
- Handling of benchmarks isn't great. But I'm not sure it can get much
  better without changes to `go test`.
- If a test has subtests, I add those subtests. This can get annoying if
  you have a bunch of subtests. Should this be configurable? Disabling
  `testing.followRunningTest` can improve this UX.

Fixes golang/vscode-go#1579

Change-Id: I027c7c3b615eda4c528da9739520e6bfd1aa6911
GitHub-Last-Rev: 59af29b6a2f585135a58b91ed6cc842796219512
GitHub-Pull-Request: golang/vscode-go#1590
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/330809
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Suzy Mueller <suzmue@golang.org>
diff --git a/docs/commands.md b/docs/commands.md
index 23e0821..9c0a925 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -59,6 +59,10 @@
 
 Runs all unit tests in the package of the current file.
 
+### `Go Test: Refresh`
+
+Refresh a test in the test explorer. Only available as a context menu option in the test explorer.
+
 ### `Go: Benchmark Package`
 
 Runs all benchmarks in the package of the current file.
diff --git a/docs/settings.md b/docs/settings.md
index 2b8d0c0..bd599c8 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -393,6 +393,22 @@
 ### `go.testEnvVars`
 
 Environment variables that will be passed to the process that runs the Go tests
+### `go.testExplorerConcatenateMessages`
+
+If true, test log messages associated with a given location will be shown as a single message.
+
+Default: `true`
+### `go.testExplorerPackages`
+
+Control whether packages in the test explorer are presented flat or nested.<br/>
+Allowed Options: `flat`, `nested`
+
+Default: `"flat"`
+### `go.testExplorerRunBenchmarks`
+
+Include benchmarks when running all tests in a group.
+
+Default: `false`
 ### `go.testFlags`
 
 Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.
diff --git a/package-lock.json b/package-lock.json
index 28c3c3f..cd32598 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,7 @@
         "@types/node": "^13.11.1",
         "@types/semver": "^7.1.0",
         "@types/sinon": "^9.0.0",
-        "@types/vscode": "^1.52.0",
+        "@types/vscode": "^1.59.0",
         "adm-zip": "^0.4.14",
         "fs-extra": "^9.0.0",
         "get-port": "^5.1.1",
@@ -49,7 +49,7 @@
         "yarn": "^1.22.4"
       },
       "engines": {
-        "vscode": "^1.59.0"
+        "vscode": "^1.58.0"
       }
     },
     "node_modules/@babel/code-frame": {
@@ -387,9 +387,9 @@
       "dev": true
     },
     "node_modules/@types/vscode": {
-      "version": "1.54.0",
-      "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz",
-      "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==",
+      "version": "1.59.0",
+      "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz",
+      "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==",
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
@@ -7276,9 +7276,9 @@
       "dev": true
     },
     "@types/vscode": {
-      "version": "1.54.0",
-      "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz",
-      "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==",
+      "version": "1.59.0",
+      "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz",
+      "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==",
       "dev": true
     },
     "@typescript-eslint/eslint-plugin": {
diff --git a/package.json b/package.json
index 9439ec4..25a71b5 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,8 @@
     "Snippets",
     "Linters",
     "Debuggers",
-    "Formatters"
+    "Formatters",
+    "Testing"
   ],
   "galleryBanner": {
     "color": "#F2F2F2",
@@ -70,7 +71,7 @@
     "@types/node": "^13.11.1",
     "@types/semver": "^7.1.0",
     "@types/sinon": "^9.0.0",
-    "@types/vscode": "^1.52.0",
+    "@types/vscode": "^1.59.0",
     "adm-zip": "^0.4.14",
     "fs-extra": "^9.0.0",
     "get-port": "^5.1.1",
@@ -233,6 +234,13 @@
         "description": "Runs all unit tests in the package of the current file."
       },
       {
+        "command": "go.test.refresh",
+        "title": "Go Test: Refresh",
+        "description": "Refresh a test in the test explorer. Only available as a context menu option in the test explorer.",
+        "category": "Test",
+        "icon": "$(refresh)"
+      },
+      {
         "command": "go.benchmark.package",
         "title": "Go: Benchmark Package",
         "description": "Runs all benchmarks in the package of the current file."
@@ -1282,6 +1290,28 @@
           "description": "Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.",
           "scope": "resource"
         },
+        "go.testExplorerPackages": {
+          "type": "string",
+          "enum": [
+            "flat",
+            "nested"
+          ],
+          "default": "flat",
+          "description": "Control whether packages in the test explorer are presented flat or nested.",
+          "scope": "resource"
+        },
+        "go.testExplorerRunBenchmarks": {
+          "type": "boolean",
+          "default": false,
+          "description": "Include benchmarks when running all tests in a group.",
+          "scope": "resource"
+        },
+        "go.testExplorerConcatenateMessages": {
+          "type": "boolean",
+          "default": true,
+          "description": "If true, test log messages associated with a given location will be shown as a single message.",
+          "scope": "resource"
+        },
         "go.generateTestsFlags": {
           "type": "array",
           "items": {
@@ -2356,6 +2386,12 @@
       }
     },
     "menus": {
+      "commandPalette": [
+        {
+          "command": "go.test.refresh",
+          "when": "false"
+        }
+      ],
       "editor/context": [
         {
           "when": "editorTextFocus && config.go.editorContextMenuCommands.toggleTestFile && resourceLangId == go",
@@ -2437,6 +2473,13 @@
           "command": "go.show.commands",
           "group": "Go group 2"
         }
+      ],
+      "testing/item/context": [
+        {
+          "command": "go.test.refresh",
+          "when": "testId =~ /_test\\.go/",
+          "group": "inline"
+        }
       ]
     }
   }
diff --git a/src/goLanguageServer.ts b/src/goLanguageServer.ts
index 317687d..2698dc1 100644
--- a/src/goLanguageServer.ts
+++ b/src/goLanguageServer.ts
@@ -631,7 +631,9 @@
 							// cause to reorder candiates, which is not ideal.
 							// Force to use non-empty `label`.
 							// https://github.com/golang/vscode-go/issues/441
-							hardcodedFilterText = items[0].label;
+							let { label } = items[0];
+							if (typeof label !== 'string') label = label.label;
+							hardcodedFilterText = label;
 						}
 						for (const item of items) {
 							item.filterText = hardcodedFilterText;
diff --git a/src/goMain.ts b/src/goMain.ts
index 541ad64..5f7d26d 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -114,6 +114,7 @@
 import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey';
 import { ExtensionAPI } from './export';
 import extensionAPI from './extensionAPI';
+import { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer';
 
 export let buildDiagnosticCollection: vscode.DiagnosticCollection;
 export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -335,6 +336,16 @@
 		})
 	);
 
+	if (isVscodeTestingAPIAvailable) {
+		const testExplorer = TestExplorer.setup(ctx);
+
+		ctx.subscriptions.push(
+			vscode.commands.registerCommand('go.test.refresh', (args) => {
+				if (args) testExplorer.resolve(args);
+			})
+		);
+	}
+
 	ctx.subscriptions.push(
 		vscode.commands.registerCommand('go.subtest.cursor', (args) => {
 			const goConfig = getGoConfig();
diff --git a/src/goModules.ts b/src/goModules.ts
index a0b6f51..ab0dd04 100644
--- a/src/goModules.ts
+++ b/src/goModules.ts
@@ -36,7 +36,8 @@
 				return resolve('');
 			}
 			const [goMod] = stdout.split('\n');
-			resolve(goMod);
+			if (goMod === '/dev/null' || goMod === 'NUL') resolve('');
+			else resolve(goMod);
 		});
 	});
 }
diff --git a/src/goSuggest.ts b/src/goSuggest.ts
index 22abcc0..1f3361a 100644
--- a/src/goSuggest.ts
+++ b/src/goSuggest.ts
@@ -122,11 +122,14 @@
 			return;
 		}
 
+		let { label } = item;
+		if (typeof label !== 'string') label = label.label;
+
 		return runGodoc(
 			path.dirname(item.fileName),
 			item.package || path.dirname(item.fileName),
 			item.receiver,
-			item.label,
+			label,
 			token
 		)
 			.then((doc) => {
@@ -358,7 +361,9 @@
 								areCompletionsForPackageSymbols = true;
 							}
 							if (suggest.class === 'package') {
-								const possiblePackageImportPaths = this.getPackageImportPath(item.label);
+								let { label } = item;
+								if (typeof label !== 'string') label = label.label;
+								const possiblePackageImportPaths = this.getPackageImportPath(label);
 								if (possiblePackageImportPaths.length === 1) {
 									item.detail = possiblePackageImportPaths[0];
 								}
diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts
new file mode 100644
index 0000000..94a74a9
--- /dev/null
+++ b/src/goTestExplorer.ts
@@ -0,0 +1,1097 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+	CancellationToken,
+	ConfigurationChangeEvent,
+	DocumentSymbol,
+	ExtensionContext,
+	FileType,
+	Location,
+	OutputChannel,
+	Position,
+	Range,
+	SymbolKind,
+	TestController,
+	TestItem,
+	TestItemCollection,
+	TestMessage,
+	TestRun,
+	TestRunProfileKind,
+	TestRunRequest,
+	TextDocument,
+	TextDocumentChangeEvent,
+	Uri,
+	workspace,
+	WorkspaceFolder,
+	WorkspaceFoldersChangeEvent
+} from 'vscode';
+import vscode = require('vscode');
+import path = require('path');
+import { getModFolderPath, isModSupported } from './goModules';
+import { getCurrentGoPath } from './util';
+import { GoDocumentSymbolProvider } from './goOutline';
+import { getGoConfig } from './config';
+import { getTestFlags, goTest, GoTestOutput } from './testUtils';
+import { outputChannel } from './goStatus';
+
+// Set true only if the Testing API is available (VSCode version >= 1.59).
+export const isVscodeTestingAPIAvailable =
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	'object' === typeof (vscode as any).tests && 'function' === typeof (vscode as any).tests.createTestController;
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace TestExplorer {
+	// exported for tests
+
+	export type FileSystem = Pick<vscode.FileSystem, 'readFile' | 'readDirectory'>;
+
+	export interface Workspace
+		extends Pick<typeof vscode.workspace, 'workspaceFolders' | 'getWorkspaceFolder' | 'textDocuments'> {
+		// use custom FS type
+		readonly fs: FileSystem;
+
+		// only include one overload
+		openTextDocument(uri: Uri): Thenable<TextDocument>;
+	}
+}
+
+async function doSafe<T>(context: string, p: Thenable<T> | (() => T | Thenable<T>), onError?: T): Promise<T> {
+	try {
+		if (typeof p === 'function') {
+			return await p();
+		} else {
+			return await p;
+		}
+	} catch (error) {
+		if (process.env.VSCODE_GO_IN_TEST === '1') {
+			throw error;
+		}
+
+		// TODO internationalization?
+		if (context === 'resolveHandler') {
+			const m = 'Failed to resolve tests';
+			outputChannel.appendLine(`${m}: ${error}`);
+			await vscode.window.showErrorMessage(m);
+		} else if (context === 'runHandler') {
+			const m = 'Failed to execute tests';
+			outputChannel.appendLine(`${m}: ${error}`);
+			await vscode.window.showErrorMessage(m);
+		} else if (/^did/.test(context)) {
+			outputChannel.appendLine(`Failed while handling '${context}': ${error}`);
+		} else {
+			const m = 'An unknown error occurred';
+			outputChannel.appendLine(`${m}: ${error}`);
+			await vscode.window.showErrorMessage(m);
+		}
+		return onError;
+	}
+}
+
+export class TestExplorer {
+	static setup(context: ExtensionContext): TestExplorer {
+		if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable');
+
+		const ctrl = vscode.tests.createTestController('go', 'Go');
+		const getSym = new GoDocumentSymbolProvider().provideDocumentSymbols;
+		const inst = new this(ctrl, workspace, getSym);
+
+		context.subscriptions.push(
+			workspace.onDidChangeConfiguration((x) =>
+				doSafe('onDidChangeConfiguration', inst.didChangeConfiguration(x))
+			)
+		);
+
+		context.subscriptions.push(
+			workspace.onDidOpenTextDocument((x) => doSafe('onDidOpenTextDocument', inst.didOpenTextDocument(x)))
+		);
+
+		context.subscriptions.push(
+			workspace.onDidChangeTextDocument((x) => doSafe('onDidChangeTextDocument', inst.didChangeTextDocument(x)))
+		);
+
+		context.subscriptions.push(
+			workspace.onDidChangeWorkspaceFolders((x) =>
+				doSafe('onDidChangeWorkspaceFolders', inst.didChangeWorkspaceFolders(x))
+			)
+		);
+
+		const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false);
+		context.subscriptions.push(watcher);
+		context.subscriptions.push(watcher.onDidCreate((x) => doSafe('onDidCreate', inst.didCreateFile(x))));
+		context.subscriptions.push(watcher.onDidDelete((x) => doSafe('onDidDelete', inst.didDeleteFile(x))));
+
+		return inst;
+	}
+
+	constructor(
+		public ctrl: TestController,
+		public ws: TestExplorer.Workspace,
+		public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable<DocumentSymbol[]>
+	) {
+		ctrl.resolveHandler = (item) => this.resolve(item);
+		ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq, tok) => this.run(rq, tok), true);
+	}
+
+	/* ***** Interface (external) ***** */
+
+	resolve(item?: TestItem) {
+		return doSafe('resolveHandler', resolve(this, item));
+	}
+
+	run(request: TestRunRequest, token: CancellationToken) {
+		return doSafe('runHandler', runTests(this, request, token));
+	}
+
+	/* ***** Interface (internal) ***** */
+
+	// Create an item.
+	createItem(label: string, uri: Uri, kind: string, name?: string): TestItem {
+		return this.ctrl.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+	}
+
+	// Retrieve an item.
+	getItem(parent: TestItem | undefined, uri: Uri, kind: string, name?: string): TestItem {
+		const items = getChildren(parent || this.ctrl.items);
+		return items.get(testID(uri, kind, name));
+	}
+
+	// Create or retrieve an item.
+	getOrCreateItem(parent: TestItem | undefined, label: string, uri: Uri, kind: string, name?: string): TestItem {
+		const existing = this.getItem(parent, uri, kind, name);
+		if (existing) return existing;
+
+		const item = this.createItem(label, uri, kind, name);
+		getChildren(parent || this.ctrl.items).add(item);
+		return item;
+	}
+
+	// Create or Retrieve a sub test or benchmark. The ID will be of the form:
+	//     file:///path/to/mod/file.go?test#TestXxx/A/B/C
+	getOrCreateSubTest(item: TestItem, name: string): TestItem {
+		const { fragment: parentName, query: kind } = Uri.parse(item.id);
+		const existing = this.getItem(item, item.uri, kind, `${parentName}/${name}`);
+		if (existing) return existing;
+
+		item.canResolveChildren = true;
+		const sub = this.createItem(name, item.uri, kind, `${parentName}/${name}`);
+		item.children.add(sub);
+		sub.range = item.range;
+		return sub;
+	}
+
+	/* ***** Listeners ***** */
+
+	protected async didOpenTextDocument(doc: TextDocument) {
+		await documentUpdate(this, doc);
+	}
+
+	protected async didChangeTextDocument(e: TextDocumentChangeEvent) {
+		await documentUpdate(
+			this,
+			e.document,
+			e.contentChanges.map((x) => x.range)
+		);
+	}
+
+	protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) {
+		if (e.removed.length > 0) {
+			for (const item of collect(this.ctrl.items)) {
+				const uri = Uri.parse(item.id);
+				if (uri.query === 'package') {
+					continue;
+				}
+
+				const ws = this.ws.getWorkspaceFolder(uri);
+				if (!ws) {
+					dispose(item);
+				}
+			}
+		}
+
+		if (e.added) {
+			await resolve(this);
+		}
+	}
+
+	protected async didCreateFile(file: Uri) {
+		await documentUpdate(this, await this.ws.openTextDocument(file));
+	}
+
+	protected async didDeleteFile(file: Uri) {
+		const id = testID(file, 'file');
+		function find(children: TestItemCollection): TestItem {
+			for (const item of collect(children)) {
+				if (item.id === id) {
+					return item;
+				}
+
+				const uri = Uri.parse(item.id);
+				if (!file.path.startsWith(uri.path)) {
+					continue;
+				}
+
+				const found = find(item.children);
+				if (found) {
+					return found;
+				}
+			}
+		}
+
+		const found = find(this.ctrl.items);
+		if (found) {
+			dispose(found);
+			disposeIfEmpty(found.parent);
+		}
+	}
+
+	protected async didChangeConfiguration(e: ConfigurationChangeEvent) {
+		let update = false;
+		for (const item of collect(this.ctrl.items)) {
+			if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
+				dispose(item);
+				update = true;
+			}
+		}
+
+		if (update) {
+			resolve(this);
+		}
+	}
+}
+
+// Construct an ID for an item. Exported for tests.
+// - Module:    file:///path/to/mod?module
+// - Package:   file:///path/to/mod/pkg?package
+// - File:      file:///path/to/mod/file.go?file
+// - Test:      file:///path/to/mod/file.go?test#TestXxx
+// - Benchmark: file:///path/to/mod/file.go?benchmark#BenchmarkXxx
+// - Example:   file:///path/to/mod/file.go?example#ExampleXxx
+export function testID(uri: Uri, kind: string, name?: string): string {
+	uri = uri.with({ query: kind });
+	if (name) uri = uri.with({ fragment: name });
+	return uri.toString();
+}
+
+function collect(items: TestItemCollection): TestItem[] {
+	const r: TestItem[] = [];
+	items.forEach((i) => r.push(i));
+	return r;
+}
+
+function getChildren(parent: TestItem | TestItemCollection): TestItemCollection {
+	if ('children' in parent) {
+		return parent.children;
+	}
+	return parent;
+}
+
+function dispose(item: TestItem) {
+	item.parent.children.delete(item.id);
+}
+
+// Dispose of the item if it has no children, recursively. This facilitates
+// cleaning up package/file trees that contain no tests.
+function disposeIfEmpty(item: TestItem) {
+	// Don't dispose of empty top-level items
+	const uri = Uri.parse(item.id);
+	if (uri.query === 'module' || uri.query === 'workspace' || (uri.query === 'package' && !item.parent)) {
+		return;
+	}
+
+	if (item.children.size > 0) {
+		return;
+	}
+
+	dispose(item);
+	disposeIfEmpty(item.parent);
+}
+
+// Dispose of the children of a test. Sub-tests and sub-benchmarks are
+// discovered emperically (from test output) not semantically (from code), so
+// there are situations where they must be discarded.
+function discardChildren(item: TestItem) {
+	item.canResolveChildren = false;
+	item.children.forEach(dispose);
+}
+
+// If a test/benchmark with children is relocated, update the children's
+// location.
+function relocateChildren(item: TestItem) {
+	for (const child of collect(item.children)) {
+		child.range = item.range;
+		relocateChildren(child);
+	}
+}
+
+// Retrieve or create an item for a Go module.
+async function getModule(expl: TestExplorer, uri: Uri): Promise<TestItem> {
+	const existing = expl.getItem(null, uri, 'module');
+	if (existing) {
+		return existing;
+	}
+
+	// Use the module name as the label
+	const goMod = Uri.joinPath(uri, 'go.mod');
+	const contents = await expl.ws.fs.readFile(goMod);
+	const modLine = contents.toString().split('\n', 2)[0];
+	const match = modLine.match(/^module (?<name>.*?)(?:\s|\/\/|$)/);
+	const item = expl.getOrCreateItem(null, match.groups.name, uri, 'module');
+	item.canResolveChildren = true;
+	return item;
+}
+
+// Retrieve or create an item for a workspace folder that is not a module.
+async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise<TestItem> {
+	const existing = expl.getItem(null, ws.uri, 'workspace');
+	if (existing) {
+		return existing;
+	}
+
+	// Use the workspace folder name as the label
+	const item = expl.getOrCreateItem(null, ws.name, ws.uri, 'workspace');
+	item.canResolveChildren = true;
+	return item;
+}
+
+// Retrieve or create an item for a Go package.
+async function getPackage(expl: TestExplorer, uri: Uri): Promise<TestItem> {
+	let item: TestItem;
+
+	const nested = getGoConfig(uri).get('testExplorerPackages') === 'nested';
+	const modDir = await getModFolderPath(uri, true);
+	const wsfolder = workspace.getWorkspaceFolder(uri);
+	if (modDir) {
+		// If the package is in a module, add it as a child of the module
+		let parent = await getModule(expl, uri.with({ path: modDir, query: '', fragment: '' }));
+		if (uri.path === parent.uri.path) {
+			return parent;
+		}
+
+		if (nested) {
+			const bits = path.relative(parent.uri.path, uri.path).split(path.sep);
+			while (bits.length > 1) {
+				const dir = bits.shift();
+				const dirUri = uri.with({ path: path.join(parent.uri.path, dir), query: '', fragment: '' });
+				parent = expl.getOrCreateItem(parent, dir, dirUri, 'package');
+			}
+		}
+
+		const label = uri.path.startsWith(parent.uri.path) ? uri.path.substring(parent.uri.path.length + 1) : uri.path;
+		item = expl.getOrCreateItem(parent, label, uri, 'package');
+	} else if (wsfolder) {
+		// If the package is in a workspace folder, add it as a child of the workspace
+		const workspace = await getWorkspace(expl, wsfolder);
+		const existing = expl.getItem(workspace, uri, 'package');
+		if (existing) {
+			return existing;
+		}
+
+		const label = uri.path.startsWith(wsfolder.uri.path)
+			? uri.path.substring(wsfolder.uri.path.length + 1)
+			: uri.path;
+		item = expl.getOrCreateItem(workspace, label, uri, 'package');
+	} else {
+		// Otherwise, add it directly to the root
+		const existing = expl.getItem(null, uri, 'package');
+		if (existing) {
+			return existing;
+		}
+
+		const srcPath = path.join(getCurrentGoPath(uri), 'src');
+		const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path;
+		item = expl.getOrCreateItem(null, label, uri, 'package');
+	}
+
+	item.canResolveChildren = true;
+	return item;
+}
+
+// Retrieve or create an item for a Go file.
+async function getFile(expl: TestExplorer, uri: Uri): Promise<TestItem> {
+	const dir = path.dirname(uri.path);
+	const pkg = await getPackage(expl, uri.with({ path: dir, query: '', fragment: '' }));
+	const existing = expl.getItem(pkg, uri, 'file');
+	if (existing) {
+		return existing;
+	}
+
+	const label = path.basename(uri.path);
+	const item = expl.getOrCreateItem(pkg, label, uri, 'file');
+	item.canResolveChildren = true;
+	return item;
+}
+
+// Recursively process a Go AST symbol. If the symbol represents a test,
+// benchmark, or example function, a test item will be created for it, if one
+// does not already exist. If the symbol is not a function and contains
+// children, those children will be processed recursively.
+async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: Set<string>, symbol: DocumentSymbol) {
+	// Skip TestMain(*testing.M) - allow TestMain(*testing.T)
+	if (symbol.name === 'TestMain' && /\*testing.M\)/.test(symbol.detail)) {
+		return;
+	}
+
+	// Recursively process symbols that are nested
+	if (symbol.kind !== SymbolKind.Function) {
+		for (const sym of symbol.children) await processSymbol(expl, uri, file, seen, sym);
+		return;
+	}
+
+	const match = symbol.name.match(/^(?<type>Test|Example|Benchmark)/);
+	if (!match) {
+		return;
+	}
+
+	seen.add(symbol.name);
+
+	const kind = match.groups.type.toLowerCase();
+	const existing = expl.getItem(file, uri, kind, symbol.name);
+	if (existing) {
+		if (!existing.range.isEqual(symbol.range)) {
+			existing.range = symbol.range;
+			relocateChildren(existing);
+		}
+		return existing;
+	}
+
+	const item = expl.getOrCreateItem(file, symbol.name, uri, kind, symbol.name);
+	item.range = symbol.range;
+}
+
+// Processes a Go document, calling processSymbol for each symbol in the
+// document.
+//
+// Any previously existing tests that no longer have a corresponding symbol in
+// the file will be disposed. If the document contains no tests, it will be
+// disposed.
+async function processDocument(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) {
+	const seen = new Set<string>();
+	const item = await getFile(expl, doc.uri);
+	const symbols = await expl.provideDocumentSymbols(doc, null);
+	for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol);
+
+	for (const child of collect(item.children)) {
+		const uri = Uri.parse(child.id);
+		if (!seen.has(uri.fragment)) {
+			dispose(child);
+			continue;
+		}
+
+		if (ranges?.some((r) => !!child.range.intersection(r))) {
+			discardChildren(child);
+		}
+	}
+
+	disposeIfEmpty(item);
+}
+
+// Reasons to stop walking
+enum WalkStop {
+	None = 0, // Don't stop
+	Abort, // Abort the walk
+	Current, // Stop walking the current directory
+	Files, // Skip remaining files
+	Directories // Skip remaining directories
+}
+
+// Recursively walk a directory, breadth first.
+async function walk(
+	fs: TestExplorer.FileSystem,
+	uri: Uri,
+	cb: (dir: Uri, file: string, type: FileType) => Promise<WalkStop | undefined>
+): Promise<void> {
+	let dirs = [uri];
+
+	// While there are directories to be scanned
+	while (dirs.length > 0) {
+		const d = dirs;
+		dirs = [];
+
+		outer: for (const uri of d) {
+			const dirs2 = [];
+			let skipFiles = false,
+				skipDirs = false;
+
+			// Scan the directory
+			inner: for (const [file, type] of await fs.readDirectory(uri)) {
+				if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) {
+					continue;
+				}
+
+				// Ignore all dotfiles
+				if (file.startsWith('.')) {
+					continue;
+				}
+
+				if (type === FileType.Directory) {
+					dirs2.push(Uri.joinPath(uri, file));
+				}
+
+				const s = await cb(uri, file, type);
+				switch (s) {
+					case WalkStop.Abort:
+						// Immediately abort the entire walk
+						return;
+
+					case WalkStop.Current:
+						// Immediately abort the current directory
+						continue outer;
+
+					case WalkStop.Files:
+						// Skip all subsequent files in the current directory
+						skipFiles = true;
+						if (skipFiles && skipDirs) {
+							break inner;
+						}
+						break;
+
+					case WalkStop.Directories:
+						// Skip all subsequent directories in the current directory
+						skipDirs = true;
+						if (skipFiles && skipDirs) {
+							break inner;
+						}
+						break;
+				}
+			}
+
+			// Add subdirectories to the recursion list
+			dirs.push(...dirs2);
+		}
+	}
+}
+
+// Walk the workspace, looking for Go modules. Returns a map indicating paths
+// that are modules (value == true) and paths that are not modules but contain
+// Go files (value == false).
+async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise<Map<string, boolean>> {
+	const found = new Map<string, boolean>();
+	await walk(fs, uri, async (dir, file, type) => {
+		if (type !== FileType.File) {
+			return;
+		}
+
+		if (file === 'go.mod') {
+			// BUG(firelizard18): This does not create a separate entry for
+			// modules within a module. Thus, tests in a module within another
+			// module will appear under the top-level module's tree. This may or
+			// may not be acceptable.
+			found.set(dir.toString(), true);
+			return WalkStop.Current;
+		}
+
+		if (file.endsWith('.go')) {
+			found.set(dir.toString(), false);
+		}
+	});
+	return found;
+}
+
+// Walk the workspace, calling the callback for any directory that contains a Go
+// test file.
+async function walkPackages(fs: TestExplorer.FileSystem, uri: Uri, cb: (uri: Uri) => Promise<unknown>) {
+	await walk(fs, uri, async (dir, file) => {
+		if (file.endsWith('_test.go')) {
+			await cb(dir);
+			return WalkStop.Files;
+		}
+	});
+}
+
+// Handle opened documents, document changes, and file creation.
+async function documentUpdate(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) {
+	if (!doc.uri.path.endsWith('_test.go')) {
+		return;
+	}
+
+	if (doc.uri.scheme === 'git') {
+		// TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
+		return;
+	}
+
+	await processDocument(expl, doc, ranges);
+}
+
+// TestController.resolveChildrenHandler callback
+async function resolve(expl: TestExplorer, item?: TestItem) {
+	// Expand the root item - find all modules and workspaces
+	if (!item) {
+		// Dispose of package entries at the root if they are now part of a workspace folder
+		for (const item of collect(expl.ctrl.items)) {
+			const uri = Uri.parse(item.id);
+			if (uri.query !== 'package') {
+				continue;
+			}
+
+			if (expl.ws.getWorkspaceFolder(uri)) {
+				dispose(item);
+			}
+		}
+
+		// Create entries for all modules and workspaces
+		for (const folder of expl.ws.workspaceFolders || []) {
+			const found = await walkWorkspaces(expl.ws.fs, folder.uri);
+			let needWorkspace = false;
+			for (const [uri, isMod] of found.entries()) {
+				if (!isMod) {
+					needWorkspace = true;
+					continue;
+				}
+
+				await getModule(expl, Uri.parse(uri));
+			}
+
+			// If the workspace folder contains any Go files not in a module, create a workspace entry
+			if (needWorkspace) {
+				await getWorkspace(expl, folder);
+			}
+		}
+		return;
+	}
+
+	const uri = Uri.parse(item.id);
+
+	// The user expanded a module or workspace - find all packages
+	if (uri.query === 'module' || uri.query === 'workspace') {
+		await walkPackages(expl.ws.fs, uri, async (uri) => {
+			await getPackage(expl, uri);
+		});
+	}
+
+	// The user expanded a module or package - find all files
+	if (uri.query === 'module' || uri.query === 'package') {
+		for (const [file, type] of await expl.ws.fs.readDirectory(uri)) {
+			if (type !== FileType.File || !file.endsWith('_test.go')) {
+				continue;
+			}
+
+			await getFile(expl, Uri.joinPath(uri, file));
+		}
+	}
+
+	// The user expanded a file - find all functions
+	if (uri.query === 'file') {
+		const doc = await expl.ws.openTextDocument(uri.with({ query: '', fragment: '' }));
+		await processDocument(expl, doc);
+	}
+
+	// TODO(firelizzard18): If uri.query is test or benchmark, this is where we
+	// would discover sub tests or benchmarks, if that is feasible.
+}
+
+type CollectedTest = { item: TestItem; explicitlyIncluded: boolean };
+
+// Recursively find all tests, benchmarks, and examples within a
+// module/package/etc, minus exclusions. Map tests to the package they are
+// defined in, and track files.
+async function collectTests(
+	expl: TestExplorer,
+	item: TestItem,
+	explicitlyIncluded: boolean,
+	excluded: TestItem[],
+	functions: Map<string, CollectedTest[]>,
+	docs: Set<Uri>
+) {
+	for (let i = item; i.parent; i = i.parent) {
+		if (excluded.indexOf(i) >= 0) {
+			return;
+		}
+	}
+
+	const uri = Uri.parse(item.id);
+	if (!uri.fragment) {
+		if (item.children.size === 0) {
+			await resolve(expl, item);
+		}
+
+		for (const child of collect(item.children)) {
+			await collectTests(expl, child, false, excluded, functions, docs);
+		}
+		return;
+	}
+
+	const file = uri.with({ query: '', fragment: '' });
+	docs.add(file);
+
+	const dir = file.with({ path: path.dirname(uri.path) }).toString();
+	if (functions.has(dir)) {
+		functions.get(dir).push({ item, explicitlyIncluded });
+	} else {
+		functions.set(dir, [{ item, explicitlyIncluded }]);
+	}
+	return;
+}
+
+// TestRunOutput is a fake OutputChannel that forwards all test output to the test API
+// console.
+class TestRunOutput implements OutputChannel {
+	readonly name: string;
+	readonly lines: string[] = [];
+
+	constructor(private run: TestRun) {
+		this.name = `Test run at ${new Date()}`;
+	}
+
+	append(value: string) {
+		this.run.appendOutput(value);
+	}
+
+	appendLine(value: string) {
+		this.lines.push(value);
+		this.run.appendOutput(value + '\r\n');
+	}
+
+	clear() {}
+	// eslint-disable-next-line @typescript-eslint/no-unused-vars
+	show(...args: unknown[]) {}
+	hide() {}
+	dispose() {}
+}
+
+// Resolve a test name to a test item. If the test name is TestXxx/Foo, Foo is
+// created as a child of TestXxx. The same is true for TestXxx#Foo and
+// TestXxx/#Foo.
+function resolveTestName(expl: TestExplorer, tests: Record<string, TestItem>, name: string): TestItem | undefined {
+	if (!name) {
+		return;
+	}
+
+	const parts = name.split(/[#/]+/);
+	let test = tests[parts[0]];
+	if (!test) {
+		return;
+	}
+
+	for (const part of parts.slice(1)) {
+		test = expl.getOrCreateSubTest(test, part);
+	}
+	return test;
+}
+
+// Process benchmark events (see test_events.md)
+function consumeGoBenchmarkEvent(
+	expl: TestExplorer,
+	run: TestRun,
+	benchmarks: Record<string, TestItem>,
+	complete: Set<TestItem>,
+	e: GoTestOutput
+) {
+	if (e.Test) {
+		// Find (or create) the (sub)benchmark
+		const test = resolveTestName(expl, benchmarks, e.Test);
+		if (!test) {
+			return;
+		}
+
+		switch (e.Action) {
+			case 'fail': // Failed
+				run.failed(test, { message: 'Failed' });
+				complete.add(test);
+				break;
+
+			case 'skip': // Skipped
+				run.skipped(test);
+				complete.add(test);
+				break;
+		}
+
+		return;
+	}
+
+	// Ignore anything that's not an output event
+	if (!e.Output) {
+		return;
+	}
+
+	// On start:    "BenchmarkFooBar"
+	// On complete: "BenchmarkFooBar-4    123456    123.4 ns/op    123 B/op    12 allocs/op"
+
+	// Extract the benchmark name and status
+	const m = e.Output.match(/^(?<name>Benchmark[/\w]+)(?:-(?<procs>\d+)\s+(?<result>.*))?(?:$|\n)/);
+	if (!m) {
+		// If the output doesn't start with `BenchmarkFooBar`, ignore it
+		return;
+	}
+
+	// Find (or create) the (sub)benchmark
+	const test = resolveTestName(expl, benchmarks, m.groups.name);
+	if (!test) {
+		return;
+	}
+
+	// If output includes benchmark results, the benchmark passed. If output
+	// only includes the benchmark name, the benchmark is running.
+	if (m.groups.result) {
+		run.passed(test);
+		complete.add(test);
+		vscode.commands.executeCommand('testing.showMostRecentOutput');
+	} else {
+		run.started(test);
+	}
+}
+
+// Pass any incomplete benchmarks (see test_events.md)
+function markComplete(items: Record<string, TestItem>, complete: Set<TestItem>, fn: (item: TestItem) => void) {
+	function mark(item: TestItem) {
+		if (!complete.has(item)) {
+			fn(item);
+		}
+		for (const child of collect(item.children)) {
+			mark(child);
+		}
+	}
+
+	for (const name in items) {
+		mark(items[name]);
+	}
+}
+
+// Process test events (see test_events.md)
+function consumeGoTestEvent(
+	expl: TestExplorer,
+	run: TestRun,
+	tests: Record<string, TestItem>,
+	record: Map<TestItem, string[]>,
+	complete: Set<TestItem>,
+	concat: boolean,
+	e: GoTestOutput
+) {
+	const test = resolveTestName(expl, tests, e.Test);
+	if (!test) {
+		return;
+	}
+
+	switch (e.Action) {
+		case 'cont':
+		case 'pause':
+			// ignore
+			break;
+
+		case 'run':
+			run.started(test);
+			break;
+
+		case 'pass':
+			// TODO(firelizzard18): add messages on pass, once that capability
+			// is added.
+			complete.add(test);
+			run.passed(test, e.Elapsed * 1000);
+			break;
+
+		case 'fail': {
+			complete.add(test);
+			const messages = parseOutput(test, record.get(test) || []);
+
+			if (!concat) {
+				run.failed(test, messages, e.Elapsed * 1000);
+				break;
+			}
+
+			const merged = new Map<string, TestMessage>();
+			for (const { message, location } of messages) {
+				const loc = `${location.uri}:${location.range.start.line}`;
+				if (merged.has(loc)) {
+					merged.get(loc).message += '\n' + message;
+				} else {
+					merged.set(loc, { message, location });
+				}
+			}
+
+			run.failed(test, Array.from(merged.values()), e.Elapsed * 1000);
+			break;
+		}
+
+		case 'skip':
+			complete.add(test);
+			run.skipped(test);
+			break;
+
+		case 'output':
+			if (/^(=== RUN|\s*--- (FAIL|PASS): )/.test(e.Output)) {
+				break;
+			}
+
+			if (record.has(test)) record.get(test).push(e.Output);
+			else record.set(test, [e.Output]);
+			break;
+	}
+}
+
+function parseOutput(test: TestItem, output: string[]): TestMessage[] {
+	const messages: TestMessage[] = [];
+
+	const uri = Uri.parse(test.id);
+	const gotI = output.indexOf('got:\n');
+	const wantI = output.indexOf('want:\n');
+	if (uri.query === 'example' && gotI >= 0 && wantI >= 0) {
+		const got = output.slice(gotI + 1, wantI).join('');
+		const want = output.slice(wantI + 1).join('');
+		const message = TestMessage.diff('Output does not match', want, got);
+		message.location = new Location(test.uri, test.range.start);
+		messages.push(message);
+		output = output.slice(0, gotI);
+	}
+
+	let current: Location;
+	const dir = Uri.joinPath(test.uri, '..');
+	for (const line of output) {
+		const m = line.match(/^\s*(?<file>.*\.go):(?<line>\d+): ?(?<message>.*\n)$/);
+		if (m) {
+			const file = Uri.joinPath(dir, m.groups.file);
+			const ln = Number(m.groups.line) - 1; // VSCode uses 0-based line numbering (internally)
+			current = new Location(file, new Position(ln, 0));
+			messages.push({ message: m.groups.message, location: current });
+		} else if (current) {
+			messages.push({ message: line, location: current });
+		}
+	}
+
+	return messages;
+}
+
+function isBuildFailure(output: string[]): boolean {
+	const rePkg = /^# (?<pkg>[\w/.-]+)(?: \[(?<test>[\w/.-]+).test\])?/;
+
+	// TODO(firelizzard18): Add more sophisticated check for build failures?
+	return output.some((x) => rePkg.test(x));
+}
+
+// Execute tests - TestController.runTest callback
+async function runTests(expl: TestExplorer, request: TestRunRequest, token: CancellationToken) {
+	const collected = new Map<string, CollectedTest[]>();
+	const docs = new Set<Uri>();
+	if (request.include) {
+		for (const item of request.include) {
+			await collectTests(expl, item, true, request.exclude || [], collected, docs);
+		}
+	} else {
+		const promises: Promise<unknown>[] = [];
+		expl.ctrl.items.forEach((item) => {
+			const p = collectTests(expl, item, true, request.exclude || [], collected, docs);
+			promises.push(p);
+		});
+		await Promise.all(promises);
+	}
+
+	// Save all documents that contain a test we're about to run, to ensure `go
+	// test` has the latest changes
+	await Promise.all(expl.ws.textDocuments.filter((x) => docs.has(x.uri)).map((x) => x.save()));
+
+	let hasBench = false,
+		hasNonBench = false;
+	for (const items of collected.values()) {
+		for (const { item } of items) {
+			const uri = Uri.parse(item.id);
+			if (uri.query === 'benchmark') hasBench = true;
+			else hasNonBench = true;
+		}
+	}
+
+	const run = expl.ctrl.createTestRun(request);
+	const outputChannel = new TestRunOutput(run);
+	for (const [dir, items] of collected.entries()) {
+		const uri = Uri.parse(dir);
+		const isMod = await isModSupported(uri, true);
+		const goConfig = getGoConfig(uri);
+		const flags = getTestFlags(goConfig);
+		const includeBench = getGoConfig(uri).get('testExplorerRunBenchmarks');
+
+		// Separate tests and benchmarks and mark them as queued for execution.
+		// Clear any sub tests/benchmarks generated by a previous run.
+		const tests: Record<string, TestItem> = {};
+		const benchmarks: Record<string, TestItem> = {};
+		for (const { item, explicitlyIncluded } of items) {
+			const uri = Uri.parse(item.id);
+			if (/[/#]/.test(uri.fragment)) {
+				// running sub-tests is not currently supported
+				vscode.window.showErrorMessage(`Cannot run ${uri.fragment} - running sub-tests is not supported`);
+				continue;
+			}
+
+			// When the user clicks the run button on a package, they expect all
+			// of the tests within that package to run - they probably don't
+			// want to run the benchmarks. So if a benchmark is not explicitly
+			// selected, don't run benchmarks. But the user may disagree, so
+			// behavior can be changed with `go.testExplorerRunBenchmarks`.
+			// However, if the user clicks the run button on a file or package
+			// that contains benchmarks and nothing else, they likely expect
+			// those benchmarks to run.
+			if (uri.query === 'benchmark' && !explicitlyIncluded && !includeBench && !(hasBench && !hasNonBench)) {
+				continue;
+			}
+
+			item.error = null;
+			run.enqueued(item);
+			discardChildren(item);
+
+			if (uri.query === 'benchmark') {
+				benchmarks[uri.fragment] = item;
+			} else {
+				tests[uri.fragment] = item;
+			}
+		}
+
+		const record = new Map<TestItem, string[]>();
+		const testFns = Object.keys(tests);
+		const benchmarkFns = Object.keys(benchmarks);
+		const concat = goConfig.get<boolean>('testExplorerConcatenateMessages');
+
+		// Run tests
+		if (testFns.length > 0) {
+			const complete = new Set<TestItem>();
+			const success = await goTest({
+				goConfig,
+				flags,
+				isMod,
+				outputChannel,
+				dir: uri.fsPath,
+				functions: testFns,
+				cancel: token,
+				goTestOutputConsumer: (e) => consumeGoTestEvent(expl, run, tests, record, complete, concat, e)
+			});
+			if (!success) {
+				if (isBuildFailure(outputChannel.lines)) {
+					markComplete(tests, new Set(), (item) => {
+						run.errored(item, { message: 'Compilation failed' });
+						item.error = 'Compilation failed';
+					});
+				} else {
+					markComplete(tests, complete, (x) => run.skipped(x));
+				}
+			}
+		}
+
+		// Run benchmarks
+		if (benchmarkFns.length > 0) {
+			const complete = new Set<TestItem>();
+			const success = await goTest({
+				goConfig,
+				flags,
+				isMod,
+				outputChannel,
+				dir: uri.fsPath,
+				functions: benchmarkFns,
+				isBenchmark: true,
+				cancel: token,
+				goTestOutputConsumer: (e) => consumeGoBenchmarkEvent(expl, run, benchmarks, complete, e)
+			});
+
+			// Explicitly complete any incomplete benchmarks (see test_events.md)
+			if (success) {
+				markComplete(benchmarks, complete, (x) => run.passed(x));
+			} else if (isBuildFailure(outputChannel.lines)) {
+				markComplete(benchmarks, new Set(), (item) => {
+					// TODO change to errored when that is added back
+					run.failed(item, { message: 'Compilation failed' });
+					item.error = 'Compilation failed';
+				});
+			} else {
+				markComplete(benchmarks, complete, (x) => run.skipped(x));
+			}
+		}
+	}
+
+	run.end();
+}
diff --git a/src/testUtils.ts b/src/testUtils.ts
index 77e812d..c33bad1 100644
--- a/src/testUtils.ts
+++ b/src/testUtils.ts
@@ -85,6 +85,14 @@
 	 * Output channel for test output.
 	 */
 	outputChannel?: vscode.OutputChannel;
+	/**
+	 * Can be used to terminate the test process.
+	 */
+	cancel?: vscode.CancellationToken;
+	/**
+	 * Output channel for JSON test output.
+	 */
+	goTestOutputConsumer?: (_: GoTestOutput) => void;
 }
 
 export function getTestEnvVars(config: vscode.WorkspaceConfiguration): any {
@@ -236,10 +244,12 @@
  * which is a subset of https://golang.org/cmd/test2json/#hdr-Output_Format
  * and includes only the fields that we are using.
  */
-interface GoTestOutput {
+export interface GoTestOutput {
 	Action: string;
 	Output?: string;
 	Package?: string;
+	Test?: string;
+	Elapsed?: number; // seconds
 }
 
 /**
@@ -294,9 +304,16 @@
 			const outBuf = new LineBuffer();
 			const errBuf = new LineBuffer();
 
+			testconfig.cancel?.onCancellationRequested(() => killProcessTree(tp));
+
 			const testResultLines: string[] = [];
 			const processTestResultLine = addJSONFlag
-				? processTestResultLineInJSONMode(pkgMap, currentGoWorkspace, outputChannel)
+				? processTestResultLineInJSONMode(
+						pkgMap,
+						currentGoWorkspace,
+						outputChannel,
+						testconfig.goTestOutputConsumer
+				  )
 				: processTestResultLineInStandardMode(pkgMap, currentGoWorkspace, testResultLines, outputChannel);
 
 			outBuf.onLine((line) => processTestResultLine(line));
@@ -443,7 +460,7 @@
 	const outArgs = args.slice(0); // command to show
 
 	// if user set -v, set -json to emulate streaming test output
-	const addJSONFlag = userFlags.includes('-v') && !userFlags.includes('-json');
+	const addJSONFlag = (userFlags.includes('-v') || testconfig.goTestOutputConsumer) && !userFlags.includes('-json');
 	if (addJSONFlag) {
 		args.push('-json'); // this is not shown to the user.
 	}
@@ -478,11 +495,15 @@
 function processTestResultLineInJSONMode(
 	pkgMap: Map<string, string>,
 	currentGoWorkspace: string,
-	outputChannel: vscode.OutputChannel
+	outputChannel: vscode.OutputChannel,
+	goTestOutputConsumer?: (_: GoTestOutput) => void
 ) {
 	return (line: string) => {
 		try {
 			const m = <GoTestOutput>JSON.parse(line);
+			if (goTestOutputConsumer) {
+				goTestOutputConsumer(m);
+			}
 			if (m.Action !== 'output' || !m.Output) {
 				return;
 			}
diff --git a/src/test_events.md b/src/test_events.md
new file mode 100644
index 0000000..b3abe93
--- /dev/null
+++ b/src/test_events.md
@@ -0,0 +1,38 @@
+# Go test events
+
+Running tests with the `-json` flag or passing test output through `go tool
+test2json1` will produce a stream of JSON events. Each event specifies an
+action, such as `run`, `pass`, `output`, etc. An event *may* specify what test
+it belongs to. The VSCode Go test controller must capture these events in order
+to notify VSCode of test output and lifecycle events.
+
+## Tests
+
+Processing test events generated by `TestXxx(*testing.T)` functions is easy.
+Events with an empty `Test` field can be ignored, and all other events have a
+meaningful `Action` field. Output is recorded, and run/pass/fail/skip events are
+converted to VSCode test API events.
+
+[go#37555](https://github.com/golang/go/issues/37555) did require special
+handling, but that only appeared in Go 1.14 and was backported to 1.14.1.
+
+## Benchmarks
+
+Test events generated by `BenchmarkXxx(*testing.B)` functions require
+significantly more processing. If a benchmark fails or is skipped, the `Test`
+and `Action` fields are populated appropriately. Otherwise, `Test` is empty and
+`Action` is always `output`. Thus, nominal lifecycle events (run/pass) must be
+deduced purely from test output. When a benchmark begins, an output such as
+`BenchmarkFooBar\n` is produced. When a benchmark completes, an output such as
+`BencmarkFooBar-4  123456  123.4 ns/op  123 B/op  12 allocs/op` is produced. No
+explicit `run` or `pass` events are generated. Thus:
+
+- When `BenchmarkFooBar\n` is seen, the benchmark will be marked as running
+- When an explicit fail/skip is seen, the benchmark will be marked as failed/skipped
+- When benchmark results are seen, the benchmark will be marked as passed
+
+Thus, a benchmark that does not produce results (and does not fail or skip) will
+never produce an event indicating that it has completed. Benchmarks that call
+`(*testing.B).Run` will not produce results. In practice, this means that any
+incomplete benchmarks must be explicitly marked as passed once `go test`
+returns.
\ No newline at end of file
diff --git a/test/gopls/index.ts b/test/gopls/index.ts
index f332137..7d7be4a 100644
--- a/test/gopls/index.ts
+++ b/test/gopls/index.ts
@@ -10,6 +10,7 @@
 export function run(): Promise<void> {
 	// Create the mocha test
 	const mocha = new Mocha({
+		grep: process.env.MOCHA_GREP,
 		ui: 'tdd'
 	});
 
diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts
new file mode 100644
index 0000000..645d73e
--- /dev/null
+++ b/test/integration/goTestExplorer.test.ts
@@ -0,0 +1,414 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import assert = require('assert');
+import path = require('path');
+import {
+	DocumentSymbol,
+	FileType,
+	TestItem,
+	Uri,
+	TextDocument,
+	SymbolKind,
+	Range,
+	Position,
+	TestItemCollection,
+	TextDocumentChangeEvent
+} from 'vscode';
+import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules';
+import { TestExplorer, testID } from '../../src/goTestExplorer';
+import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
+
+type Files = Record<string, string | { contents: string; language: string }>;
+
+interface TestCase {
+	workspace: string[];
+	files: Files;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function symbols(doc: TextDocument, token: unknown): Thenable<DocumentSymbol[]> {
+	const syms: DocumentSymbol[] = [];
+	const range = new Range(new Position(0, 0), new Position(0, 0));
+	doc.getText().replace(/^func (Test|Benchmark|Example)([A-Z]\w+)(\(.*\))/gm, (m, type, name, details) => {
+		syms.push(new DocumentSymbol(type + name, details, SymbolKind.Function, range, range));
+		return m;
+	});
+	return Promise.resolve(syms);
+}
+
+function setup(folders: string[], files: Files) {
+	return setupCtor(folders, files, TestExplorer);
+}
+
+function setupCtor<T extends TestExplorer>(
+	folders: string[],
+	files: Files,
+	ctor: new (...args: ConstructorParameters<typeof TestExplorer>) => T
+) {
+	const ws = MockTestWorkspace.from(folders, files);
+	const ctrl = new MockTestController();
+	const expl = new ctor(ctrl, ws, symbols);
+
+	function walk(dir: Uri, modpath?: string) {
+		const dirs: Uri[] = [];
+		for (const [name, type] of ws.fs.dirs.get(dir.toString())) {
+			const uri = dir.with({ path: path.join(dir.path, name) });
+			if (type === FileType.Directory) {
+				dirs.push(uri);
+			} else if (name === 'go.mod') {
+				modpath = dir.path;
+			}
+		}
+		pkg2mod[dir.path] = modpath || '';
+		for (const dir of dirs) {
+			walk(dir, modpath);
+		}
+	}
+
+	// prevent getModFolderPath from actually doing anything;
+	for (const pkg in pkg2mod) delete pkg2mod[pkg];
+	walk(Uri.file('/'));
+
+	return { ctrl, expl, ws };
+}
+
+function assertTestItems(items: TestItemCollection, expect: string[]) {
+	const actual: string[] = [];
+	function walk(items: TestItemCollection) {
+		items.forEach((item) => {
+			actual.push(item.id);
+			walk(item.children);
+		});
+	}
+	walk(items);
+	assert.deepStrictEqual(actual, expect);
+}
+
+suite('Test Explorer', () => {
+	suite('Items', () => {
+		interface TC extends TestCase {
+			item?: ([string, string, string] | [string, string, string, string])[];
+			expect: string[];
+		}
+
+		const cases: Record<string, Record<string, TC>> = {
+			Root: {
+				'Basic module': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main.go': 'package main'
+					},
+					expect: ['file:///src/proj?module']
+				},
+				'Basic workspace': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/main.go': 'package main'
+					},
+					expect: ['file:///src/proj?workspace']
+				},
+				'Module and workspace': {
+					workspace: ['/src/proj1', '/src/proj2'],
+					files: {
+						'/src/proj1/go.mod': 'module test',
+						'/src/proj2/main.go': 'package main'
+					},
+					expect: ['file:///src/proj1?module', 'file:///src/proj2?workspace']
+				},
+				'Module in workspace': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/mod/go.mod': 'module test',
+						'/src/proj/main.go': 'package main'
+					},
+					expect: ['file:///src/proj/mod?module', 'file:///src/proj?workspace']
+				}
+			},
+			Module: {
+				'Empty': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main.go': 'package main'
+					},
+					item: [['test', '/src/proj', 'module']],
+					expect: []
+				},
+				'Root package': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main_test.go': 'package main'
+					},
+					item: [['test', '/src/proj', 'module']],
+					expect: ['file:///src/proj/main_test.go?file']
+				},
+				'Sub packages': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/foo/main_test.go': 'package main',
+						'/src/proj/bar/main_test.go': 'package main'
+					},
+					item: [['test', '/src/proj', 'module']],
+					expect: ['file:///src/proj/foo?package', 'file:///src/proj/bar?package']
+				},
+				'Nested packages': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main_test.go': 'package main',
+						'/src/proj/foo/main_test.go': 'package main',
+						'/src/proj/foo/bar/main_test.go': 'package main'
+					},
+					item: [['test', '/src/proj', 'module']],
+					expect: [
+						'file:///src/proj/foo?package',
+						'file:///src/proj/foo/bar?package',
+						'file:///src/proj/main_test.go?file'
+					]
+				}
+			},
+			Package: {
+				'Empty': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/pkg/main.go': 'package main'
+					},
+					item: [
+						['test', '/src/proj', 'module'],
+						['pkg', '/src/proj/pkg', 'package']
+					],
+					expect: []
+				},
+				'Flat': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/pkg/main_test.go': 'package main',
+						'/src/proj/pkg/sub/main_test.go': 'package main'
+					},
+					item: [
+						['test', '/src/proj', 'module'],
+						['pkg', '/src/proj/pkg', 'package']
+					],
+					expect: ['file:///src/proj/pkg/main_test.go?file']
+				},
+				'Sub package': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/pkg/sub/main_test.go': 'package main'
+					},
+					item: [
+						['test', '/src/proj', 'module'],
+						['pkg', '/src/proj/pkg', 'package']
+					],
+					expect: []
+				}
+			},
+			File: {
+				'Empty': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main_test.go': 'package main'
+					},
+					item: [
+						['test', '/src/proj', 'module'],
+						['main_test.go', '/src/proj/main_test.go', 'file']
+					],
+					expect: []
+				},
+				'One of each': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main_test.go': `
+							package main
+
+							func TestMain(*testing.M) {}
+							func TestFoo(*testing.T) {}
+							func BenchmarkBar(*testing.B) {}
+							func ExampleBaz() {}
+						`
+					},
+					item: [
+						['test', '/src/proj', 'module'],
+						['main_test.go', '/src/proj/main_test.go', 'file']
+					],
+					expect: [
+						'file:///src/proj/main_test.go?test#TestFoo',
+						'file:///src/proj/main_test.go?benchmark#BenchmarkBar',
+						'file:///src/proj/main_test.go?example#ExampleBaz'
+					]
+				}
+			}
+		};
+
+		for (const n in cases) {
+			suite(n, () => {
+				for (const m in cases[n]) {
+					test(m, async () => {
+						const { workspace, files, expect, item: itemData = [] } = cases[n][m];
+						const { ctrl } = setup(workspace, files);
+
+						let item: TestItem | undefined;
+						for (const [label, uri, kind, name] of itemData) {
+							const u = Uri.parse(uri);
+							const child = ctrl.createTestItem(testID(u, kind, name), label, u);
+							(item?.children || ctrl.items).add(child);
+							item = child;
+						}
+						await ctrl.resolveHandler(item);
+
+						const actual: string[] = [];
+						(item?.children || ctrl.items).forEach((x) => actual.push(x.id));
+						assert.deepStrictEqual(actual, expect);
+					});
+				}
+			});
+		}
+	});
+
+	suite('Events', () => {
+		suite('Document opened', () => {
+			class DUT extends TestExplorer {
+				async _didOpen(doc: TextDocument) {
+					await this.didOpenTextDocument(doc);
+				}
+			}
+
+			interface TC extends TestCase {
+				open: string;
+				expect: string[];
+			}
+
+			const cases: Record<string, TC> = {
+				'In workspace': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/foo_test.go': 'package main\nfunc TestFoo(*testing.T) {}',
+						'/src/proj/bar_test.go': 'package main\nfunc TestBar(*testing.T) {}',
+						'/src/proj/baz/main_test.go': 'package main\nfunc TestBaz(*testing.T) {}'
+					},
+					open: 'file:///src/proj/foo_test.go',
+					expect: [
+						'file:///src/proj?module',
+						'file:///src/proj/foo_test.go?file',
+						'file:///src/proj/foo_test.go?test#TestFoo'
+					]
+				},
+				'Outside workspace': {
+					workspace: [],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/foo_test.go': 'package main\nfunc TestFoo(*testing.T) {}'
+					},
+					open: 'file:///src/proj/foo_test.go',
+					expect: [
+						'file:///src/proj?module',
+						'file:///src/proj/foo_test.go?file',
+						'file:///src/proj/foo_test.go?test#TestFoo'
+					]
+				}
+			};
+
+			for (const name in cases) {
+				test(name, async () => {
+					const { workspace, files, open, expect } = cases[name];
+					const { ctrl, expl, ws } = setupCtor(workspace, files, DUT);
+
+					await expl._didOpen(ws.fs.files.get(open));
+
+					assertTestItems(ctrl.items, expect);
+				});
+			}
+		});
+
+		suite('Document edited', async () => {
+			class DUT extends TestExplorer {
+				async _didOpen(doc: TextDocument) {
+					await this.didOpenTextDocument(doc);
+				}
+
+				async _didChange(e: TextDocumentChangeEvent) {
+					await this.didChangeTextDocument(e);
+				}
+			}
+
+			interface TC extends TestCase {
+				open: string;
+				changes: [string, string][];
+				expect: {
+					before: string[];
+					after: string[];
+				};
+			}
+
+			const cases: Record<string, TC> = {
+				'Add test': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/foo_test.go': 'package main'
+					},
+					open: 'file:///src/proj/foo_test.go',
+					changes: [['file:///src/proj/foo_test.go', 'package main\nfunc TestFoo(*testing.T) {}']],
+					expect: {
+						before: ['file:///src/proj?module'],
+						after: [
+							'file:///src/proj?module',
+							'file:///src/proj/foo_test.go?file',
+							'file:///src/proj/foo_test.go?test#TestFoo'
+						]
+					}
+				},
+				'Remove test': {
+					workspace: ['/src/proj'],
+					files: {
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/foo_test.go': 'package main\nfunc TestFoo(*testing.T) {}'
+					},
+					open: 'file:///src/proj/foo_test.go',
+					changes: [['file:///src/proj/foo_test.go', 'package main']],
+					expect: {
+						before: [
+							'file:///src/proj?module',
+							'file:///src/proj/foo_test.go?file',
+							'file:///src/proj/foo_test.go?test#TestFoo'
+						],
+						after: ['file:///src/proj?module']
+					}
+				}
+			};
+
+			for (const name in cases) {
+				test(name, async () => {
+					const { workspace, files, open, changes, expect } = cases[name];
+					const { ctrl, expl, ws } = setupCtor(workspace, files, DUT);
+
+					await expl._didOpen(ws.fs.files.get(open));
+
+					assertTestItems(ctrl.items, expect.before);
+
+					for (const [file, contents] of changes) {
+						const doc = ws.fs.files.get(file);
+						doc.contents = contents;
+						await expl._didChange({
+							document: doc,
+							contentChanges: []
+						});
+					}
+
+					assertTestItems(ctrl.items, expect.after);
+				});
+			}
+		});
+	});
+});
diff --git a/test/integration/index.ts b/test/integration/index.ts
index d6549f0..efc7404 100644
--- a/test/integration/index.ts
+++ b/test/integration/index.ts
@@ -9,6 +9,7 @@
 import * as path from 'path';
 export function run(): Promise<void> {
 	const mocha = new Mocha({
+		grep: process.env.MOCHA_GREP,
 		ui: 'tdd'
 	});
 
diff --git a/test/mocks/MockMemento.ts b/test/mocks/MockMemento.ts
index 5157fbe..bac98d0 100644
--- a/test/mocks/MockMemento.ts
+++ b/test/mocks/MockMemento.ts
@@ -25,4 +25,8 @@
 	public clear() {
 		this._value = {};
 	}
+
+	keys(): readonly string[] {
+		return Object.keys(this._value);
+	}
 }
diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts
new file mode 100644
index 0000000..1d6cee8
--- /dev/null
+++ b/test/mocks/MockTest.ts
@@ -0,0 +1,297 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import path = require('path');
+import {
+	CancellationToken,
+	EndOfLine,
+	FileType,
+	MarkdownString,
+	Position,
+	Range,
+	TestController,
+	TestItem,
+	TestItemCollection,
+	TestRun,
+	TestRunProfile,
+	TestRunProfileKind,
+	TestRunRequest,
+	TextDocument,
+	TextLine,
+	Uri,
+	WorkspaceFolder
+} from 'vscode';
+import { TestExplorer } from '../../src/goTestExplorer';
+
+type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable<void> | void;
+
+class MockTestCollection implements TestItemCollection {
+	constructor(private item: MockTestItem | MockTestController) {}
+
+	private readonly m = new Map<string, MockTestItem>();
+
+	get size() {
+		return this.m.size;
+	}
+
+	forEach(fn: (item: TestItem, coll: TestItemCollection) => unknown) {
+		for (const item of this.m.values()) fn(item, this);
+	}
+
+	add(item: TestItem): void {
+		if (this.m.has(item.id)) {
+			throw new Error(`Test item ${item.id} already exists`);
+		}
+
+		if (!(item instanceof MockTestItem)) {
+			throw new Error('not a mock');
+		} else if (this.item instanceof MockTestItem) {
+			item.parent = this.item;
+		}
+
+		this.m.set(item.id, item);
+	}
+
+	delete(id: string): void {
+		this.m.delete(id);
+	}
+
+	get(id: string): TestItem {
+		return this.m.get(id);
+	}
+
+	replace(items: readonly TestItem[]): void {
+		throw new Error('not impelemented');
+	}
+}
+
+class MockTestItem implements TestItem {
+	private static idNum = 0;
+	private idNum: number;
+
+	constructor(public id: string, public label: string, public uri: Uri | undefined, public ctrl: MockTestController) {
+		this.idNum = MockTestItem.idNum;
+		MockTestItem.idNum++;
+	}
+
+	parent: TestItem | undefined;
+	canResolveChildren: boolean;
+	busy: boolean;
+	description?: string;
+	range?: Range;
+	error?: string | MarkdownString;
+	runnable: boolean;
+	debuggable: boolean;
+
+	children: MockTestCollection = new MockTestCollection(this);
+
+	invalidateResults(): void {}
+
+	dispose(): void {
+		if (this.parent instanceof MockTestItem) {
+			this.parent.children.delete(this.id);
+		}
+	}
+}
+
+class MockTestRunProfile implements TestRunProfile {
+	constructor(
+		public label: string,
+		public kind: TestRunProfileKind,
+		public runHandler: TestRunHandler,
+		public isDefault: boolean
+	) {}
+
+	configureHandler?: () => void;
+	dispose(): void {}
+}
+
+export class MockTestController implements TestController {
+	id = 'go';
+	label = 'Go';
+	items = new MockTestCollection(this);
+
+	resolveHandler?: (item: TestItem) => void | Thenable<void>;
+
+	createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun {
+		throw new Error('Method not implemented.');
+	}
+
+	createRunProfile(
+		label: string,
+		kind: TestRunProfileKind,
+		runHandler: TestRunHandler,
+		isDefault?: boolean
+	): TestRunProfile {
+		return new MockTestRunProfile(label, kind, runHandler, isDefault);
+	}
+
+	createTestItem(id: string, label: string, uri?: Uri): TestItem {
+		return new MockTestItem(id, label, uri, this);
+	}
+
+	dispose(): void {}
+}
+
+type DirEntry = [string, FileType];
+
+class MockTestFileSystem implements TestExplorer.FileSystem {
+	constructor(public dirs: Map<string, DirEntry[]>, public files: Map<string, MockTestDocument>) {}
+
+	readDirectory(uri: Uri): Thenable<[string, FileType][]> {
+		const k = uri.with({ query: '', fragment: '' }).toString();
+		return Promise.resolve(this.dirs.get(k) || []);
+	}
+
+	readFile(uri: Uri): Thenable<Uint8Array> {
+		const k = uri.with({ query: '', fragment: '' }).toString();
+		const s = this.files.get(k)?.getText();
+		return Promise.resolve(Buffer.from(s || ''));
+	}
+}
+
+function unindent(s: string): string {
+	let lines = s.split('\n');
+	if (/^\s*$/.test(lines[0])) lines = lines.slice(1);
+
+	const m = lines[0].match(/^\s+/);
+	if (!m) return s;
+	if (!lines.every((l) => /^\s*$/.test(l) || l.startsWith(m[0]))) return s;
+
+	for (const i in lines) {
+		lines[i] = lines[i].substring(m[0].length);
+	}
+	return lines.join('\n');
+}
+
+export class MockTestWorkspace implements TestExplorer.Workspace {
+	static from(folders: string[], contents: Record<string, string | { contents: string; language: string }>) {
+		const wsdirs: WorkspaceFolder[] = [];
+		const dirs = new Map<string, DirEntry[]>();
+		const files = new Map<string, MockTestDocument>();
+
+		for (const i in folders) {
+			const uri = Uri.parse(folders[i]);
+			wsdirs.push({ uri, index: Number(i), name: path.basename(uri.path) });
+		}
+
+		function push(uri: Uri, child: FileType) {
+			const entry: DirEntry = [path.basename(uri.path), child];
+			const dir = uri.with({ path: path.dirname(uri.path) });
+			if (dirs.has(dir.toString())) {
+				dirs.get(dir.toString()).push(entry);
+				return;
+			}
+
+			if (path.dirname(dir.path) !== dir.path) {
+				push(dir, FileType.Directory);
+			}
+			dirs.set(dir.toString(), [entry]);
+		}
+
+		for (const k in contents) {
+			const uri = Uri.parse(k);
+			const entry = contents[k];
+
+			let doc: MockTestDocument;
+			if (typeof entry === 'object') {
+				doc = new MockTestDocument(uri, unindent(entry.contents), entry.language);
+			} else if (path.basename(uri.path) === 'go.mod') {
+				doc = new MockTestDocument(uri, unindent(entry), 'go.mod');
+			} else {
+				doc = new MockTestDocument(uri, unindent(entry));
+			}
+
+			files.set(uri.toString(), doc);
+			push(uri, FileType.File);
+		}
+
+		return new this(wsdirs, new MockTestFileSystem(dirs, files));
+	}
+
+	constructor(public workspaceFolders: WorkspaceFolder[], public fs: MockTestFileSystem) {}
+
+	openTextDocument(uri: Uri): Thenable<TextDocument> {
+		return Promise.resolve(this.fs.files.get(uri.toString()));
+	}
+
+	getWorkspaceFolder(uri: Uri): WorkspaceFolder {
+		return this.workspaceFolders.filter((x) => x.uri === uri)[0];
+	}
+
+	textDocuments: TextDocument[] = [];
+}
+
+class MockTestDocument implements TextDocument {
+	constructor(
+		public uri: Uri,
+		private _contents: string,
+		public languageId: string = 'go',
+		public isUntitled: boolean = false,
+		public isDirty: boolean = false
+	) {}
+
+	set contents(s: string) {
+		this._contents = s;
+	}
+
+	readonly version: number = 1;
+	readonly eol: EndOfLine = EndOfLine.LF;
+
+	get lineCount() {
+		return this._contents.split('\n').length;
+	}
+
+	get fileName() {
+		return path.basename(this.uri.path);
+	}
+
+	save(): Thenable<boolean> {
+		if (!this.isDirty) {
+			return Promise.resolve(false);
+		}
+
+		this.isDirty = false;
+		return Promise.resolve(true);
+	}
+
+	get isClosed(): boolean {
+		throw new Error('Method not implemented.');
+	}
+
+	lineAt(line: number): TextLine;
+	lineAt(position: Position): TextLine;
+	lineAt(position: any): TextLine {
+		throw new Error('Method not implemented.');
+	}
+
+	offsetAt(position: Position): number {
+		throw new Error('Method not implemented.');
+	}
+
+	positionAt(offset: number): Position {
+		throw new Error('Method not implemented.');
+	}
+
+	getText(range?: Range): string {
+		if (range) {
+			throw new Error('Method not implemented.');
+		}
+		return this._contents;
+	}
+
+	getWordRangeAtPosition(position: Position, regex?: RegExp): Range {
+		throw new Error('Method not implemented.');
+	}
+
+	validateRange(range: Range): Range {
+		throw new Error('Method not implemented.');
+	}
+
+	validatePosition(position: Position): Position {
+		throw new Error('Method not implemented.');
+	}
+}
diff --git a/test/runTest.ts b/test/runTest.ts
index de9eaa8..27a23f4 100644
--- a/test/runTest.ts
+++ b/test/runTest.ts
@@ -6,6 +6,9 @@
 async function main() {
 	// We are in test mode.
 	process.env['VSCODE_GO_IN_TEST'] = '1';
+	if (process.argv.length > 2) {
+		process.env['MOCHA_GREP'] = process.argv[2];
+	}
 
 	// The folder containing the Extension Manifest package.json
 	// Passed to `--extensionDevelopmentPath`