Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@ expect(new Error('hi')).toEqual(new Error('hi', { cause: 'x' }))
To test if something was thrown, use [`toThrow`](#tothrow) assertion.
:::

:::warning
Some built-in objects (e.g. `Response`, `Request`) expose their data through getters instead of enumerable keys. Since `toEqual` relies on `Object.keys()`, these properties are not compared, which may cause false-negatives. Vitest emits a `console.warn` when this is detected. Consider comparing the relevant properties directly in that case.
:::

## toStrictEqual

- **Type:** `(received: any) => Awaitable<void>`
Expand Down
8 changes: 8 additions & 0 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
generateToBeMessage,
getObjectSubset,
isError,
isNonPlainEmptyObject,
iterableEquality,
equals as jestEquals,
sparseArrayEquality,
Expand Down Expand Up @@ -105,6 +106,13 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {

def('toEqual', function (expected) {
const actual = utils.flag(this, 'object')

if (isNonPlainEmptyObject(actual) && isNonPlainEmptyObject(expected)) {
console.warn(
`toEqual: comparing two non-plain objects whose Object.keys() returns []. This may result in a false-negative — the objects are not actually compared. Consider using a custom matcher or accessing the properties explicitly.`,
)
}

const equal = jestEquals(actual, expected, [
...customTesters,
iterableEquality,
Expand Down
16 changes: 16 additions & 0 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,22 @@ export function pluralize(word: string, count: number): string {
return `${count} ${word}${count === 1 ? '' : 's'}`
}

export function isPlainObject(value: unknown): value is Record<string | symbol, unknown> {
if (value === null || typeof value !== 'object') {
return false
}
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}

export function isNonPlainEmptyObject(value: unknown): boolean {
return value != null
&& typeof value === 'object'
&& !Array.isArray(value)
&& !isPlainObject(value)
&& Object.keys(value).length === 0
}

export function getObjectKeys(object: object): Array<string | symbol> {
return [
...Object.keys(object),
Expand Down
49 changes: 49 additions & 0 deletions test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2103,3 +2103,52 @@ it('diff', () => {
snapshotError(() => expect({ hello: 'world' }).toBeUndefined())
snapshotError(() => expect({ hello: 'world' }).toBeNull())
})

describe('toEqual warns on non-plain objects with empty Object.keys', () => {
// Uses private fields to guarantee Object.keys() === [] on all platforms
class HiddenState {
#value: string
constructor(value: string) { this.#value = value }
}

let warnSpy: ReturnType<typeof vi.spyOn>

beforeAll(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
return () => warnSpy.mockRestore()
})

it('warns with a message mentioning toEqual and false-negative when comparing two non-plain objects with no enumerable keys', () => {
expect(new HiddenState('a')).toEqual(new HiddenState('b'))
expect(warnSpy).toHaveBeenCalledOnce()
expect(warnSpy.mock.calls[0][0]).toContain('toEqual')
expect(warnSpy.mock.calls[0][0]).toContain('false-negative')
})

it('does not warn for plain empty objects', () => {
warnSpy.mockClear()
expect({}).toEqual({})
expect(warnSpy).not.toHaveBeenCalled()
})

it('does not warn when comparing objects with enumerable keys', () => {
warnSpy.mockClear()
expect({ a: 1 }).toEqual({ a: 1 })
expect(warnSpy).not.toHaveBeenCalled()
})

it('does not warn when only one side is a non-plain empty object', () => {
warnSpy.mockClear()
expect(() => expect(new HiddenState('a')).toEqual({ key: 'value' })).toThrow()
expect(warnSpy).not.toHaveBeenCalled()
})

it('does not warn for class instances with enumerable keys', () => {
warnSpy.mockClear()
class Foo {
x = 1
}
expect(new Foo()).toEqual(new Foo())
expect(warnSpy).not.toHaveBeenCalled()
})
})
96 changes: 96 additions & 0 deletions test/core/test/jest-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { isNonPlainEmptyObject, isPlainObject } from '@vitest/expect'
import { describe, expect, it } from 'vitest'

describe('isPlainObject', () => {
it('returns true for plain object literal', () => {
expect(isPlainObject({})).toBe(true)
})

it('returns true for Object.create(null)', () => {
expect(isPlainObject(Object.create(null))).toBe(true)
})

it('returns true for object created with new Object()', () => {
expect(isPlainObject(new Object())).toBe(true)
})

it('returns false for class instances', () => {
class Foo {}
expect(isPlainObject(new Foo())).toBe(false)
})

it('returns false for built-in objects like Response', () => {
expect(isPlainObject(new Response('body', { status: 200 }))).toBe(false)
})

it('returns false for Date', () => {
expect(isPlainObject(new Date())).toBe(false)
})

it('returns false for Map', () => {
expect(isPlainObject(new Map())).toBe(false)
})

it('returns false for Set', () => {
expect(isPlainObject(new Set())).toBe(false)
})

it('returns false for Array', () => {
expect(isPlainObject([])).toBe(false)
})

it('returns false for null', () => {
expect(isPlainObject(null)).toBe(false)
})

it('returns false for primitive values', () => {
expect(isPlainObject(42)).toBe(false)
expect(isPlainObject('string')).toBe(false)
expect(isPlainObject(true)).toBe(false)
expect(isPlainObject(undefined)).toBe(false)
})
})

describe('isNonPlainEmptyObject', () => {
it('returns true for a non-plain object with no enumerable keys', () => {
class HiddenState {
// eslint-disable-next-line ts/no-unused-private-class-members
#value: string
constructor(value: string) { this.#value = value }
}
expect(isNonPlainEmptyObject(new HiddenState('a'))).toBe(true)
})

it('returns true for a class instance with no enumerable keys', () => {
class Foo {}
expect(isNonPlainEmptyObject(new Foo())).toBe(true)
})

it('returns false for a plain empty object', () => {
expect(isNonPlainEmptyObject({})).toBe(false)
})

it('returns false for Object.create(null)', () => {
expect(isNonPlainEmptyObject(Object.create(null))).toBe(false)
})

it('returns false for a non-plain object with enumerable keys', () => {
class Foo {
x = 1
}
expect(isNonPlainEmptyObject(new Foo())).toBe(false)
})

it('returns false for empty arrays', () => {
expect(isNonPlainEmptyObject([])).toBe(false)
})

it('returns false for null', () => {
expect(isNonPlainEmptyObject(null)).toBe(false)
})

it('returns false for primitive values', () => {
expect(isNonPlainEmptyObject(42)).toBe(false)
expect(isNonPlainEmptyObject('string')).toBe(false)
})
})
Loading