Ken Muse

A Crash Course on Jest TestEnvironments with TypeScript


Lately I’ve been working on building some code that required integration testing to make sure that it could be properly deployed to a variety of environments. While the code itself could be unit tested and validated to ensure that it worked as expected, I wanted to make sure that a framework I build around the code would properly invoke the code in an emulated environment. To make this work, I need to ensure that the emulators are shut down when the tests are complete.

Of course, Jest offers two methods to help with this: beforeAll and afterAll. These are guaranteed to run once before the tests in a describe suite start and once after they finish. Unfortunately, I noticed a few situations where afterAll can fail to run. For example, if there’s a mistake in the code that causes an exception that makes the tests end early, afterAll won’t run. This can leave resources like emulators running, and that can create problems for other tests. This is where Test Environments come in.

What are Jest Test Environments?

They are something that gets a passing mention in the documentation, but which offer powerful features. In fact, the limited documentation on those is related to the configuration option: testEnvironment. It offers a short summary:

The test environment that will be used for testing. The default environment in Jest is a Node.js environment. If you are building a web app, you can use a browser-like environment through jsdom instead.

Not completely clear? That’s okay. Essentially, it’s a module that can be used to setup the test environment that is used for executing the tests. Out of the box, it defaults to node, but offers jsdom for web testing. Essentially, it’s just a module that exports a class with a few methods that Jest will call at various points in the test lifecycle. While this sounds similar to beforeEach/afterEach and beforeAll/afterAll, it is actually quite different. It happens outside of the test suites, and it creates an environment that can directly interact with the larger lifecycle of Jest. This includes being able to run code before or after the test suite, each describe block, and even each test. Powerful stuff!

How they work

The class itself just has a few requirements:

  1. It needs to extend the TestEnvironment class from the module jest-environment-node
  2. It needs to be the default export from the module
  3. It needs to provide the methods setup, teardown, and getVmContext

In TypeScript, this could be written as:

 1import {TestEnvironment} from 'jest-environment-node';
 2import type { EnvironmentContext, JestEnvironmentConfig} from '@jest/environment';
 3
 4export default class MyTestEnvironment extends TestEnvironment {
 5  constructor(config: JestEnvironmentConfig, context: EnvironmentContext){
 6    super(config, context);
 7  }
 8
 9  async setup() {
10    await super.setup();
11    // Your code here
12    // ⋮
13  }
14
15  async teardown() {
16    await super.setup();
17    // Your code here
18    // ⋮
19  }
20
21  async getVmContext() {
22    return super.setup();
23  }
24}

When the environment is created, the constructor will be called to provide details about the environment and its configuration. The configuration has two properties, projectConfig: Config.ProjectConfig and globalConfig: Config.GlobalConfig. The global configuration is the standard configuration that is passed to Jest. Project configurations allow you to run tests ib multiple sets of related files in parallel, each with their own configurations and runners. The project configuration also contains the property testEnvironmentOptions, which allows you to pass additional settings to the test environment as an object. Jest also passes in a context object which includes a reference to the console, the current testPath, and any docblock pragmas included at the top of the current test suite.

Jest initializes the test environment by calling setup. This allows the environment a chance to prepare the environment and do any initial setup. Similarly, teardown is called when the test environment is no longer needed, and it can be used to clean up any resources. In my case, I could use setup to spawn the emulator process. I could then use teardown to kill the process to make sure that the ports were released for other tests. It’s important to remember that you can’t use console.log after the tests have completed, which means that you shouldn’t attempt to log anything in teardown.

The final method, getVmContext is used to provide a V8 Virtual Machine context (node:vm) that can be used for loading and running the code in a controller context. This supports more advanced scenarios and use cases. That said, for most situations the default implementation is sufficient.

The deeper magic of Jest Test Environments

Those are the required methods, but there is a powerful optional method that can give you even more access to the lifecycle: handleTestEvent:

1  async handleTestEvent(event: Event, _state: State) {
2    // Your code here
3    // ⋮
4  }

The event object has a property, name, that indicates which lifecycle event is being processed. For example, when Jest starts to execute a describe block, it raises the event run_describe_start (and concludes with run_describe_end). Each event can provide its own object and properties to get more details. For example, event.describeBlock.name contains the name of the describe block that is being executed. This makes it easy to intercept specific moments in the test lifecycle. This means that I could choose to create the emulator at the start of the describe block and tear it down at the end. I can even introduce logic for handling the start and end of individual tests.

Think locally, act globally

So it turns out there’s another fun trick provided by TestEnvironment. There’s a special object provided in the class as this.global. Anything assigned to this – functions or variables – can be accessed by the test cases within the environment. You can assign to this object at any time, making those values available at specific times in the test cycle. This is a bit more tricky to use with TypeScript, since it introduces new global variables. The trick, though, is simple.

In the TestEnvironment, let’s add a few globals during setup (rather than in response to a specific event):

1  async setup() {
2    await super.setup();
3    this.global.isHappy = true;
4    this.global.doSomethingNice = () => {
5      return 'Hello, world!';
6    }
7  }

To make isHappy and doSomethingNice() available to the tests, I just need to have a file that defines these as global. This can be part of the test suite file or in a separate .d.ts file:

1declare global {
2  var isHappy: boolean;
3  function doSomethingNice(): string;
4}

This makes TypeScript aware of the new global features. It’s important to know that variables (like isHappy) must be declared as var in this file or they won’t validate properly. Once this is done, the tests can use the special object globalThis to access the functions and variables:

1beforeEach(() => {
2  const isHappy = globalThis.isHappy;
3  const somethingNiceHandler = globalThis.doSomethingNice;
4  // or call globalThis.doSomethingNice() ...
5  // ⋮
6}); 

Configuring the test environment

As you can imagine, the easiest way to configure the test environment is to use the testEnvironment configuration in the Jest configuration file. If you want to have more control, both the test environment and its options can be specified at the top of a test suite as a docblock pragma, @jest-environment. The environment can be a reference to a module that is imported, or it can use a local module (including TypeScript files!). For example:

1/**
2 * @jest-environment ./test/environments/specialServiceEnvironment.ts
3 * @jest-environment-options { "timeoutSeconds": 15, "port": 12345 }
4 */

Unfortunately, there’s no way to specify these pragmas immediately before describe or test to configure those independently. That said, you can use context.docblockPragmas (which is provided in the constructor) to provide environment settings using your own custom pragma values. It provides a map containing the name (the part after the @) and the value (everything provided after the first whitespace after the name). If the same pragma is used multiple times, the values are provided as an array.

For example, consider this pragma:

1/**
2 * @test-configuration test01 timeout 15000
3 * @test-configuration test02 timeout 10000
4 */

It could be read by using context.docblockPragmas['test-configuration']. Since there are two pragmas with that name, I’ll get back an array of strings:

1[ 'test01 timeout 15000', 'test02 timeout 10000' ]

Jest uses this to enable a JSON object to be defined and passed to the TestEnvironment using the pragma @jest-environment-options. It is available to the constructor as config.projectConfig.testEnvironmentOptions.

There’s always a catch

While this is a powerful system, it does have a bug with TypeScript (at least through Jest 29.7) that limits the code’s ability to use ESM constructs. When it invokes ts-jest to compile the test environment, it doesn’t utilize a newer method call that allows the TestEnvironment to be treated as an ESM module. This means that imports in child modules that are referenced with the extension .js won’t be found. In short, you’ll want to be using CJS for now until the issue is resolved.

As an example, you can’t import a local TypeScript module mymodule.ts using the statement import { MyClass } from './mymodule.js. It will fail because it cannot resolve the module properly. Instead, TypeScript based TestEnvironments must use the syntax, import { MyClass } from './mymodule') to load the files.

Aside from that, it’s really a surprisingly straightforward system that can be used to do some really powerful things. I can’t wait to hear how you use it for your own projects!