Skip to content

Commit

Permalink
feat(expect): support expect.closeTo api (vitest-dev#4260)
Browse files Browse the repository at this point in the history
Co-authored-by: golebiowskib <bartosz.golebiowski@@ttpsc.pl>
  • Loading branch information
2 people authored and LorenzoBloedow committed Dec 19, 2023
1 parent cb39b7a commit c58463a
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 7 deletions.
23 changes: 23 additions & 0 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:** `<T>(expected: T[]) => any`
Expand Down
59 changes: 58 additions & 1 deletion packages/expect/src/jest-asymmetric-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -266,6 +266,56 @@ export class StringMatching extends AsymmetricMatcher<RegExp> {
}
}

class CloseTo extends AsymmetricMatcher<number> {
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,
Expand Down Expand Up @@ -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: <T = unknown>(expected: Array<T>) => new ArrayContaining<T>(expected, true),
stringMatching: (expected: string | RegExp) => new StringMatching(expected, true),
closeTo: (expected: any, precision?: number) => new CloseTo(expected, precision, true),
}
}
16 changes: 10 additions & 6 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'}`
}
1 change: 1 addition & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface AsymmetricMatchersContaining {
objectContaining<T = any>(expected: T): any
arrayContaining<T = unknown>(expected: Array<T>): any
stringMatching(expected: string | RegExp): any
closeTo(expected: number, precision?: number): any
}

export interface JestAssertion<T = any> extends jest.Matchers<void, T> {
Expand Down
25 changes: 25 additions & 0 deletions test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')]))
Expand Down

0 comments on commit c58463a

Please sign in to comment.