diff --git a/CHANGELOG.md b/CHANGELOG.md index 906194cc0824..a8d3122f60b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[expect]` Add asymmetric matcher `expect.closeTo` ([#12243](https://github.com/facebook/jest/pull/12243)) - `[jest-mock]` Added `mockFn.mock.lastCall` to retrieve last argument ([#12285](https://github.com/facebook/jest/pull/12285)) ### Fixes diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index e495a8ab34e6..c1dd22306097 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -432,6 +432,26 @@ test('doAsync calls both callbacks', () => { The `expect.assertions(2)` call ensures that both callbacks actually get called. +### `expect.closeTo(number, numDigits?)` + +`expect.closeTo(number, numDigits?)` is useful when comparing floating point numbers in object properties or array item. If you need to compare a number, please use `.toBeCloseTo` instead. + +The optional `numDigits` argument limits the number of digits to check **after** the decimal point. For the default value `2`, the test criterion is `Math.abs(expected - received) < 0.005 (that is, 10 ** -2 / 2)`. + +For example, this test passes with a precision of 5 digits: + +```js +test('compare float in object properties', () => { + expect({ + title: '0.1 + 0.2', + sum: 0.1 + 0.2, + }).toEqual({ + title: '0.1 + 0.2', + sum: expect.closeTo(0.3, 5), + }); +}); +``` + ### `expect.hasAssertions()` `expect.hasAssertions()` verifies that at least one assertion is called during a test. This is often useful when testing asynchronous code, in order to make sure that assertions in a callback actually got called. diff --git a/packages/expect/src/__tests__/asymmetricMatchers.test.ts b/packages/expect/src/__tests__/asymmetricMatchers.test.ts index 68243087aa7b..c1db50a353da 100644 --- a/packages/expect/src/__tests__/asymmetricMatchers.test.ts +++ b/packages/expect/src/__tests__/asymmetricMatchers.test.ts @@ -12,6 +12,8 @@ import { anything, arrayContaining, arrayNotContaining, + closeTo, + notCloseTo, objectContaining, objectNotContaining, stringContaining, @@ -377,3 +379,105 @@ test('StringNotMatching throws if expected value is neither string nor regexp', test('StringNotMatching returns true if received value is not string', () => { jestExpect(stringNotMatching('en').asymmetricMatch(1)).toBe(true); }); + +describe('closeTo', () => { + [ + [0, 0], + [0, 0.001], + [1.23, 1.229], + [1.23, 1.226], + [1.23, 1.225], + [1.23, 1.234], + [Infinity, Infinity], + [-Infinity, -Infinity], + ].forEach(([expected, received]) => { + test(`${expected} closeTo ${received} return true`, () => { + jestExpect(closeTo(expected).asymmetricMatch(received)).toBe(true); + }); + test(`${expected} notCloseTo ${received} return false`, () => { + jestExpect(notCloseTo(expected).asymmetricMatch(received)).toBe(false); + }); + }); + + [ + [0, 0.01], + [1, 1.23], + [1.23, 1.2249999], + [Infinity, -Infinity], + [Infinity, 1.23], + [-Infinity, -1.23], + ].forEach(([expected, received]) => { + test(`${expected} closeTo ${received} return false`, () => { + jestExpect(closeTo(expected).asymmetricMatch(received)).toBe(false); + }); + test(`${expected} notCloseTo ${received} return true`, () => { + jestExpect(notCloseTo(expected).asymmetricMatch(received)).toBe(true); + }); + }); + + [ + [0, 0.1, 0], + [0, 0.0001, 3], + [0, 0.000004, 5], + [2.0000002, 2, 5], + ].forEach(([expected, received, precision]) => { + test(`${expected} closeTo ${received} with precision ${precision} return true`, () => { + jestExpect(closeTo(expected, precision).asymmetricMatch(received)).toBe( + true, + ); + }); + test(`${expected} notCloseTo ${received} with precision ${precision} return false`, () => { + jestExpect( + notCloseTo(expected, precision).asymmetricMatch(received), + ).toBe(false); + }); + }); + + [ + [3.141592e-7, 3e-7, 8], + [56789, 51234, -4], + ].forEach(([expected, received, precision]) => { + test(`${expected} closeTo ${received} with precision ${precision} return false`, () => { + jestExpect(closeTo(expected, precision).asymmetricMatch(received)).toBe( + false, + ); + }); + test(`${expected} notCloseTo ${received} with precision ${precision} return true`, () => { + jestExpect( + notCloseTo(expected, precision).asymmetricMatch(received), + ).toBe(true); + }); + }); + + test('closeTo throw if expected is not number', () => { + jestExpect(() => { + closeTo('a'); + }).toThrow(); + }); + + test('notCloseTo throw if expected is not number', () => { + jestExpect(() => { + notCloseTo('a'); + }).toThrow(); + }); + + test('closeTo throw if precision is not number', () => { + jestExpect(() => { + closeTo(1, 'a'); + }).toThrow(); + }); + + test('notCloseTo throw if precision is not number', () => { + jestExpect(() => { + notCloseTo(1, 'a'); + }).toThrow(); + }); + + test('closeTo return false if received is not number', () => { + jestExpect(closeTo(1).asymmetricMatch('a')).toBe(false); + }); + + test('notCloseTo return false if received is not number', () => { + jestExpect(notCloseTo(1).asymmetricMatch('a')).toBe(false); + }); +}); diff --git a/packages/expect/src/asymmetricMatchers.ts b/packages/expect/src/asymmetricMatchers.ts index 01f56a48df65..284dd2cf56fb 100644 --- a/packages/expect/src/asymmetricMatchers.ts +++ b/packages/expect/src/asymmetricMatchers.ts @@ -253,6 +253,46 @@ class StringMatching extends AsymmetricMatcher { return 'string'; } } +class CloseTo extends AsymmetricMatcher { + private precision: number; + constructor(sample: number, precision: number = 2, inverse: boolean = false) { + if (!isA('Number', sample)) { + throw new Error('Expected is not a Number'); + } + + if (!isA('Number', precision)) { + throw new Error('Precision is not a Number'); + } + + super(sample); + this.inverse = inverse; + this.precision = precision; + } + + asymmetricMatch(other: number) { + if (!isA('Number', other)) { + return false; + } + let result: boolean = false; + if (other === Infinity && this.sample === Infinity) { + result = true; // Infinity - Infinity is NaN + } else if (other === -Infinity && this.sample === -Infinity) { + result = true; // -Infinity - -Infinity is NaN + } else { + result = + Math.abs(this.sample - other) < Math.pow(10, -this.precision) / 2; + } + return this.inverse ? !result : result; + } + + toString() { + return `Number${this.inverse ? 'Not' : ''}CloseTo`; + } + + getExpectedType() { + return 'number'; + } +} export const any = (expectedObject: unknown): Any => new Any(expectedObject); export const anything = (): Anything => new Anything(); @@ -274,3 +314,7 @@ export const stringMatching = (expected: string | RegExp): StringMatching => new StringMatching(expected); export const stringNotMatching = (expected: string | RegExp): StringMatching => new StringMatching(expected, true); +export const closeTo = (expected: number, precision?: number): CloseTo => + new CloseTo(expected, precision); +export const notCloseTo = (expected: number, precision?: number): CloseTo => + new CloseTo(expected, precision, true); diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 75ab9ba9d18d..998506e0ad39 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -14,6 +14,8 @@ import { anything, arrayContaining, arrayNotContaining, + closeTo, + notCloseTo, objectContaining, objectNotContaining, stringContaining, @@ -363,13 +365,15 @@ expect.any = any; expect.not = { arrayContaining: arrayNotContaining, + closeTo: notCloseTo, objectContaining: objectNotContaining, stringContaining: stringNotContaining, stringMatching: stringNotMatching, }; -expect.objectContaining = objectContaining; expect.arrayContaining = arrayContaining; +expect.closeTo = closeTo; +expect.objectContaining = objectContaining; expect.stringContaining = stringContaining; expect.stringMatching = stringMatching;