From 7f91c6f6f94c10026aaf05acd9a26ab508eab867 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Sat, 7 Oct 2023 03:08:46 -0500 Subject: [PATCH] feat(expect): support `expect.closeTo` api (#4260) Co-authored-by: golebiowskib --- docs/api/expect.md | 23 ++++++++ .../expect/src/jest-asymmetric-matchers.ts | 59 ++++++++++++++++++- packages/expect/src/jest-utils.ts | 16 +++-- packages/expect/src/types.ts | 1 + test/core/test/jest-expect.test.ts | 25 ++++++++ 5 files changed, 117 insertions(+), 7 deletions(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index c9fd89b6e269..a4fd18ad33a7 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -1210,6 +1210,29 @@ If the value in the error message is too truncated, you can increase [chaiConfig }) ``` +## expect.closeTo + +- **Type:** `(expected: any, precision?: number) => any` +- **Version:** Since Vitest 1.0.0 + +`expect.closeTo` 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.arrayContaining - **Type:** `(expected: T[]) => any` diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index df50596b3b7e..0758f8650967 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -3,7 +3,7 @@ import { GLOBAL_EXPECT } from './constants' import { getState } from './state' import { diff, getMatcherUtils, stringify } from './jest-matcher-utils' -import { equals, isA, iterableEquality, subsetEquality } from './jest-utils' +import { equals, isA, iterableEquality, pluralize, subsetEquality } from './jest-utils' export interface AsymmetricMatcherInterface { asymmetricMatch(other: unknown): boolean @@ -266,6 +266,56 @@ export class StringMatching extends AsymmetricMatcher { } } +class CloseTo extends AsymmetricMatcher { + private readonly precision: number + + constructor(sample: number, precision = 2, inverse = 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 = false + if (other === Number.POSITIVE_INFINITY && this.sample === Number.POSITIVE_INFINITY) { + result = true // Infinity - Infinity is NaN + } + else if (other === Number.NEGATIVE_INFINITY && this.sample === Number.NEGATIVE_INFINITY) { + result = true // -Infinity - -Infinity is NaN + } + else { + result + = Math.abs(this.sample - other) < 10 ** -this.precision / 2 + } + return this.inverse ? !result : result + } + + toString() { + return `Number${this.inverse ? 'Not' : ''}CloseTo` + } + + override getExpectedType() { + return 'number' + } + + override toAsymmetricMatcher(): string { + return [ + this.toString(), + this.sample, + `(${pluralize('digit', this.precision)})`, + ].join(' ') + } +} + export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.expect, @@ -303,11 +353,18 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { (expected: any) => new StringMatching(expected), ) + utils.addMethod( + chai.expect, + 'closeTo', + (expected: any, precision?: number) => new CloseTo(expected, precision), + ) + // defineProperty does not work ;(chai.expect as any).not = { stringContaining: (expected: string) => new StringContaining(expected, true), objectContaining: (expected: any) => new ObjectContaining(expected, true), arrayContaining: (expected: Array) => new ArrayContaining(expected, true), stringMatching: (expected: string | RegExp) => new StringMatching(expected, true), + closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true), } } diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index f49e37bf0dc8..d9af24d5d5fd 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -450,12 +450,12 @@ export function subsetEquality(object: unknown, seenReferences.set(subset[key], true) } const result - = object != null - && hasPropertyInObject(object, key) - && equals(object[key], subset[key], [ - iterableEquality, - subsetEqualityWithContext(seenReferences), - ]) + = object != null + && hasPropertyInObject(object, key) + && equals(object[key], subset[key], [ + iterableEquality, + subsetEqualityWithContext(seenReferences), + ]) // The main goal of using seenReference is to avoid circular node on tree. // It will only happen within a parent and its child, not a node and nodes next to it (same level) // We should keep the reference for a parent and its child only @@ -530,3 +530,7 @@ export function generateToBeMessage(deepEqualityName: string, return toBeMessage } + +export function pluralize(word: string, count: number): string { + return `${count} ${word}${count === 1 ? '' : 's'}` +} diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index e3499fa9ec84..5cc03df20271 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -94,6 +94,7 @@ export interface AsymmetricMatchersContaining { objectContaining(expected: T): any arrayContaining(expected: Array): any stringMatching(expected: string | RegExp): any + closeTo(expected: number, precision?: number): any } export interface JestAssertion extends jest.Matchers { diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index f67671fbcc80..f51ddb967a52 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -143,6 +143,31 @@ describe('jest-expect', () => { expect('Mohammad').toEqual(expect.stringMatching(/Moh/)) expect('Mohammad').not.toEqual(expect.stringMatching(/jack/)) + expect({ + sum: 0.1 + 0.2, + }).toEqual({ + sum: expect.closeTo(0.3, 5), + }) + + expect({ + sum: 0.1 + 0.2, + }).not.toEqual({ + sum: expect.closeTo(0.4, 5), + }) + + expect({ + sum: 0.1 + 0.2, + }).toEqual({ + sum: expect.not.closeTo(0.4, 5), + }) + + expect(() => { + expect({ + sum: 0.1 + 0.2, + }).toEqual({ + sum: expect.closeTo(0.4), + }) + }).toThrowErrorMatchingInlineSnapshot(`"expected { sum: 0.30000000000000004 } to deeply equal { sum: CloseTo{ …(4) } }"`) // TODO: support set // expect(new Set(['bar'])).not.toEqual(new Set([expect.stringContaining('zoo')]))