2025-12-12

Mocking Classes with Jest

There are many examples in the jest docs but I never manage to find what I am looking for. Last time about mocking classes.

I was refactoing our COBOL langauge server startup and needed to mock VS Code LanguageClient with custom logic.

class LanguageClient {
  public state = State.Stopped;
  createDefaultErrorHandler() {
    return jest.fn();
  }
  start() {
    this.state = State.Running;
  }
  dispose() {
    this.state = State.Stopped;
  }
}

It turned out to be harder than expected.

Named export

First I overlooked how to mock a non-default export and spent a bit of time deciphering the returns of fucntion of a fucntion. Once I got over that I had

jest.mock("vscode-languageclient/node", () => { 
  const LanguageClient = jest.fn().mockImplementation(() => {
    return {
      state: State.Stopped,
      createDefaultErrorHandler() {
        return jest.fn();
      },
      start() {
        this.state = State.Running;
      },
      dispose() {
        this.state = State.Stopped;
      }
    }
  });
  return {
    __esModule: true,
    LanguageClient,
  };
});

which works fine until you want to check if dispose was called in your test.

Mocking methods

Trying

const spy = jest.spyOn(LanguageClient.prototype, "dispose");

did not work.

In retrospect it is clear why. The above mocks the implementation of LanguageClient. That means that it retuns an instance object and our dispose lives there instead of on the the prototype where we would want it.

This problem can be fixed by creating mock of dispose separately as shown in the “complete example” in the jest doc. Following the sample I ended up with

import { LanguageClient } from "vscode-languageclient/node";
const mockDispose = jest.fn().mockImplementation(function (this: any) {
  this.state = State.Stopped;
});
jest.mock("vscode-languageclient/node", () => {
  const LanguageClient = jest.fn().mockImplementation(() => {
    return {
      state: State.Stopped,
      createDefaultErrorHandler() {
        return jest.fn();
      },
      start() {
        this.state = State.Running;
      },
      dispose: mockDispose,
    };
  });
  return {
    __esModule: true,
    LanguageClient,
  };
});

One problem I ran into was that jest complained ReferenceError: Cannot access 'mockDispose' before initialization. I had to switch from const to var on the mockDispose declaration for the problem to go away. However, later when I was retesting this, const worked and I was not able to reprocude the original error.

Then I could use

expect(mockDispose).toHaveBeenCalled();

in the tests.

However, now the logic is split in two places which is unpleasant.

Also notice I added an import satement in the example above. It will become of interest in the next step.

Import the mock

An alternative implementation that came to mind was moving mockDispose into the module mock and importing it.

import {
  LanguageClient,
  // @ts-ignore
  mockDispose,
} from "vscode-languageclient/node";
jest.mock("vscode-languageclient/node", () => {
  const originalModule = jest.requireActual("vscode-languageclient/node");
  const mockDispose = jest.fn().mockImplementation(function (this: any) {
    this.state = State.Stopped;
  });
  const LanguageClient = jest.fn().mockImplementation(() => {
    return {
      state: State.Stopped,
      createDefaultErrorHandler() {
        return jest.fn();
      },
      start() {
        this.state = State.Running;
      },
      dispose: mockDispose,
    };
  });
  return {
    __esModule: true,
    ...originalModule,
    LanguageClient,
    mockDispose,
  };
});
...
test(`Test LanguageClient is being instantiated and dispose is called`, () => {
  ...
  expect(mockDispose).toHaveBeenCalled();
})

This shortens the visual distance of the two pieces but it is not much of an improvement. And because typescript only knows types of the actual modules it is not aware of mockDispose. The mockDispose import has to be explicitly ts-ignored.

Let’s recall good old ES3 and prototypes.

Mock methods on the prototype

It took me a good deal of time to figure this out. The culprit was in this of course. Following the examples from jest doc I was always returning a new object from my mock implementation (the function passed as an argument to .mockImplementation). No surprise I was not getting the methods on its protype. But there was one more problem.

During the call to mockImplementation jest wraps our implementation in a function of its own that can later be used in expect assertions. That means that the new LanguageClient call in our code happens to the wrapper function which it in turn calls our mock impelentation. It doeas it via fn.apply(this,arguments). And that means that our mock implementation has to return this explicitly.

Here is the final implementaion.

jest.mock("vscode-languageclient/node", () => {
  const originalModule = jest.requireActual("vscode-languageclient/node");
  const LanguageClient = jest.fn().mockImplementation(function (this: any) {
    this.state = State.Stopped;
    return this; // this is the culprit
  });
  LanguageClient.prototype = {
    dispose(this: any) {
      this.state = State.Stopped;
    },
    createDefaultErrorHandler() {
      return jest.fn();
    },
    start(this: any) {
      this.state = State.Running;
    },
  };

  return {
    __esModule: true,
    ...originalModule,
    LanguageClient,
  };
});

Now when the LanguageClient mock is instantiated it has the proper prototpe with dispose, createDefaultErrorHandlerandcreateDefaultErrorHandlermethods. I can spy ondispose` and use it later in expects asserts.

const spy = jest.spyOn(LanguageClient.prototype, "dispose");
expect(spy).toHaveBeenCalled();

This would all be good if it was 2015. But we are in 2025 and classes are well established.

Class mocks

How about trying an actual class mock.

jest.mock("vscode-languageclient/node", () => {
  const originalModule = jest.requireActual("vscode-languageclient/node");
  class LanguageClient {
    public state = State.Stopped;
    createDefaultErrorHandler() {
      return jest.fn();
    }
    start() {
      this.state = State.Running;
    }
    dispose() {
      this.state = State.Stopped;
    }
  }

  return {
    __esModule: true,
    ...originalModule,
    LanguageClient,
  };
});

This worked fine except for the very first test test that checked whether LanguageClient is being instantiated. How do I check that the constructor is called?

The following does not work

expect(LanguageClient).toHaveBeenCalled();

because LanguageClient is a class, not a jest.fn() mock and cannot be used in expect assertions.

Checking Object.getOwnPropertyDescriptors(LanguageClient.prototype) shows the constructor as a field on the prototype

{
  constructor: {
    value: [class LanguageClient],
    writable: true,
    enumerable: false,
    configurable: true
  },
  ...
}

and one may get lured into trying to spy on it with

const spy = jest.spyOn(LanguageClient.prototype, "constructor");

It does not work. Classes were added in ES6 as a syntax sugar over functions the class is the constructor function. Howerver jest.spyOn needs two arguments. The first is the object in which it replaces the second argument with its spy wrapper. Trying a desperate

const spy = jest.spyOn(LanguageClient);

What now? Call for help. And in my case I was lucky a colleague of mine was stil at work and … after some suffereing we solved it.

Using extends jest.fn() on the class declaration saves the day. This is the final code.

import { LanguageClient, State } from "vscode-languageclient/node";
...
jest.mock("vscode-languageclient/node", () => {
  const originalModule = jest.requireActual("vscode-languageclient/node");
  class LanguageClient extends jest.fn() {
    public state = State.Stopped;
    createDefaultErrorHandler() {
      return jest.fn();
    }
    start() {
      this.state = State.Running;
    }
    dispose() {
      this.state = State.Stopped;
    }
  }

  return {
    __esModule: true,
    ...originalModule,
    LanguageClient,
  };
});
...
test(`Test LanguageClient is being instantiated`, () => {
  ...
  expect(LanguageClient).toHaveBeenCalled();
})
...
test(`Test LanguageClient is dispose is called`, () => {
  const spy = jest.spyOn(LanguageClient.prototype, "dispose");
  ...
  expect(spy).toHaveBeenCalled();
})