Where are my tests?

Hamza
The Binary Bin
Published in
6 min readJan 28, 2023

Everyone knows you need to write tests for your code. Besides validating your logic and preventing future regressions, well-written test cases also encourage good structure and provide a specification for exactly what your code does. Unlike comments, which are but wishy-washy statements that validate nothing and quickly go out of date.

And since tests are so integral to your code, anyone who appreciates cohesion would love to treat their source code and tests as mutual benefactors rather than a couple of estranged files. And this is why we’ll be building out an extension for Visual Studio Code that will let you jump between your code and its test cases faster than you can say npm start.

What we’re building

Test cases (and code, of course) are pointless if you don’t have the requirements beforehand. So here’s exactly what we want to achieve —

  1. We want to be able to jump back and forth between a source file and its unit test file by simply going to a file and hitting a keyboard shortcut.
  2. We’ll be dealing with typescript (.ts) files and their test (.test.ts) files only.
  3. We’ll only be doing this for vscode. Since plugin code isn’t easily portable across IDEs.
  4. We’ll assume that the source and test paths follow a deliberate structure, instead of being randomly dumped wherever. To be more precise, your source code and test should follow the same relative path from some fixed base location. eg: my_project/source_root/some/relative/path/file.ts and /my_project/test_root/some/relative/path/file.test.ts This is important because we’ll try to replace the source and test roots without changing the rest of the file path.
  5. We’ll only support vscode version 1.74 and later. We could probably support earlier versions, but I haven’t tested them out so I won’t bother.

Of course, these basic requirements are just to get us started, and we can easily extend our extension (pun unintended) to support any additional needs. Plus, the same logic should apply to any other IDE you may want to support.

Setting up

All the setup instructions are provided here. This will get us set up with a hello world extension that we can build upon.

Providing the manifest

In our package.json we need to add a contributes section where we can specify the name of the command that will be used to jump between the files. We will also specify the keyboard shortcut for this command inside keyBindings .

"contributes": {
"commands": [
{
"command": "hello.openCorresponding",
"title": "Open Corresponding source/test file"
}
],
"keybindings": [
{
"command": "hello.openCorresponding",
"key": "shift+ctrl+l",
"mac": "shift+cmd+l",
"when": "editorTextFocus"
}
]
}

Configuring path roots

There’s one more thing we need to add to our manifest. Different projects could be following different conventions for their directory structures, so we will need to accommodate all of them if our extension is to work seamlessly.

We can do this by allowing users to configure all the possible path roots. We basically want the following settings to be visible in our vscode extension settings ( ctrl/cmd + ,and search for the extension name — hello in our case)

To achieve this, we will add a configuration section to our package.json inside "contributes" . We’ll also specify some sensible defaults.

"configuration": {
"title": "Hello Quick Shortcuts",
"properties": {
"hello.sourcePathRoots": {
"type": "string",
"default": "src",
"description": "Specifies the the possible roots of the source code path."
},
"hello.unitTestPathRoots": {
"type": "string",
"default": "test,test/unit,/test/unit/src",
"description": "Specifies the the possible roots of the unit tests path."
}
}
},

One might wonder — and rightly so — if we could simply search by filename instead of the entire path. You can do this if it works for you, but our approach works even if multiple files have the same name (eg: index.ts).

Jumping to corresponding files

extension.ts is where the heart of our code lies. It contains the activatefunction which provides a place for us to register our command. So let’s do that now —

export function activate(context: vscode.ExtensionContext) {
let disposable = vscode.commands.registerCommand(
"hello.openCorresponding",
() => {
openCorrespondingSourceOrTestFile();
});
context.subscriptions.push(disposable);
}

We’ve told vscode to call our openCorrespondingSourceOrTestFile function whenever the hello.openCorresponding command is executed. All our logic for jumping between files will now reside inside this function.

Before we add the logic, we’ll also add an enum to distinguish between source and test files —

enum FileType {
SOURCE = "source",
TEST = "test",
INVALID = "invalid",
}

Now, let’s write the openCorrespondingSourceOrTestFilefunction that will do the jumping bit. The vscode object is really useful for performing editor actions here.

const openCorrespondingSourceOrTestFile = () => {
const currentFilePath = vscode.window.activeTextEditor?.document.uri.path;
if (!currentFilePath) {
vscode.window.showErrorMessage(
"No file open. You can only run this command inside a file."
);
return;
}
const currentFileType = getCurrentFileType(currentFilePath);

if (currentFileType === FileType.INVALID) {
vscode.window.showErrorMessage(
"Invalid file type. Please try with a .ts file or its corresponding .test.ts file."
);
return;
}

const correspondingFilePath = buildCorrespondingFilePath(
currentFilePath,
currentFileType
);

if (correspondingFilePath === undefined) {
const correspondingFileType =
currentFileType === FileType.SOURCE ? FileType.TEST : FileType.SOURCE;
vscode.window.showErrorMessage(
`Unable to find the ${correspondingFileType} file path. Please ensure it exists and that you are following the module structure.`
);
return;
}

const correspondingFileUri = vscode.Uri.file(correspondingFilePath);
vscode.commands.executeCommand("vscode.open", correspondingFileUri);
};

The getCurrentFileType method is quite straightforward. It simply gets the type of the file based on the file extension (again, pun unintended).

const getCurrentFileType = (currentFilePath: string): FileType => {
const splitFilePathSegments = currentFilePath?.split("/") ?? [];
const fileName = splitFilePathSegments[splitFilePathSegments.length - 1];
if (fileName.includes(".test.ts")) {
return FileType.TEST;
}
if (fileName.includes(".ts")) {
return FileType.SOURCE;
}
return FileType.INVALID;
};

And now, the only thing left is to actually get the corresponding file path. This is a bit tricky since we could be following any of the conventions mentioned in our path roots configuration, so we’ll need to explore all the paths till we find the file.

const buildCorrespondingFilePath = (
currentFilePath: string,
currentFileType: FileType
): string | undefined => {
if (currentFileType === FileType.INVALID) {
return undefined;
}

const sourcePathRootsString = <string>(
vscode.workspace.getConfiguration("hello").get("sourcePathRoots")
);
const unitTestPathRootsString = <string>(
vscode.workspace.getConfiguration("hello").get("unitTestPathRoots")
);

const sourcePathRoots = sourcePathRootsString.split(",");
const unitTestPathRoots = unitTestPathRootsString.split(",");

for (const sourcePathRoot of sourcePathRoots) {
for (const unitTestPathRoot of unitTestPathRoots) {
const correspondingRootFilePath = currentFilePath.replace(
currentFileType === FileType.SOURCE ? sourcePathRoot + '/' : unitTestPathRoot + '/',
currentFileType === FileType.SOURCE ? unitTestPathRoot + '/' : sourcePathRoot + '/'
);
const splitFilePathSegments = correspondingRootFilePath?.split("/") ?? [];
splitFilePathSegments[splitFilePathSegments.length - 1] =
splitFilePathSegments[splitFilePathSegments.length - 1].replace(
currentFileType === FileType.SOURCE ? ".ts" : ".test.ts",
currentFileType === FileType.SOURCE ? ".test.ts" : ".ts"
);

const correspondingFilePath = splitFilePathSegments.join("/");
if(fs.existsSync(correspondingFilePath)) {
return correspondingFilePath;
}
}
}

return undefined;
};

There’s nothing too complex happening above. All we’re doing is replacing the path root with a corresponding test or source path root and attempting to build the path to the file if it’s present.

Testing it out

We can verify if our extension works by jumping between files within our extension project itself! We’ll first use the Developer: Reload Window command mentioned in the setting up document to get our latest changes in the extension host. We’ll also open our project in this extension host.

Our code is written under src/ and the tests are in src/test/suite/ . We already have src under our source root paths, but we need to add the test root path. So we open up vscode settings using cmd + , , search for our hello extension, and add src/test/suite under the possible unit tests path roots.

Now, if we open extension.ts in the extension host, we can hit our defined shortcut cmd/ctrl + shift + l to open extension.test.ts . And no points for guessing what’ll happen if we hit the shortcut again.

If you do run into issues. you can place breakpoints in our extension code and attempt to debug there.

Packaging our extension

Packaging and publishing extensions are relatively straightforward. You can find the necessary instructions here.

Extend everything

If there’s one thing you should take away from all this, it’s that unit tests are important.

But you already knew that. No, what you should really learn from this is how easy it is to customize and extend vscode to do whatever you want it to. There are loads of extensions in the marketplace already, and if you don’t find what you need, you can always build your own and contribute to the community.

And lastly, don't forget to test your extensions!

--

--

The Binary Bin
The Binary Bin

Published in The Binary Bin

We show you how to build loads of things. Things you never thought you’d needed, and you probably never will. Plus, have loads of fun while doing it.

Hamza
Hamza

Written by Hamza

Writes about the technical stuff.

No responses yet