4 tips for retaining strong-typing in tests with TypeScript

So I'm going to be honest, testing with TypeScript whilst trying to maintain strong typing can be a bit of a challenge. Still, the benefits in terms of safe refactorings, as well as the clarity strong typing can bring to other members of a development team are considerable.

I have collated as few of the techniques that I have found useful in some commonly occurring scenarios for retaining strong typing in tests.

Dealing with required modules

It is very common to use the require function to get access to one of your dependencies in a test. Quite often the dependency in question will be called by the code we are testing and we are wanting to replace one of its members with a fake or mock implementation.

Now as the require function has a return type of any, this can be an issue as the rest of the code that works with the imported variable will not be strongly typed.

    let ProfileRecord: mongoose.Model<Profile> = require('../Models/profiles.models').ProfileRecord;

As with the code above we can manually add a type annotation to the variable we are assigning. Sometimes there is no way around this type of compromise. However, TypeScript (v2.4+) gives us a better approach. We are able to use dynamic import expressions to import modules. So we can write the following code.

let { ProfileRecord } = await import('../Models/profiles.models');  

As the dynamic import function returns a promise we are able to use the await keyword and subsequently destructure the desired member of the returned value. The code that is actually produced by TypeScript uses the require function but allows us an approach that retains our strong typing.

Dealing with prototypes

The prototype of any function has a type of "any". This is a big deal because ES6 style classes are basically syntactic sugar over constructor functions and all functions defined within a class are accessible via the prototype. In effect, when we need to control the behavior of members of types that our code instantiates, we need to access the prototype members.

The danger here is that if the code that we are mocking changes in some way, then TypeScript will not be able to detect that our tests are attempting to access properties that do not exist anymore or have a different signature.

In order to avoid this, we can assign the prototype to a variable and add a type annotation to this variable.

const ProfilePrototype: mongoose.Document = ProfileRecord.prototype;  

The code above assigns the prototype of a class/constructor function and adds a type annotation to the assigned variable. Not only do we now get type safety in our code but if the method name or signature gets changed in the future then we will know immediately as our code will not compile.

If the prototype that we assign in this way is from our own code, then it will also mean that any refactorings that we do on it (provided our editor supports TypeScript) will also be applied to our usage in tests.

Accessing mocks

The following shows the usage of sinon.js to replace the method on an imported type with a sinon.js stub. During our test, we will most likely treat the object as if it was the original implementation. However, after the test has run we will most likely wish to either use the mock object's API to remove it or perhaps make some kind of assertion.

    beforeEach(function() {
        sinon.stub(ProfileRecord, 'find');
    });

    afterEach(function() {
        (ProfileRecord.find as sinon.SinonStub).restore();
    });

When we use sinon to alter our code we do not actually affect the type of the variable that we are changing. Therefore if we want to do something like use the variable to assert something or remove something that we have done to it (as we are doing in the example above), then we need to reference our variable by casting it to the type that exposes the API of our mocking library.

Whilst this is not perfect, I have yet to see a mocking library in other languages that does not have a level of impedance when it comes to accessing its mock API versus using the underlying object that is being altered of substituted in some way.

Using Partial types

In our tests we typically:

  1. Arrange the data that the part of the system we are testing requires.
  2. Pass that data to the system and activate/invoke that part of the code.
  3. Check that given the provided data, the code did the things we wanted.

Often we only need to setup one or 2 properties on our objects. This can present a problem. The code that we are about to test will most likely expect the object that we are passing it to implement the full interface of the type that it expects.

As this would feel excessive and time-consuming it may feel like the only choice we have is to initialize an object to a type of any.

Fortunately, TypeScript has us covered in this instance. We can use partial types for this. Partial types use 2 very powerful aspects of TypeScript's type system, generics, and index types. They allow us to declare a variable that has the same structure as its related type, with the critical difference that every member of that type is nullable/optional. So we are able to get the type safety that we desire for the properties that we are using with the benefit of not needing to initialize the other properties of the interface.

let req: Partial<Request> = { file, body };

let createdModels: Partial<Profile> = {};

let res: Partial<Response> = {  
    json: (data: any) => createdModels = data
};

await uploadProfile(<Request>req, <Response>res);  

As a final step, we need to cast our partial objects back to the type that they are standing in for. Provided we have implemented the properties that our test requires, we should be all good.

Final thoughts

Testing in JavaScript is one area where the language's inherent flexibility is often used to manage dependencies of our tests. Fortunately, TypeScript's type system has evolved to really become something that allows us to express the core of JavaScript's nature in a strongly typed manner. It has many features that mean any adjustments required are neither particularly quirky nor prohibitively expensive to implement.

Andrew de Rozario

Self / community educated developer who loves all things web, JavaScript and .Net related. Passionate about sharing knowledge, teaching and eating though maybe not in that order.

Subscribe to Just In Time Coder

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!