src/goTest: split up test explorer

Change-Id: Id2da687d115d5551d70bc8235a6aab5f7ce69ecc
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/343790
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/src/goMain.ts b/src/goMain.ts
index 5f7d26d..8300c5b 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -114,7 +114,7 @@
 import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey';
 import { ExtensionAPI } from './export';
 import extensionAPI from './extensionAPI';
-import { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer';
+import { GoTestExplorer, isVscodeTestingAPIAvailable } from './goTest/explore';
 
 export let buildDiagnosticCollection: vscode.DiagnosticCollection;
 export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -337,11 +337,11 @@
 	);
 
 	if (isVscodeTestingAPIAvailable) {
-		const testExplorer = TestExplorer.setup(ctx);
+		const testExplorer = GoTestExplorer.setup(ctx);
 
 		ctx.subscriptions.push(
 			vscode.commands.registerCommand('go.test.refresh', (args) => {
-				if (args) testExplorer.resolve(args);
+				if (args) testExplorer.resolver.resolve(args);
 			})
 		);
 	}
diff --git a/src/goTest/explore.ts b/src/goTest/explore.ts
new file mode 100644
index 0000000..139769f
--- /dev/null
+++ b/src/goTest/explore.ts
@@ -0,0 +1,247 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+	ConfigurationChangeEvent,
+	ExtensionContext,
+	Range,
+	TestController,
+	TestItem,
+	TestItemCollection,
+	TestRunProfileKind,
+	TextDocument,
+	TextDocumentChangeEvent,
+	Uri,
+	workspace,
+	WorkspaceFoldersChangeEvent
+} from 'vscode';
+import vscode = require('vscode');
+import { GoDocumentSymbolProvider } from '../goOutline';
+import { outputChannel } from '../goStatus';
+import { dispose, disposeIfEmpty, findItem, GoTest, Workspace } from './utils';
+import { GoTestResolver, ProvideSymbols } from './resolve';
+import { GoTestRunner } from './run';
+
+// 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;
+
+// Check whether the process is running as a test.
+function isInTest() {
+	return process.env.VSCODE_GO_IN_TEST === '1';
+}
+
+export class GoTestExplorer {
+	static setup(context: ExtensionContext): GoTestExplorer {
+		if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable');
+
+		const ctrl = vscode.tests.createTestController('go', 'Go');
+		const symProvider = new GoDocumentSymbolProvider(true);
+		const inst = new this(workspace, ctrl, (doc, token) => symProvider.provideDocumentSymbols(doc, token));
+
+		context.subscriptions.push(ctrl);
+
+		context.subscriptions.push(
+			workspace.onDidChangeConfiguration(async (x) => {
+				try {
+					await inst.didChangeConfiguration(x);
+				} catch (error) {
+					if (isInTest()) throw error;
+					else outputChannel.appendLine(`Failed while handling 'onDidChangeConfiguration': ${error}`);
+				}
+			})
+		);
+
+		context.subscriptions.push(
+			workspace.onDidOpenTextDocument(async (x) => {
+				try {
+					await inst.didOpenTextDocument(x);
+				} catch (error) {
+					if (isInTest()) throw error;
+					else outputChannel.appendLine(`Failed while handling 'onDidOpenTextDocument': ${error}`);
+				}
+			})
+		);
+
+		context.subscriptions.push(
+			workspace.onDidChangeTextDocument(async (x) => {
+				try {
+					await inst.didChangeTextDocument(x);
+				} catch (error) {
+					if (isInTest()) throw error;
+					else outputChannel.appendLine(`Failed while handling 'onDidChangeTextDocument': ${error}`);
+				}
+			})
+		);
+
+		context.subscriptions.push(
+			workspace.onDidChangeWorkspaceFolders(async (x) => {
+				try {
+					await inst.didChangeWorkspaceFolders(x);
+				} catch (error) {
+					if (isInTest()) throw error;
+					else outputChannel.appendLine(`Failed while handling 'onDidChangeWorkspaceFolders': ${error}`);
+				}
+			})
+		);
+
+		const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false);
+		context.subscriptions.push(watcher);
+		context.subscriptions.push(
+			watcher.onDidCreate(async (x) => {
+				try {
+					await inst.didCreateFile(x);
+				} catch (error) {
+					if (isInTest()) throw error;
+					else outputChannel.appendLine(`Failed while handling 'FileSystemWatcher.onDidCreate': ${error}`);
+				}
+			})
+		);
+		context.subscriptions.push(
+			watcher.onDidDelete(async (x) => {
+				try {
+					await inst.didDeleteFile(x);
+				} catch (error) {
+					if (isInTest()) throw error;
+					else outputChannel.appendLine(`Failed while handling 'FileSystemWatcher.onDidDelete': ${error}`);
+				}
+			})
+		);
+
+		return inst;
+	}
+
+	public readonly resolver: GoTestResolver;
+
+	constructor(
+		private readonly workspace: Workspace,
+		private readonly ctrl: TestController,
+		provideDocumentSymbols: ProvideSymbols
+	) {
+		const resolver = new GoTestResolver(workspace, ctrl, provideDocumentSymbols);
+		const runner = new GoTestRunner(workspace, ctrl, resolver);
+
+		this.resolver = resolver;
+
+		ctrl.resolveHandler = async (item) => {
+			try {
+				await resolver.resolve(item);
+			} catch (error) {
+				if (isInTest()) throw error;
+
+				const m = 'Failed to resolve tests';
+				outputChannel.appendLine(`${m}: ${error}`);
+				await vscode.window.showErrorMessage(m);
+			}
+		};
+
+		ctrl.createRunProfile(
+			'go test',
+			TestRunProfileKind.Run,
+			async (request, token) => {
+				try {
+					await runner.run(request, token);
+				} catch (error) {
+					const m = 'Failed to execute tests';
+					outputChannel.appendLine(`${m}: ${error}`);
+					await vscode.window.showErrorMessage(m);
+				}
+			},
+			true
+		);
+	}
+
+	/* ***** Listeners ***** */
+
+	protected async didOpenTextDocument(doc: TextDocument) {
+		await this.documentUpdate(doc);
+	}
+
+	protected async didChangeTextDocument(e: TextDocumentChangeEvent) {
+		await this.documentUpdate(
+			e.document,
+			e.contentChanges.map((x) => x.range)
+		);
+	}
+
+	protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) {
+		if (e.added.length > 0) {
+			await this.resolver.resolve();
+		}
+
+		if (e.removed.length === 0) {
+			return;
+		}
+
+		this.ctrl.items.forEach((item) => {
+			const uri = Uri.parse(item.id);
+			if (uri.query === 'package') {
+				return;
+			}
+
+			const ws = this.workspace.getWorkspaceFolder(uri);
+			if (!ws) {
+				dispose(item);
+			}
+		});
+	}
+
+	protected async didCreateFile(file: Uri) {
+		await this.documentUpdate(await this.workspace.openTextDocument(file));
+	}
+
+	protected async didDeleteFile(file: Uri) {
+		const id = GoTest.id(file, 'file');
+		function find(children: TestItemCollection): TestItem {
+			return findItem(children, (item) => {
+				if (item.id === id) {
+					return item;
+				}
+
+				const uri = Uri.parse(item.id);
+				if (!file.path.startsWith(uri.path)) {
+					return;
+				}
+
+				return find(item.children);
+			});
+		}
+
+		const found = find(this.ctrl.items);
+		if (found) {
+			dispose(found);
+			disposeIfEmpty(found.parent);
+		}
+	}
+
+	protected async didChangeConfiguration(e: ConfigurationChangeEvent) {
+		let update = false;
+		this.ctrl.items.forEach((item) => {
+			if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
+				dispose(item);
+				update = true;
+			}
+		});
+
+		if (update) {
+			this.resolver.resolve();
+		}
+	}
+
+	// Handle opened documents, document changes, and file creation.
+	private async documentUpdate(doc: TextDocument, ranges?: Range[]) {
+		if (doc.uri.scheme === 'git') {
+			// TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
+			const { path } = JSON.parse(doc.uri.query);
+			doc = await vscode.workspace.openTextDocument(path);
+		}
+
+		if (!doc.uri.path.endsWith('_test.go')) {
+			return;
+		}
+
+		await this.resolver.processDocument(doc, ranges);
+	}
+}
diff --git a/src/goTest/resolve.ts b/src/goTest/resolve.ts
new file mode 100644
index 0000000..1cdf74d
--- /dev/null
+++ b/src/goTest/resolve.ts
@@ -0,0 +1,460 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+	CancellationToken,
+	DocumentSymbol,
+	FileType,
+	Range,
+	SymbolKind,
+	TestController,
+	TestItem,
+	TestItemCollection,
+	TextDocument,
+	Uri,
+	workspace,
+	WorkspaceFolder
+} from 'vscode';
+import path = require('path');
+import { getModFolderPath } from '../goModules';
+import { getCurrentGoPath } from '../util';
+import { getGoConfig } from '../config';
+import { dispose, disposeIfEmpty, FileSystem, GoTest, Workspace } from './utils';
+import { walk, WalkStop } from './walk';
+
+export type ProvideSymbols = (doc: TextDocument, token: CancellationToken) => Thenable<DocumentSymbol[]>;
+
+const testFuncRegex = /^(?<name>(?<kind>Test|Benchmark|Example)($|\P{Ll}.*))/u;
+const testMethodRegex = /^\(\*(?<type>[^)]+)\)\.(?<name>(?<kind>Test)($|\P{Ll}.*))$/u;
+const runTestSuiteRegex = /^\s*suite\.Run\(\w+,\s*(?:&?(?<type1>\w+)\{\}|new\((?<type2>\w+)\))\)/mu;
+
+interface TestSuite {
+	func?: TestItem;
+	methods: Set<TestItem>;
+}
+
+export class GoTestResolver {
+	public readonly isDynamicSubtest = new WeakSet<TestItem>();
+	public readonly isTestMethod = new WeakSet<TestItem>();
+	public readonly isTestSuiteFunc = new WeakSet<TestItem>();
+	private readonly testSuites = new Map<string, TestSuite>();
+
+	constructor(
+		private readonly workspace: Workspace,
+		private readonly ctrl: TestController,
+		private readonly provideDocumentSymbols: ProvideSymbols
+	) {}
+
+	get items() {
+		return this.ctrl.items;
+	}
+
+	async resolve(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
+			this.ctrl.items.forEach((item) => {
+				const uri = Uri.parse(item.id);
+				if (uri.query !== 'package') {
+					return;
+				}
+
+				if (this.workspace.getWorkspaceFolder(uri)) {
+					dispose(item);
+				}
+			});
+
+			// Create entries for all modules and workspaces
+			for (const folder of this.workspace.workspaceFolders || []) {
+				const found = await walkWorkspaces(this.workspace.fs, folder.uri);
+				let needWorkspace = false;
+				for (const [uri, isMod] of found.entries()) {
+					if (!isMod) {
+						needWorkspace = true;
+						continue;
+					}
+
+					await this.getModule(Uri.parse(uri));
+				}
+
+				// If the workspace folder contains any Go files not in a module, create a workspace entry
+				if (needWorkspace) {
+					await this.getWorkspace(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(this.workspace.fs, uri, async (uri) => {
+				await this.getPackage(uri);
+			});
+		}
+
+		// The user expanded a module or package - find all files
+		if (uri.query === 'module' || uri.query === 'package') {
+			for (const [file, type] of await this.workspace.fs.readDirectory(uri)) {
+				if (type !== FileType.File || !file.endsWith('_test.go')) {
+					continue;
+				}
+
+				await this.getFile(Uri.joinPath(uri, file));
+			}
+		}
+
+		// The user expanded a file - find all functions
+		if (uri.query === 'file') {
+			const doc = await this.workspace.openTextDocument(uri.with({ query: '', fragment: '' }));
+			await this.processDocument(doc);
+		}
+
+		// TODO(firelizzard18): If uri.query is test or benchmark, this is where we
+		// would discover sub tests or benchmarks, if that is feasible.
+	}
+
+	// Find test items related to the given resource
+	find(resource: Uri): TestItem[] {
+		const findStr = resource.toString();
+		const found: TestItem[] = [];
+
+		function find(items: TestItemCollection) {
+			items.forEach((item) => {
+				const itemStr = item.uri.toString();
+				if (findStr === itemStr) {
+					found.push(item);
+					find(item.children);
+				} else if (findStr.startsWith(itemStr)) {
+					find(item.children);
+				}
+			});
+		}
+
+		find(this.ctrl.items);
+		return found;
+	}
+
+	// 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, dynamic?: boolean): TestItem {
+		const { fragment: parentName, query: kind } = Uri.parse(item.id);
+
+		let existing: TestItem | undefined;
+		item.children.forEach((child) => {
+			if (child.label === name) existing = child;
+		});
+		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;
+		if (dynamic) this.isDynamicSubtest.add(item);
+		return sub;
+	}
+
+	// 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 processDocument(doc: TextDocument, ranges?: Range[]) {
+		const seen = new Set<string>();
+		const item = await this.getFile(doc.uri);
+		const symbols = await this.provideDocumentSymbols(doc, null);
+		const testify = symbols.some((s) =>
+			s.children.some(
+				(sym) => sym.kind === SymbolKind.Namespace && sym.name === '"github.com/stretchr/testify/suite"'
+			)
+		);
+		for (const symbol of symbols) {
+			await this.processSymbol(doc, item, seen, testify, symbol);
+		}
+
+		item.children.forEach((child) => {
+			const uri = Uri.parse(child.id);
+			if (!seen.has(uri.fragment)) {
+				dispose(child);
+				return;
+			}
+
+			if (ranges?.some((r) => !!child.range.intersection(r))) {
+				item.children.forEach(dispose);
+			}
+		});
+
+		disposeIfEmpty(item);
+	}
+
+	/* ***** Private ***** */
+
+	// Create an item.
+	private createItem(label: string, uri: Uri, kind: string, name?: string): TestItem {
+		return this.ctrl.createTestItem(GoTest.id(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+	}
+
+	// Retrieve an item.
+	private getItem(parent: TestItem | undefined, uri: Uri, kind: string, name?: string): TestItem {
+		return (parent?.children || this.ctrl.items).get(GoTest.id(uri, kind, name));
+	}
+
+	// Create or retrieve an item.
+	private 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);
+		(parent?.children || this.ctrl.items).add(item);
+		return item;
+	}
+
+	// If a test/benchmark with children is relocated, update the children's
+	// location.
+	private relocateChildren(item: TestItem) {
+		item.children.forEach((child) => {
+			child.range = item.range;
+			this.relocateChildren(child);
+		});
+	}
+
+	// Retrieve or create an item for a Go module.
+	private async getModule(uri: Uri): Promise<TestItem> {
+		const existing = this.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 this.workspace.fs.readFile(goMod);
+		const modLine = contents.toString().split('\n', 2)[0];
+		const match = modLine.match(/^module (?<name>.*?)(?:\s|\/\/|$)/);
+		const item = this.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.
+	private async getWorkspace(ws: WorkspaceFolder): Promise<TestItem> {
+		const existing = this.getItem(null, ws.uri, 'workspace');
+		if (existing) {
+			return existing;
+		}
+
+		// Use the workspace folder name as the label
+		const item = this.getOrCreateItem(null, ws.name, ws.uri, 'workspace');
+		item.canResolveChildren = true;
+		return item;
+	}
+
+	// Retrieve or create an item for a Go package.
+	private async getPackage(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 this.getModule(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 = this.getOrCreateItem(parent, dir, dirUri, 'package');
+				}
+			}
+
+			const label = uri.path.startsWith(parent.uri.path)
+				? uri.path.substring(parent.uri.path.length + 1)
+				: uri.path;
+			item = this.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 this.getWorkspace(wsfolder);
+			const existing = this.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 = this.getOrCreateItem(workspace, label, uri, 'package');
+		} else {
+			// Otherwise, add it directly to the root
+			const existing = this.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 = this.getOrCreateItem(null, label, uri, 'package');
+		}
+
+		item.canResolveChildren = true;
+		return item;
+	}
+
+	// Retrieve or create an item for a Go file.
+	private async getFile(uri: Uri): Promise<TestItem> {
+		const dir = path.dirname(uri.path);
+		const pkg = await this.getPackage(uri.with({ path: dir, query: '', fragment: '' }));
+		const existing = this.getItem(pkg, uri, 'file');
+		if (existing) {
+			return existing;
+		}
+
+		const label = path.basename(uri.path);
+		const item = this.getOrCreateItem(pkg, label, uri, 'file');
+		item.canResolveChildren = true;
+		return item;
+	}
+
+	private getTestSuite(type: string): TestSuite {
+		if (this.testSuites.has(type)) {
+			return this.testSuites.get(type);
+		}
+
+		const methods = new Set<TestItem>();
+		const suite = { methods };
+		this.testSuites.set(type, suite);
+		return suite;
+	}
+
+	// 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.
+	private async processSymbol(
+		doc: TextDocument,
+		file: TestItem,
+		seen: Set<string>,
+		importsTestify: boolean,
+		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 this.processSymbol(doc, file, seen, importsTestify, sym);
+			return;
+		}
+
+		const match = symbol.name.match(testFuncRegex) || (importsTestify && symbol.name.match(testMethodRegex));
+		if (!match) {
+			return;
+		}
+
+		seen.add(symbol.name);
+
+		const kind = match.groups.kind.toLowerCase();
+		const suite = match.groups.type ? this.getTestSuite(match.groups.type) : undefined;
+		const existing =
+			this.getItem(file, doc.uri, kind, symbol.name) ||
+			(suite?.func && this.getItem(suite?.func, doc.uri, kind, symbol.name));
+
+		if (existing) {
+			if (!existing.range.isEqual(symbol.range)) {
+				existing.range = symbol.range;
+				this.relocateChildren(existing);
+			}
+			return existing;
+		}
+
+		const item = this.getOrCreateItem(suite?.func || file, match.groups.name, doc.uri, kind, symbol.name);
+		item.range = symbol.range;
+
+		if (suite) {
+			this.isTestMethod.add(item);
+			if (!suite.func) suite.methods.add(item);
+			return;
+		}
+
+		if (!importsTestify) {
+			return;
+		}
+
+		// Runs any suite
+		const text = doc.getText(symbol.range);
+		if (text.includes('suite.Run(')) {
+			this.isTestSuiteFunc.add(item);
+		}
+
+		// Runs a specific suite
+		// - suite.Run(t, new(MySuite))
+		// - suite.Run(t, MySuite{})
+		// - suite.Run(t, &MySuite{})
+		const matchRunSuite = text.match(runTestSuiteRegex);
+		if (matchRunSuite) {
+			const g = matchRunSuite.groups;
+			const suite = this.getTestSuite(g.type1 || g.type2);
+			suite.func = item;
+
+			for (const method of suite.methods) {
+				if (Uri.parse(method.parent.id).query !== 'file') {
+					continue;
+				}
+
+				method.parent.children.delete(method.id);
+				item.children.add(method);
+			}
+		}
+	}
+}
+
+// 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: 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: 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;
+		}
+	});
+}
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
new file mode 100644
index 0000000..5bc9df9
--- /dev/null
+++ b/src/goTest/run.ts
@@ -0,0 +1,474 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+	CancellationToken,
+	Location,
+	OutputChannel,
+	Position,
+	TestController,
+	TestItem,
+	TestMessage,
+	TestRun,
+	TestRunRequest,
+	Uri
+} from 'vscode';
+import vscode = require('vscode');
+import path = require('path');
+import { isModSupported } from '../goModules';
+import { getGoConfig } from '../config';
+import { getTestFlags, goTest, GoTestOutput } from '../testUtils';
+import { GoTestResolver } from './resolve';
+import { dispose, forEachAsync, Workspace } from './utils';
+
+type CollectedTest = { item: TestItem; explicitlyIncluded?: boolean };
+
+// 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() {}
+}
+
+export class GoTestRunner {
+	constructor(
+		private readonly workspace: Workspace,
+		private readonly ctrl: TestController,
+		private readonly resolver: GoTestResolver
+	) {}
+
+	// Execute tests - TestController.runTest callback
+	async run(request: TestRunRequest, token: CancellationToken) {
+		const collected = new Map<TestItem, CollectedTest[]>();
+		const files = new Set<TestItem>();
+		if (request.include) {
+			for (const item of request.include) {
+				await this.collectTests(item, true, request.exclude || [], collected, files);
+			}
+		} else {
+			const promises: Promise<unknown>[] = [];
+			this.ctrl.items.forEach((item) => {
+				const p = this.collectTests(item, true, request.exclude || [], collected, files);
+				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
+		const fileUris = new Set(Array.from(files).map((x) => x.uri));
+		await Promise.all(this.workspace.textDocuments.filter((x) => fileUris.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;
+			}
+		}
+
+		function isInMod(item: TestItem): boolean {
+			const uri = Uri.parse(item.id);
+			if (uri.query === 'module') return true;
+			if (!item.parent) return false;
+			return isInMod(item.parent);
+		}
+
+		const run = this.ctrl.createTestRun(request);
+		const outputChannel = new TestRunOutput(run);
+		for (const [pkg, items] of collected.entries()) {
+			const isMod = isInMod(pkg) || (await isModSupported(pkg.uri, true));
+			const goConfig = getGoConfig(pkg.uri);
+			const flags = getTestFlags(goConfig);
+			const includeBench = getGoConfig(pkg.uri).get('testExplorerRunBenchmarks');
+
+			// If any of the tests are test suite methods, add all test functions that call `suite.Run`
+			const hasTestMethod = items.some(({ item }) => this.resolver.isTestMethod.has(item));
+			if (hasTestMethod) {
+				const add: TestItem[] = [];
+				pkg.children.forEach((file) => {
+					file.children.forEach((test) => {
+						if (!this.resolver.isTestSuiteFunc.has(test)) return;
+						if (items.some(({ item }) => item === test)) return;
+						add.push(test);
+					});
+				});
+				items.push(...add.map((item) => ({ item })));
+			}
+
+			// 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);
+
+				// Remove subtests created dynamically from test output
+				item.children.forEach((child) => {
+					if (this.resolver.isDynamicSubtest.has(child)) {
+						dispose(child);
+					}
+				});
+
+				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: pkg.uri.fsPath,
+					functions: testFns,
+					cancel: token,
+					goTestOutputConsumer: (e) => this.consumeGoTestEvent(run, tests, record, complete, concat, e)
+				});
+				if (!success) {
+					if (this.isBuildFailure(outputChannel.lines)) {
+						this.markComplete(tests, new Set(), (item) => {
+							run.errored(item, { message: 'Compilation failed' });
+							item.error = 'Compilation failed';
+						});
+					} else {
+						this.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: pkg.uri.fsPath,
+					functions: benchmarkFns,
+					isBenchmark: true,
+					cancel: token,
+					goTestOutputConsumer: (e) => this.consumeGoBenchmarkEvent(run, benchmarks, complete, e)
+				});
+
+				// Explicitly complete any incomplete benchmarks (see test_events.md)
+				if (success) {
+					this.markComplete(benchmarks, complete, (x) => run.passed(x));
+				} else if (this.isBuildFailure(outputChannel.lines)) {
+					this.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 {
+					this.markComplete(benchmarks, complete, (x) => run.skipped(x));
+				}
+			}
+		}
+
+		run.end();
+	}
+
+	// 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 collectTests(
+		item: TestItem,
+		explicitlyIncluded: boolean,
+		excluded: TestItem[],
+		functions: Map<TestItem, CollectedTest[]>,
+		files: Set<TestItem>
+	) {
+		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 this.resolver.resolve(item);
+			}
+
+			await forEachAsync(item.children, (child) => {
+				return this.collectTests(child, false, excluded, functions, files);
+			});
+			return;
+		}
+
+		function getFile(item: TestItem): TestItem {
+			const uri = Uri.parse(item.id);
+			if (uri.query === 'file') return item;
+			return getFile(item.parent);
+		}
+
+		const file = getFile(item);
+		files.add(file);
+
+		const pkg = file.parent;
+		if (functions.has(pkg)) {
+			functions.get(pkg).push({ item, explicitlyIncluded });
+		} else {
+			functions.set(pkg, [{ item, explicitlyIncluded }]);
+		}
+		return;
+	}
+
+	// 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.
+	resolveTestName(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 = this.resolver.getOrCreateSubTest(test, part, true);
+		}
+		return test;
+	}
+
+	// Process benchmark events (see test_events.md)
+	consumeGoBenchmarkEvent(
+		run: TestRun,
+		benchmarks: Record<string, TestItem>,
+		complete: Set<TestItem>,
+		e: GoTestOutput
+	) {
+		if (e.Test) {
+			// Find (or create) the (sub)benchmark
+			const test = this.resolveTestName(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 = this.resolveTestName(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)
+	markComplete(items: Record<string, TestItem>, complete: Set<TestItem>, fn: (item: TestItem) => void) {
+		function mark(item: TestItem) {
+			if (!complete.has(item)) {
+				fn(item);
+			}
+			item.children.forEach((child) => mark(child));
+		}
+
+		for (const name in items) {
+			mark(items[name]);
+		}
+	}
+
+	// Process test events (see test_events.md)
+	consumeGoTestEvent(
+		run: TestRun,
+		tests: Record<string, TestItem>,
+		record: Map<TestItem, string[]>,
+		complete: Set<TestItem>,
+		concat: boolean,
+		e: GoTestOutput
+	) {
+		const test = this.resolveTestName(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 = this.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;
+		}
+	}
+
+	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;
+	}
+
+	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));
+	}
+}
diff --git a/src/goTest/utils.ts b/src/goTest/utils.ts
new file mode 100644
index 0000000..7da5cbe
--- /dev/null
+++ b/src/goTest/utils.ts
@@ -0,0 +1,75 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import * as vscode from 'vscode';
+
+export class GoTest {
+	// 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
+	static id(uri: vscode.Uri, kind: string, name?: string): string {
+		uri = uri.with({ query: kind });
+		if (name) uri = uri.with({ fragment: name });
+		return uri.toString();
+	}
+}
+
+// The subset of vscode.FileSystem that is used by the test explorer.
+export type FileSystem = Pick<vscode.FileSystem, 'readFile' | 'readDirectory'>;
+
+// The subset of vscode.workspace that is used by the test explorer.
+export interface Workspace
+	extends Pick<typeof vscode.workspace, 'workspaceFolders' | 'getWorkspaceFolder' | 'textDocuments'> {
+	// use custom FS type
+	readonly fs: FileSystem;
+
+	// only include one overload
+	openTextDocument(uri: vscode.Uri): Thenable<vscode.TextDocument>;
+}
+
+export function findItem(
+	items: vscode.TestItemCollection,
+	fn: (item: vscode.TestItem) => vscode.TestItem | undefined
+): vscode.TestItem | undefined {
+	let found: vscode.TestItem | undefined;
+	items.forEach((item) => {
+		if (found) return;
+		found = fn(item);
+	});
+	return found;
+}
+
+export function forEachAsync<T>(
+	items: vscode.TestItemCollection,
+	fn: (item: vscode.TestItem) => Promise<T>
+): Promise<T[]> {
+	const promises: Promise<T>[] = [];
+	items.forEach((item) => promises.push(fn(item)));
+	return Promise.all(promises);
+}
+
+export function dispose(item: vscode.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.
+export function disposeIfEmpty(item: vscode.TestItem) {
+	// Don't dispose of empty top-level items
+	const uri = vscode.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);
+}
diff --git a/src/goTest/walk.ts b/src/goTest/walk.ts
new file mode 100644
index 0000000..f748dcd
--- /dev/null
+++ b/src/goTest/walk.ts
@@ -0,0 +1,82 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import * as vscode from 'vscode';
+import { FileSystem } from './utils';
+
+// Reasons to stop walking, used by walk
+export 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.
+export async function walk(
+	fs: FileSystem,
+	uri: vscode.Uri,
+	cb: (dir: vscode.Uri, file: string, type: vscode.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 === vscode.FileType.File) || (skipDirs && type === vscode.FileType.Directory)) {
+					continue;
+				}
+
+				// Ignore all dotfiles
+				if (file.startsWith('.')) {
+					continue;
+				}
+
+				if (type === vscode.FileType.Directory) {
+					dirs2.push(vscode.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);
+		}
+	}
+}
diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts
deleted file mode 100644
index 85dd90c..0000000
--- a/src/goTestExplorer.ts
+++ /dev/null
@@ -1,1225 +0,0 @@
-/*---------------------------------------------------------
- * 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;
-
-const testFuncRegex = /^(?<name>(?<kind>Test|Benchmark|Example)\P{Ll}.*)/u;
-const testMethodRegex = /^\(\*(?<type>[^)]+)\)\.(?<name>(?<kind>Test)\P{Ll}.*)$/u;
-const runTestSuiteRegex = /^\s*suite\.Run\(\w+,\s*(?:&?(?<type1>\w+)\{\}|new\((?<type2>\w+)\))\)/mu;
-
-interface TestSuite {
-	func?: TestItem;
-	methods: Set<TestItem>;
-}
-
-// 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 symProvider = new GoDocumentSymbolProvider(true);
-		const inst = new this(ctrl, workspace, (doc, token) => symProvider.provideDocumentSymbols(doc, token));
-
-		context.subscriptions.push(ctrl);
-
-		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) ***** */
-
-	readonly isDynamicSubtest = new WeakSet<TestItem>();
-	readonly isTestMethod = new WeakSet<TestItem>();
-	readonly isTestSuiteFunc = new WeakSet<TestItem>();
-	readonly testSuites = new Map<string, TestSuite>();
-
-	getTestSuite(type: string): TestSuite {
-		if (this.testSuites.has(type)) {
-			return this.testSuites.get(type);
-		}
-
-		const methods = new Set<TestItem>();
-		const suite = { methods };
-		this.testSuites.set(type, suite);
-		return suite;
-	}
-
-	find(uri: vscode.Uri): TestItem[] {
-		const findStr = uri.toString();
-		const found: TestItem[] = [];
-
-		function find(items: TestItemCollection) {
-			items.forEach((item) => {
-				const itemStr = item.uri.toString();
-				if (findStr === itemStr) {
-					found.push(item);
-					find(item.children);
-				} else if (findStr.startsWith(itemStr)) {
-					find(item.children);
-				}
-			});
-		}
-
-		find(this.ctrl.items);
-		return found;
-	}
-
-	// 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, dynamic?: boolean): TestItem {
-		const { fragment: parentName, query: kind } = Uri.parse(item.id);
-		const existing = collect(item.children).find((child) => child.label === 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;
-		if (dynamic) this.isDynamicSubtest.add(item);
-		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);
-}
-
-// 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,
-	doc: TextDocument,
-	file: TestItem,
-	seen: Set<string>,
-	importsTestify: boolean,
-	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, doc, file, seen, importsTestify, sym);
-		return;
-	}
-
-	const match = symbol.name.match(testFuncRegex) || (importsTestify && symbol.name.match(testMethodRegex));
-	if (!match) {
-		return;
-	}
-
-	seen.add(symbol.name);
-
-	const kind = match.groups.kind.toLowerCase();
-	const suite = match.groups.type ? expl.getTestSuite(match.groups.type) : undefined;
-	const existing =
-		expl.getItem(file, doc.uri, kind, symbol.name) ||
-		(suite?.func && expl.getItem(suite?.func, doc.uri, kind, symbol.name));
-
-	if (existing) {
-		if (!existing.range.isEqual(symbol.range)) {
-			existing.range = symbol.range;
-			relocateChildren(existing);
-		}
-		return existing;
-	}
-
-	const item = expl.getOrCreateItem(suite?.func || file, match.groups.name, doc.uri, kind, symbol.name);
-	item.range = symbol.range;
-
-	if (suite) {
-		expl.isTestMethod.add(item);
-		if (!suite.func) suite.methods.add(item);
-		return;
-	}
-
-	if (!importsTestify) {
-		return;
-	}
-
-	// Runs any suite
-	const text = doc.getText(symbol.range);
-	if (text.includes('suite.Run(')) {
-		expl.isTestSuiteFunc.add(item);
-	}
-
-	// Runs a specific suite
-	// - suite.Run(t, new(MySuite))
-	// - suite.Run(t, MySuite{})
-	// - suite.Run(t, &MySuite{})
-	const matchRunSuite = text.match(runTestSuiteRegex);
-	if (matchRunSuite) {
-		const g = matchRunSuite.groups;
-		const suite = expl.getTestSuite(g.type1 || g.type2);
-		suite.func = item;
-
-		for (const method of suite.methods) {
-			if (Uri.parse(method.parent.id).query !== 'file') {
-				continue;
-			}
-
-			method.parent.children.delete(method.id);
-			item.children.add(method);
-		}
-	}
-}
-
-// 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);
-	const testify = symbols.some((s) =>
-		s.children.some(
-			(sym) => sym.kind === SymbolKind.Namespace && sym.name === '"github.com/stretchr/testify/suite"'
-		)
-	);
-	for (const symbol of symbols) {
-		await processSymbol(expl, doc, item, seen, testify, 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))) {
-			item.children.forEach(dispose);
-		}
-	}
-
-	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.scheme === 'git') {
-		// TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
-		const { path } = JSON.parse(doc.uri.query);
-		doc = await vscode.workspace.openTextDocument(path);
-	}
-
-	if (!doc.uri.path.endsWith('_test.go')) {
-		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<TestItem, CollectedTest[]>,
-	files: Set<TestItem>
-) {
-	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, files);
-		}
-		return;
-	}
-
-	function getFile(item: TestItem): TestItem {
-		const uri = Uri.parse(item.id);
-		if (uri.query === 'file') return item;
-		return getFile(item.parent);
-	}
-
-	const file = getFile(item);
-	files.add(file);
-
-	const pkg = file.parent;
-	if (functions.has(pkg)) {
-		functions.get(pkg).push({ item, explicitlyIncluded });
-	} else {
-		functions.set(pkg, [{ 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, true);
-	}
-	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<TestItem, CollectedTest[]>();
-	const files = new Set<TestItem>();
-	if (request.include) {
-		for (const item of request.include) {
-			await collectTests(expl, item, true, request.exclude || [], collected, files);
-		}
-	} else {
-		const promises: Promise<unknown>[] = [];
-		expl.ctrl.items.forEach((item) => {
-			const p = collectTests(expl, item, true, request.exclude || [], collected, files);
-			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
-	const fileUris = new Set(Array.from(files).map((x) => x.uri));
-	await Promise.all(expl.ws.textDocuments.filter((x) => fileUris.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;
-		}
-	}
-
-	function isInMod(item: TestItem): boolean {
-		const uri = Uri.parse(item.id);
-		if (uri.query === 'module') return true;
-		if (!item.parent) return false;
-		return isInMod(item.parent);
-	}
-
-	const run = expl.ctrl.createTestRun(request);
-	const outputChannel = new TestRunOutput(run);
-	for (const [pkg, items] of collected.entries()) {
-		const isMod = isInMod(pkg) || (await isModSupported(pkg.uri, true));
-		const goConfig = getGoConfig(pkg.uri);
-		const flags = getTestFlags(goConfig);
-		const includeBench = getGoConfig(pkg.uri).get('testExplorerRunBenchmarks');
-
-		// If any of the tests are test suite methods, add all test functions that call `suite.Run`
-		const hasTestMethod = items.some(({ item }) => expl.isTestMethod.has(item));
-		if (hasTestMethod) {
-			const add: TestItem[] = [];
-			pkg.children.forEach((file) => {
-				file.children.forEach((test) => {
-					if (!expl.isTestSuiteFunc.has(test)) return;
-					if (items.some(({ item }) => item === test)) return;
-					add.push(test);
-				});
-			});
-			items.push(...add.map((item) => ({ item })));
-		}
-
-		// 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);
-
-			// Remove subtests created dynamically from test output
-			item.children.forEach((child) => {
-				if (expl.isDynamicSubtest.has(child)) {
-					dispose(child);
-				}
-			});
-
-			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: pkg.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: pkg.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/test/integration/goTest.explore.test.ts b/test/integration/goTest.explore.test.ts
new file mode 100644
index 0000000..2222678
--- /dev/null
+++ b/test/integration/goTest.explore.test.ts
@@ -0,0 +1,236 @@
+/*---------------------------------------------------------
+ * 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 fs = require('fs-extra');
+import { TextDocument, TestItemCollection, TextDocumentChangeEvent, ExtensionContext, workspace, Uri } from 'vscode';
+import { GoTestExplorer } from '../../src/goTest/explore';
+import { getCurrentGoPath } from '../../src/util';
+import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
+import { getSymbols_Regex, populateModulePathCache } from './goTest.utils';
+
+type Files = Record<string, string | { contents: string; language: string }>;
+
+interface TestCase {
+	workspace: string[];
+	files: Files;
+}
+
+function setupCtor<T extends GoTestExplorer>(
+	folders: string[],
+	files: Files,
+	ctor: new (...args: ConstructorParameters<typeof GoTestExplorer>) => T
+) {
+	const ws = MockTestWorkspace.from(folders, files);
+	const ctrl = new MockTestController();
+	const expl = new ctor(ws, ctrl, getSymbols_Regex);
+	populateModulePathCache(ws);
+	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('Go Test Explorer', () => {
+	suite('Document opened', () => {
+		class DUT extends GoTestExplorer {
+			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 GoTestExplorer {
+			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);
+			});
+		}
+	});
+
+	suite('stretchr', () => {
+		let gopath: string;
+		let repoPath: string;
+		let fixturePath: string;
+		let fixtureSourcePath: string;
+		let document: TextDocument;
+		let testExplorer: GoTestExplorer;
+
+		const ctx: Partial<ExtensionContext> = {
+			subscriptions: []
+		};
+
+		suiteSetup(async () => {
+			gopath = getCurrentGoPath();
+			if (!gopath) {
+				assert.fail('Cannot run tests without a configured GOPATH');
+			}
+			console.log(`Using GOPATH: ${gopath}`);
+
+			// Set up the test fixtures.
+			repoPath = path.join(gopath, 'src', 'test');
+			fixturePath = path.join(repoPath, 'testfixture');
+			fixtureSourcePath = path.join(__dirname, '..', '..', '..', 'test', 'testdata', 'stretchrTestSuite');
+
+			await fs.remove(repoPath);
+			await fs.copy(fixtureSourcePath, fixturePath, {
+				recursive: true
+			});
+
+			testExplorer = GoTestExplorer.setup(ctx as ExtensionContext);
+
+			const uri = Uri.file(path.join(fixturePath, 'suite_test.go'));
+			document = await workspace.openTextDocument(uri);
+
+			// Force didOpenTextDocument to fire. Without this, the test may run
+			// before the event is handled.
+			//
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			await (testExplorer as any).didOpenTextDocument(document);
+		});
+
+		suiteTeardown(() => {
+			fs.removeSync(repoPath);
+			ctx.subscriptions.forEach((x) => x.dispose());
+		});
+
+		test('discovery', () => {
+			const tests = testExplorer.resolver.find(document.uri).map((x) => x.id);
+			assert.deepStrictEqual(tests.sort(), [
+				document.uri.with({ query: 'file' }).toString(),
+				document.uri.with({ query: 'test', fragment: '(*ExampleTestSuite).TestExample' }).toString(),
+				document.uri.with({ query: 'test', fragment: 'TestExampleTestSuite' }).toString()
+			]);
+		});
+	});
+});
diff --git a/test/integration/goTest.resolve.test.ts b/test/integration/goTest.resolve.test.ts
new file mode 100644
index 0000000..293ab24
--- /dev/null
+++ b/test/integration/goTest.resolve.test.ts
@@ -0,0 +1,213 @@
+/*---------------------------------------------------------
+ * 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 { TestItem, Uri } from 'vscode';
+import { GoTestResolver } from '../../src/goTest/resolve';
+import { GoTest } from '../../src/goTest/utils';
+import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
+import { getSymbols_Regex, populateModulePathCache } from './goTest.utils';
+
+type Files = Record<string, string | { contents: string; language: string }>;
+
+interface TestCase {
+	workspace: string[];
+	files: Files;
+}
+
+function setup(folders: string[], files: Files) {
+	const workspace = MockTestWorkspace.from(folders, files);
+	const ctrl = new MockTestController();
+	const resolver = new GoTestResolver(workspace, ctrl, getSymbols_Regex);
+	populateModulePathCache(workspace);
+	return { resolver, ctrl };
+}
+
+suite('Go Test Resolver', () => {
+	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, resolver } = setup(workspace, files);
+
+					let item: TestItem | undefined;
+					for (const [label, uri, kind, name] of itemData) {
+						const u = Uri.parse(uri);
+						const child = ctrl.createTestItem(GoTest.id(u, kind, name), label, u);
+						(item?.children || resolver.items).add(child);
+						item = child;
+					}
+					await resolver.resolve(item);
+
+					const actual: string[] = [];
+					(item?.children || resolver.items).forEach((x) => actual.push(x.id));
+					assert.deepStrictEqual(actual, expect);
+				});
+			}
+		});
+	}
+});
diff --git a/test/integration/goTest.utils.ts b/test/integration/goTest.utils.ts
new file mode 100644
index 0000000..e95a3b2
--- /dev/null
+++ b/test/integration/goTest.utils.ts
@@ -0,0 +1,43 @@
+/*---------------------------------------------------------
+ * 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 { DocumentSymbol, FileType, Uri, TextDocument, SymbolKind, Range, Position } from 'vscode';
+import { packagePathToGoModPathMap } from '../../src/goModules';
+import { MockTestWorkspace } from '../mocks/MockTest';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export function getSymbols_Regex(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);
+}
+
+export function populateModulePathCache(workspace: MockTestWorkspace) {
+	function walk(dir: Uri, modpath?: string) {
+		const dirs: Uri[] = [];
+		for (const [name, type] of workspace.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;
+			}
+		}
+		packagePathToGoModPathMap[dir.path] = modpath || '';
+		for (const dir of dirs) {
+			walk(dir, modpath);
+		}
+	}
+
+	// prevent getModFolderPath from actually doing anything;
+	for (const pkg in packagePathToGoModPathMap) {
+		delete packagePathToGoModPathMap[pkg];
+	}
+	walk(Uri.file('/'));
+}
diff --git a/test/integration/goTestExplorer.test.ts b/test/integration/goTestExplorer.test.ts
deleted file mode 100644
index e04bb71..0000000
--- a/test/integration/goTestExplorer.test.ts
+++ /dev/null
@@ -1,463 +0,0 @@
-/*---------------------------------------------------------
- * 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 fs = require('fs-extra');
-import vscode = require('vscode');
-import { packagePathToGoModPathMap as pkg2mod } from '../../src/goModules';
-import { TestExplorer, testID } from '../../src/goTestExplorer';
-import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
-import { getCurrentGoPath } from '../../src/util';
-import { GoDocumentSymbolProvider } from '../../src/goOutline';
-import { getGoConfig } from '../../src/config';
-
-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: vscode.TextDocument, token: unknown): Thenable<vscode.DocumentSymbol[]> {
-	const syms: vscode.DocumentSymbol[] = [];
-	const range = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0));
-	doc.getText().replace(/^func (Test|Benchmark|Example)([A-Z]\w+)(\(.*\))/gm, (m, type, name, details) => {
-		syms.push(new vscode.DocumentSymbol(type + name, details, vscode.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: vscode.Uri, modpath?: string) {
-		const dirs: vscode.Uri[] = [];
-		for (const [name, type] of ws.fs.dirs.get(dir.toString())) {
-			const uri = dir.with({ path: path.join(dir.path, name) });
-			if (type === vscode.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(vscode.Uri.file('/'));
-
-	return { ctrl, expl, ws };
-}
-
-function assertTestItems(items: vscode.TestItemCollection, expect: string[]) {
-	const actual: string[] = [];
-	function walk(items: vscode.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: vscode.TestItem | undefined;
-						for (const [label, uri, kind, name] of itemData) {
-							const u = vscode.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: vscode.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: vscode.TextDocument) {
-					await this.didOpenTextDocument(doc);
-				}
-
-				async _didChange(e: vscode.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);
-				});
-			}
-		});
-	});
-
-	suite('stretchr', () => {
-		let gopath: string;
-		let repoPath: string;
-		let fixturePath: string;
-		let fixtureSourcePath: string;
-		let document: vscode.TextDocument;
-		let testExplorer: TestExplorer;
-
-		const ctx: Partial<vscode.ExtensionContext> = {
-			subscriptions: []
-		};
-
-		suiteSetup(async () => {
-			gopath = getCurrentGoPath();
-			if (!gopath) {
-				assert.fail('Cannot run tests without a configured GOPATH');
-			}
-			console.log(`Using GOPATH: ${gopath}`);
-
-			// Set up the test fixtures.
-			repoPath = path.join(gopath, 'src', 'test');
-			fixturePath = path.join(repoPath, 'testfixture');
-			fixtureSourcePath = path.join(__dirname, '..', '..', '..', 'test', 'testdata', 'stretchrTestSuite');
-
-			fs.removeSync(repoPath);
-			fs.copySync(fixtureSourcePath, fixturePath, {
-				recursive: true
-			});
-
-			testExplorer = TestExplorer.setup(ctx as vscode.ExtensionContext);
-
-			const uri = vscode.Uri.file(path.join(fixturePath, 'suite_test.go'));
-			document = await vscode.workspace.openTextDocument(uri);
-
-			// Force didOpenTextDocument to fire. Without this, the test may run
-			// before the event is handled.
-			//
-			// eslint-disable-next-line @typescript-eslint/no-explicit-any
-			await (testExplorer as any).didOpenTextDocument(document);
-		});
-
-		suiteTeardown(() => {
-			fs.removeSync(repoPath);
-			ctx.subscriptions.forEach((x) => x.dispose());
-		});
-
-		test('discovery', () => {
-			const tests = testExplorer.find(document.uri).map((x) => x.id);
-			assert.deepStrictEqual(tests.sort(), [
-				document.uri.with({ query: 'file' }).toString(),
-				document.uri.with({ query: 'test', fragment: '(*ExampleTestSuite).TestExample' }).toString(),
-				document.uri.with({ query: 'test', fragment: 'TestExampleTestSuite' }).toString()
-			]);
-		});
-	});
-});
diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts
index 1d6cee8..41bc6aa 100644
--- a/test/mocks/MockTest.ts
+++ b/test/mocks/MockTest.ts
@@ -24,7 +24,7 @@
 	Uri,
 	WorkspaceFolder
 } from 'vscode';
-import { TestExplorer } from '../../src/goTestExplorer';
+import { FileSystem, Workspace } from '../../src/goTest/utils';
 
 type TestRunHandler = (request: TestRunRequest, token: CancellationToken) => Thenable<void> | void;
 
@@ -138,7 +138,7 @@
 
 type DirEntry = [string, FileType];
 
-class MockTestFileSystem implements TestExplorer.FileSystem {
+class MockTestFileSystem implements FileSystem {
 	constructor(public dirs: Map<string, DirEntry[]>, public files: Map<string, MockTestDocument>) {}
 
 	readDirectory(uri: Uri): Thenable<[string, FileType][]> {
@@ -167,7 +167,7 @@
 	return lines.join('\n');
 }
 
-export class MockTestWorkspace implements TestExplorer.Workspace {
+export class MockTestWorkspace implements Workspace {
 	static from(folders: string[], contents: Record<string, string | { contents: string; language: string }>) {
 		const wsdirs: WorkspaceFolder[] = [];
 		const dirs = new Map<string, DirEntry[]>();