26

I am trying to add a custom matcher to Jest in Typescript. This works fine, but I can't get Typescript to recognize the extended Matchers.

myMatcher.ts

export default function myMatcher (this: jest.MatcherUtils, received: any, expected: any): { pass: boolean; message (): string; } {
  const pass = received === expected;
  return {
    pass: pass,
    message: () => `expected ${pass ? '!' : '='}==`,
  }
}

myMatcher.d.ts

declare namespace jest {
  interface Matchers {
    myMatcher (expected: any): boolean;
  }
}

someTest.ts

import myMatcher from './myMatcher';

expect.extend({
  myMatcher,
})

it('should work', () => {
  expect('str').myMatcher('str');
})

tsconfig.json

{
  "compilerOptions": {
    "outDir": "./dist/",
    "moduleResolution": "node",
    "module": "es6",
    "target": "es5",
    "lib": [
      "es7",
      "dom"
    ]
  },
  "types": [
    "jest"
  ],
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "dist",
    "doc",
    "**/__mocks__/*",
    "**/__tests__/*"
  ]
}

In someTests.ts, I get the error

error TS2339: Property 'myMatcher' does not exist on type 'Matchers'

I have read through the Microsoft documentation several times, but I can't figure out how to do the namespace merging with globally available types (not exported).

Putting it in the index.d.ts from jest works fine, but isn't a good solution for a rapidly changing codebase and classes being extended by multiple parties.

Andreas Köberle
  • 106,652
  • 57
  • 273
  • 297
Dylan Stewart
  • 403
  • 4
  • 7

4 Answers4

26

OK, so there are a few issues here

When a source file (.ts or .tsx) file and a declaration file (.d.ts) file are both candidates for module resolution, as is the case here, the compiler will resolve the source file.

You probably have two files because you want to export a value and also modify the type of the global object jest. However, you do not need two files for this as TypeScript has a specific construct for augmenting the global scope from within a module. That is to say, all you need is the following .ts file

myMatcher.ts

// use declare global within a module to introduce or augment a global declaration.
declare global {
  namespace jest {
    interface Matchers {
      myMatcher: typeof myMatcher;
    }
  }
}
export default function myMatcher<T>(this: jest.MatcherUtils, received: T, expected: T) {
  const pass = received === expected;
  return {
    pass,
    message: () => 'expected' + pass ? '===' : '!==';
  };
}

That said, if you have such a situation, it is a good practice to perform the global mutation and the global type augmentation in the same file. Given that, I would consider rewriting it as follows

myMatcher.ts

// ensure this is parsed as a module.
export {}

declare global {
  namespace jest {
    interface Matchers {
      myMatcher: typeof myMatcher;
    }
  }
}
function myMatcher<T>(this: jest.MatcherUtils, received: T, expected: T) {
  const pass = received === expected;
  return {
    pass,
    message: () => 'expected' + pass ? '===' : '!==';
  };
}

expect.extend({
  myMatcher
});

someTest.ts

import './myMatcher';

it('should work', () => {
  expect('str').myMatcher('str');
});
Aluan Haddad
  • 29,886
  • 8
  • 72
  • 84
  • Here I was thinking that type declarations could be moved out of .ts files into .d.ts files for organization. Really though, .d.ts files are just a TS facade to plain javascript. Good call on the plain import there too. Interestingly, if I put the declare statement in the someTest.ts file, I lose the rest of the Matchers declaration information. So the TS compiler must do something weird with with the declaration order there that I'll have to look into more. – Dylan Stewart May 01 '17 at 15:51
  • 1
    @DylanStewart declarations can be factored out of `.ts` files into `.d.ts` files, but only if they have different module specifiers. E.g. `my-module.ts` and `my-module-declarations.d.ts` is ok but `my-module.ts` and `my-module.d.ts` is **not** ok. The same goes for `.tsx` files. – Aluan Haddad May 02 '17 at 15:27
  • @AluanHaddad I'm having trouble with the proposed solution you had there. If i were to use declare global approach it works, however if I were to factor out the declare namespace jest into a separate module and then import that module it doesn't seem to work. Here's a screenshot. Do you have any ideas? https://d2ffutrenqvap3.cloudfront.net/items/2A1H3v0v2B3l3e1a3q06/Image%202018-02-08%20at%2008.55.06.png – Tony Feb 08 '18 at 16:56
  • @Tony A file _with_ a top level `import` or `export` is a module. To affect the global declaration space within a module, you need to use `declare global`. Your `custom-matchers.ts` has a top level `import` that means it is a module and needs the block`declare global` – Aluan Haddad Feb 08 '18 at 20:13
  • @Tony what your code does is declare a module scoped namespace that has nothing whatsoever to do with the `jest` global. – Aluan Haddad Feb 08 '18 at 20:21
  • What's the usefulness of the last three lines of code in your second `myMatcher.ts`? `expect` does not even exist. – Scott Lin Apr 05 '18 at 03:55
  • @ScottLin it's just a illustrate how it would be used. Because of the import on the first line, `myMatcher` exists on the result of `expect('str')`. If there was no import it wouldn't exist – Aluan Haddad Apr 05 '18 at 03:59
  • Hmm, I think you're talking about `someTest.ts`, but my comment was in reference to `myMatcher.ts`. – Scott Lin Apr 05 '18 at 07:33
  • @ScottLin you mean `expect.extend({ myMatcher });`? – Aluan Haddad Apr 05 '18 at 07:33
  • Yes, exactly. The last three lines of code in your second myMatcher.ts. – Scott Lin Apr 05 '18 at 07:35
  • @ScottLin it is part of the Jest API which is exposed in terms of global variables like `expect`. https://facebook.github.io/jest/docs/en/expect.html#expectextendmatchers – Aluan Haddad Apr 05 '18 at 07:36
  • @ScottLin the point was that I refactored the code in the OP so that the registration of the matcher and the augmentation enriching the type were placed in the same file and would therefore be seen as a single logical entity (value change together with type change). – Aluan Haddad Apr 05 '18 at 12:20
  • In order to `expect` to be defined within that file, it is required to mention it in **tsconfig.spec.json** along with `types: ['jest']`: `{ "extends": "../tsconfig.json", "compilerOptions": { "types": ["jest"] }, "files": ["myMatcher.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"] } ` Still I am having issues to make it working with angular :( – Felix May 24 '19 at 09:59
  • 1
    Thanks!! The `export {}` is vital! – Nickofthyme Oct 22 '19 at 21:30
  • @shusson's answer is almost the same, except the `export {};` part. What's the difference? – trusktr Nov 14 '19 at 04:25
  • the comment above it actually explains what it is. it ensures that the file is treated as a module even if it doesn't import any dependencies. also, my answer was about a year before his – Aluan Haddad Nov 14 '19 at 18:22
  • @trusktr It's not technically necessary, because this file doesn't import anything so we could just remove the declare Global and it would work because it would be parsed as a global script. however, if we add a dependency then we need declare global or the augmentation stops working. it's really just a preference that favors maintainability – Aluan Haddad Nov 14 '19 at 18:28
  • I see. Thanks! It indeed does prevent the functionality of the file from breaking due to hard-to-understand reasons, especially for new TypeScript users. They'll import something, and it'll just work. – trusktr Nov 15 '19 at 17:11
  • I followed the instruction and with `expect('str').myMatcher('str')` I get: Expected 2 arguments, but got 1. An argument for 'expected' was not provided. Any hints? – Piotr Migdal Feb 13 '20 at 18:14
  • @PiotrMigdal `myMatcher` should be replaced with `yourMatcher`. The matcher in this answer comes from the one in the question – Aluan Haddad Feb 13 '20 at 19:10
  • @AluanHaddad No, it is not the case. I finally made it work. (Are you sure you tested it?) For types, it needs to be `interface Matchers` (otherwise TS error) and `myMatcher: (received: string) => R`. I used the type `string` for received and expected, not sure if correctly (but works). For a string linter, it needs return type/ See https://gist.github.com/stared/bfcaad992a19d2e20b189549434d11aa (it works). In any case, thank you for pointers on how to make it work in a single file. – Piotr Migdal Feb 13 '20 at 23:22
  • Thanks, I'll revise this. – Aluan Haddad Feb 14 '20 at 13:35
10

A simple way is:

customMatchers.ts

declare global {
    namespace jest {
        interface Matchers<R> {
            // add any of your custom matchers here
            toBeDivisibleBy: (argument: number) => {};
        }
    }
}

// this will extend the expect with a custom matcher
expect.extend({
    toBeDivisibleBy(received: number, argument: number) {
        const pass = received % argument === 0;
        if (pass) {
            return {
                message: () => `expected ${received} not to be divisible by ${argument}`,
                pass: true
            };
        } else {
            return {
                message: () => `expected ${received} to be divisible by ${argument}`,
                pass: false
            };
        }
    }
});

my.spec.ts

import "path/to/customMatchers";

test('even and odd numbers', () => {
   expect(100).toBeDivisibleBy(2);
   expect(101).not.toBeDivisibleBy(2);
});
shusson
  • 5,542
  • 34
  • 38
  • 1
    via the [setupFilesAfterEnv](https://jestjs.io/docs/en/configuration.html#setupfilesafterenv-array) config option, jest can autmatically import the `customMathers.ts` file once before the tests – TmTron Nov 01 '19 at 17:48
1

Answer by @AluanHaddad is almost correct, sans a few types. This one works:

export {};

declare global {
  namespace jest {
    interface Matchers<R> {
      myMatcher: (received: string) => R;
    }
  }
}

function myMatcher<T>(this: jest.MatcherUtils, received: string, expected: string): jest.CustomMatcherResult {
  const pass = received === expected;
  return {
    pass,
    message: (): string => `expected ${received} to be ${expected}`,
  }
}

expect.extend({
  myMatcher,
});

For a real-world example, see https://github.com/Quantum-Game/quantum-tensors/blob/master/tests/customMatchers.ts (and that the tests actually pass: https://travis-ci.com/Quantum-Game/quantum-tensors).

Piotr Migdal
  • 11,864
  • 9
  • 64
  • 86
1

adding "@types/testing-library__jest-dom" to types in tsconfig.json fixed the issue for me

// tsconfig.json

"types": [
      "node",
      "jest",
      "@types/testing-library__jest-dom"
    ],

See this answer as well Property 'toBeInTheDocument' does not exist on type 'Matchers<any>'

Ahmed Nawaz Khan
  • 1,451
  • 3
  • 20
  • 42