Debugging with TypeScript, Jest, ts-jest and Visual Studio Code

Michael Tiller
10 min readFeb 1, 2018

--

I’ve been working on a TypeScript project where I’m using jest. I’m super happy with TypeScript and Jest and with ts-jest, you get really good TypeScript support not just for the testing itself, but also for coverage analysis.

In converting an existing project to use these tools, I ran into lots of problems. In order to help the developers out, I decided to create a new repository completely from scratch to demonstrate the issue. I documented each step of the process only to find that at the end everything worked. So I thought I should at least do a write up explaining what I did so others can see some minimalist configuration options that lead to a full featured experience with VSCode.

TL;DR

If you just want to get something working, I suggest you go to my ts-jest-sample repository and copy the files tsconfig.json, jest.config.js and .vscode/launch.json. These files can at least be used as a starting point since they worked well for me and then you can tweak them. My sense, from having spent a lot of time on this, is that the less “customizing” you do to these configurations, the happier you will be.

Update: You may want to turn off coverage during debugging. I’ve seen cases where the coverage instrumentation seems to confuse the debugger and/or corrupt source maps (see Debugging below for more details).

Goals

My goal was to create a TypeScript project that would allow me to:

  • Compile code as an es5 library that can be published as a Node module with typings.
  • Use jest and ts-jest for testing
  • Provide proper stack traces for failed tests
  • Generate accurate code coverage metrics
  • Debug using the Node debugger with proper source map/breakpoint support

Setup

Module and Dependencies

I start by initialing this as an `npm` project.

$ yarn init .

Then, I install typescript, jest, ts-jest and @types/jest as dependencies:

$ yarn add -D typescript jest ts-jest @types/jest

At the time of this writing, that means typescript@2.7.1, jest@22.1.4 and ts-jest@22.0.2.

TypeScript

Next, we initialize this as a TypeScript project using:

$ npx tsc --init .

I want my TypeScript generated code to be stored in ./lib and I want declarations generated.

So, I configured outDir in tsconfig.json to be ./lib.

Files

My .gitignore is then configured to be:

/node_modules
/lib

…while my .npmignore is just:

/node_modules

For the same reason, I remove the default value for files in tsconfig.json and replace it with:

“exclude”: [“node_modules”, “lib”]

Source

To start, I create a src/index.ts that contains a simple function:

export function sampleFunction(x: string): string {
return x + x;
}

I also add a simple jest test. I prefer to keep my tests in a completely separate location, so I’ll put all my tests in __tests__. So I create the following test case in __tests__/base.spec.ts:

import { sampleFunction } from “../src”;describe(“This is a simple test”, () => {
test(“Check the sampleFunction function”, () => {
expect(sampleFunction(“hello”)).toEqual(“hellohello”);
});
});

Configuring Jest

At this point, I’d like to run that test. But first I need to create a jest.config.js file for all my jest settings. This has to take into account the fact that I’m using ts-jest and the fact that my tests are stored in __tests__. So the resulting file looks like this:

module.exports = {
transform: {
“^.+\\.tsx?$”: “ts-jest”,
},
testRegex: “(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$”,
moduleFileExtensions: [“ts”, “tsx”, “js”, “jsx”, “json”, “node”],
};

Scripts

I then add the following scripts to package.json:

“scripts”: {
“compile”: “tsc”,
“test”: “jest”
}

At this point, if I run yarn test, I get exactly what I was hoping for:

PASS __tests__/base.spec.ts
This is a simple test
✓ Check the sampleFunction function (3ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total

Code Coverage

Configuration
To enable code coverage, I update my jest.config.js file to:

module.exports = {
transform: {
"^.+\\.tsx?$": "ts-jest",
},
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
collectCoverage: true,
mapCoverage: true,

};

I’ll also want to update my .gitignore and .npmignore files to avoid version controlling or publishing the coverage directory generated by jest.

Code Organization
At this point, I’m going to start introducing sub-modules in my project. So I’ll add a src/core and a src/utils module just so make things slightly more realistic. Then I’ll export the contents of both of these so that `src/index.ts` looks like this:

export * from "./core";
export * from "./utils";

These then import specific files containing various types and functions. Initially, I’ll create a very simple set of types for representing extremely simple expressions with only literals and the binary operations +, -, * and /. Then I can write a few tests like these:

import { evaluate, Expression } from "../src";describe("Simple expression tests", () => {
test("Check literal value", () => {
expect(evaluate({ type: "literal", value: 5 })).toBeCloseTo(5);
});
test("Check addition", () => {
let expr: Expression = {
type: "binary",
operator: "+",
left: {
type: "literal",
value: 5,
},
right: {
type: "literal",
value: 10,
},
};
expect(evaluate(expr)).toBeCloseTo(15);
});
});

So far so good. But note that if I actually run these tests, I get these results:

PASS  __tests__/base.spec.ts
Simple expression tests
✓ Check literal value (4ms)
✓ Check addition
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.048s
Ran all test suites.
---------------|----------|----------|----------|----------|----------------|
File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
---------------|----------|----------|----------|----------|----------------|
All files | 66.67 | 37.5 | 50 | 66.67 | |
src | 100 | 100 | 100 | 100 | |
index.ts | 100 | 100 | 100 | 100 | |
src/core | 61.54 | 37.5 | 100 | 61.54 | |
functions.ts | 54.55 | 37.5 | 100 | 54.55 | 14,16,18,20,25 |
index.ts | 100 | 100 | 100 | 100 | |
src/utils | 66.67 | 100 | 0 | 66.67 | |
checks.ts | 50 | 100 | 0 | 50 | 2 |
index.ts | 100 | 100 | 100 | 100 | |
---------------|----------|----------|----------|----------|----------------|

Note the lack of code coverage. Adding a few more test cases along with some /* istanbul ignore … */ comments to let istanbul know what it can safely ignore, we get to:

PASS  __tests__/base.spec.ts
Simple expression tests
✓ Check literal value (3ms)
✓ Check addition
✓ Check subtraction
✓ Check multiplication (1ms)
✓ Check division
Test Suites: 1 passed, 1 total
Tests: 5 passed, 5 total
Snapshots: 0 total
Time: 1.353s
Ran all test suites.
---------------|----------|----------|----------|----------|----------------|
File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
---------------|----------|----------|----------|----------|----------------|
All files | 100 | 100 | 100 | 100 | |
src | 100 | 100 | 100 | 100 | |
index.ts | 100 | 100 | 100 | 100 | |
src/core | 100 | 100 | 100 | 100 | |
functions.ts | 100 | 100 | 100 | 100 | |
index.ts | 100 | 100 | 100 | 100 | |
src/utils | 100 | 100 | 100 | 100 | |
checks.ts | 100 | 100 | 100 | 100 | |
index.ts | 100 | 100 | 100 | 100 | |
---------------|----------|----------|----------|----------|----------------|

Now, if we change a test to make it fail, we get something like this:

● Simple expression tests › Check divisionexpect(received).toBeCloseTo(expected, precision)Expected value to be close to (with 2-digit precision):
1
Received:
2
19 | test("Check division", () => {
20 | let expr = bin("/", 10, 5);
> 21 | expect(evaluate(expr)).toBeCloseTo(1);
22 | });
23 | });
24 |
at Object.<anonymous> (__tests__/base.spec.ts:21:32)Test Suites: 1 failed, 1 total
Tests: 1 failed, 4 passed, 5 total
Snapshots: 0 total
Time: 1.535s
Ran all test suites.

Note that the stack track is correct. It points to the problem in the TypeScript code.

Compilation
Recall that we added a compile script to our package.json. We can compile the code with yarn compile. Doing so, we see that the lib directory is populated with two subdirectories, src and __tests__.

However, if we look in those directories, we will find that they only include the generated Javascript code. They do not include type definitions. In order to generate type definitions (.d.ts files) so that other TypeScript users can benefit from all the type information we’ve added to our code, we have to set the declaration field in our tsconfig.json file to be true.

Also note that in order for others to use this package as an NPM module, you need to set the main field in package.json to lib/src/index.js. Furthermore, in order for others to be able to access the types in this module, we also need to set the typings field in package.json to lib/src/index.d.ts. In other words,

  "main": "lib/src/index.js",
"typings": "lib/src/index.d.ts",

If properly configured, we can then launch a node session and import our new package:

$ node
> var me = require(".")
undefined
> me
{ evaluate: [Function: evaluate],
assertNever: [Function: assertNever] }
>

Now be sure to update your jest.config.js to include the following setting or jest will start matching the code in the lib/__tests__ directory:

  testPathIgnorePatterns: ["/lib/", "/node_modules/"],

Debugging

Finally, we come to debugging. I’m using Visual Studio Code, so I’ll demonstrate how to get debugging working there. Some of this information may very well translate to other IDEs.

In VSCode, we can go to the debugging sidebar. Initially, next to the “play” button will be the words “No Configuration”. Clicking on that brings up a pull-down menu with an option “Add Configuration…”.

As much as I love TypeScript, debugging is really its Achilles Heel. It isn’t that you cannot debug, it is that it is just difficult to get working. If you select “Add Configuration…” and then “Node.js”, you’ll see several preconfigurations including one for mocha. But there isn't one for jest. So you'll have to create your own .vscode/launch.json file. Fortunately, the jestpage suggestions you create a .vscode/launch.json file that looks like this:

{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest Tests",
"type": "node",
"request": "launch",
"runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand", "--coverage", "false"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

I was pleasantly surprised to find that I could not only run my tests but also set breakpoints in both the tests (i.e., in __tests__/base.spec.ts) as well as in the code (e.g., src/core/functions.ts) and the debugger will find them. However, I have seen cases where adding a debugger statement triggered the debugger to stop, but not at the correct line. As a result, my standard practice is to turn coverage off during debugging, hence the "--coverage", "false" inthe runtimeArgs parameter in launch.json .

Note that I tested all this on Node 8.x. I’ve seen issues with debugging using Node 6.x so if you are having trouble there, you might consider upgrading to Node 8 (or let, if you manage to fix it for Node 6, submit a PR for the README explaining the fix).

Conclusion

The lesson I learned from this whole process was that the tooling seems to support (quite well) the basic configurations for the various tools. Things pretty much worked as expected with a few minor tweaks here and there. This was definitely not the case for my previous effort where I attempted to reuse some complicated configurations that did things like change the source map settings, target multiple platforms via multiple tsconfig.json files, etc.

Now those things might certainly be needed. But my suggestion is to start with the configurations I’ve outlined above (and which are available in my ts-jest-sample repository) and to stick as closely to these minimalist configurations as possible (at least in the beginning) and then slowly activate other settings as you need them. I would also strongly suggest that you simultaneously check to make sure such changes don’t break any of the “goals” I outlined at the start. My experience is that deviating too far from what I’ve outlined here can get you into trouble.

If you do run into situations where tweaking the settings breaks something, I strongly encourage you to fork my sample repository, make the settings change that is giving you trouble and then report it to the appropriate upstream tool.

Additional Resources

I should start by pointing out that there are some great resources out there to help you configure things if you do run into trouble. Although I will caution you that I tried many of these things and they didn’t work for me. What did work was these minimalist configurations (at least as a starting point).

If you want to get debugging working with TypeScript in general, check out the article “Debugging TypeScript in VS Code without compiling, using ts-node” by @dupski. Another article entitled “Debugging Node.js projects with TypeScript and VS Code — Digging into Sourcemap” does a good job of explaining source maps and how they fit into the debugging process (with special emphasis on TypeScript). There is a page dedicated to debugging with VSCode with some tips on getting started. Also note the "trace": "sm" option in VS Code’s launch configuration (discussed in this response to a potentially and generally useful issue). It generates diagnostic information from VSCode about source maps that can be quite useful.

Now all of those are fairly general resource and I found all of them helpful. In my particular case, I was working with jest and ts-jest and that introduced a few additional complications. So I want to take a moment to comment on that particular use case. First, the documentation for jest does mention how to configure VSCode for debugging. So you should definitely start there.

Note that if you add a debugger; statement and the code you pop up into has variables like cov_1syqxtf0wf in the generated code, you are not looking at the code emitted from the TypeScript compiler but rather the Javascript code that has been instrumented for coverage analysis. So if you see this, you something (I’m not sure what) has gone horribly wrong. See my note above in “Debugging” about disabling coverage while debugging.

--

--