DEV Community

Timo Schinkel for Coolblue

Posted on

Mocking third party classes in TypeScript

TypeScript is becoming increasingly popular as a replacement for plain JavaScript; the strict type checking should result in more resilient code. Initiatives like Definitely Typed make that the majority of often used NPM packages have type definitions. This is great when writing code, but can become a nuisance when writing tests. Especially when mocking these sometimes large third party classes.

Mocking with type definitions

Let's consider the following code that takes a payload and stores it on an AWS S3 bucket using the AWS SDK:

import { S3 } from 'aws-sdk';

export class MyS3Repository implements MyRepositoryInterface {
    private readonly s3: S3;
    private readonly bucketName: string;

    public constructor(s3: S3, bucketName: string) {
        this.s3 = s3;
        this.bucketName = bucketName;
    }

    async store(entity: MyEntity): Promise<void> {
        return this.s3.putObject({
            Body: JSON.stringify(entity), 
            Bucket: this.bucketName, 
            Key: entity.getIdentifier()
        }).promise()
        .then((response: S3.PutObjectOutput) => undefined);
    }
}
Enter fullscreen mode Exit fullscreen mode

NB This is a simplified example, but it will work as an example.

After creating an implementation the next logical step is to create a unit test for this class. We don't want to actually make a connection with an S3 bucket every time we run our test suite. One way to prevent this is to mock the S3 object that is injected into the repository. Coming from JavaScript Jest is my go-to tool to write tests and that means jest.fn():

describe('MyS3Repository', () => {
    describe('store()', () => {
        it('should store on S3', async () => {
            const s3 = {
                putObject: jest.fn().mockReturnValue({
                    promise: jest.fn().mockResolvedValue({})
                })
            }
            const repository = new MyS3Repository(s3);
            const entity = new MyEntity(/* ... */);

            expect.assertions(1);
            await expect(repository.store()).resolves.toBe(undefined);
        })
    });
});
Enter fullscreen mode Exit fullscreen mode

When working with "plain" JavaScript this would work as expected, and the unit test would pass. But when using TypeScript you will encounter an error:

Argument of type '{ putObject: jest.Mock; }' is not assignable to parameter of type 'S3'.
Type '{ getObject: Mock; putObject: Mock; }' is missing the following properties from type 'S3': config, abortMultipartUpload, completeMultipartUpload, copyObject, and 98 more.ts(2345)

What happens here is that TypeScript is detecting that the object that should impersonate the S3 object is missing 98 methods. A trade off needs to be made here; do I use an existing package and try to work with it or do I write the necessary code myself. When not working on personal projects the scale typically tilts towards the first. So let's see if we can make this work.

Pick me!

A number of solutions for this problem are described like using the __mocks__ functionality, introducing interfaces and using jest.mock().

For multiple reasons none of these really worked well for me. As I don't have control over the code introducing an interface is not possible. jest.mock() seems to only work on modules that have a default export and __mocks__ still requires mocking the entire signature of a class.

Another option is to use additional tooling like typemoq, ts-mockito or substitute. But I am a bit reluctant to add a full-fledged mocking library if it is not completely necessary.

Here I find myself with a very useful object with 99 methods and I only use one. Enter the Pick<Type, Keys> utility!

Constructs a type by picking the set of properties Keys from Type.

It allows us to tell TypeScript that even if we have an object with 99 methods we only really care about a small subset of those methods:

import { S3 } from 'aws-sdk';

export class MyS3Repository implements MyRepositoryInterface {
    private readonly s3: Pick<S3, 'putObject'>;
    private readonly bucketName: string;

    public constructor(s3: Pick<S3, 'putObject'>, bucketName: string) {
        this.s3 = s3;
        this.bucketName = bucketName;
    }
}
Enter fullscreen mode Exit fullscreen mode

Result of this is that in our unit test we only have to mock putObject() without TypeScript complaining about missing types.

Conclusion

If you find yourself with a large third party class that you want to mock you can resort to tooling like typemoq, ts-mockito or substitute. Or you can leverage features offered by TypeScript. By using Pick you are able to specify exactly what properties you need in your code. Doing that will make your mocking work a lot simpler.

Top comments (2)

Collapse
 
florianweissdev profile image
Forian Weiß • Edited

Interesting feature for sure but like this you are changing your implementation for you tests. Sometimes necessary but not optimal.

Sadly I cannot verify it right now but I think you may be able to fix your cited error by casting your mock object:

const s3 = {
                putObject: jest.fn().mockReturnValue({
                    promise: jest.fn().mockResolvedValue({})
                })
            } as S3;
Enter fullscreen mode Exit fullscreen mode
Collapse
 
timoschinkel profile image
Timo Schinkel

Thank you for your feedback. Your suggestion will result in the following error:

Conversion of type '{ putObject: jest.Mock; }' to type 'S3' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Type '{ putObject: Mock; }' is missing the following properties from type 'S3': config, abortMultipartUpload, completeMultipartUpload, copyObject, and 98 more.ts(2352)

But this can be remedied by casting to unknown first:

const s3 = ({
  putObject: jest.fn().mockReturnValue({
    promise: jest.fn().mockResolvedValue({})
  })
} as unknown) as S3;
Enter fullscreen mode Exit fullscreen mode

I opted for the Pick solution as it actually changes the interface of the object to be more concise and more explicit about what part of the object is actually required.

I think this mostly will come down to preference.