Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed toHaveProperty returning false positives when looking for undefined property #8923

1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -20,6 +20,7 @@

### Fixes

- `[expect]` Fix false positives when looking for undefined prop ([#8786](https://github.com/facebook/jest/issues/8786))
SimenB marked this conversation as resolved.
Show resolved Hide resolved
- `[expect]` Display expectedDiff more carefully in toBeCloseTo ([#8389](https://github.com/facebook/jest/pull/8389))
- `[jest-diff]` Do not inverse format if line consists of one change ([#8903](https://github.com/facebook/jest/pull/8903))
- `[jest-fake-timers]` `getTimerCount` will not include cancelled immediates ([#8764](https://github.com/facebook/jest/pull/8764))
Expand Down
22 changes: 10 additions & 12 deletions packages/expect/src/__tests__/__snapshots__/matchers.test.js.snap
Expand Up @@ -3083,6 +3083,16 @@ Expected value: <green>undefined</>
Received value: <red>3</>"
`;

exports[`.toHaveProperty() {pass: false} expect({"a": {}}).toHaveProperty('a.b', undefined) 1`] = `
"<dim>expect(</><red>received</><dim>).</>toHaveProperty<dim>(</><green>path</><dim>, </><green>value</><dim>)</>

Expected path: <green>\\"a.b\\"</>
Received path: <red>\\"a\\"</>

Expected value: <green>undefined</>
Received value: <red>{}</>"
`;

exports[`.toHaveProperty() {pass: false} expect({"a": 1}).toHaveProperty('a.b.c.d') 1`] = `
"<dim>expect(</><red>received</><dim>).</>toHaveProperty<dim>(</><green>path</><dim>)</>

Expand Down Expand Up @@ -3339,18 +3349,6 @@ Expected path: <green>\\"a.b\\"</>
Expected value: not <green>undefined</>"
`;

exports[`.toHaveProperty() {pass: true} expect({"a": {}}).toHaveProperty('a.b', undefined) 1`] = `
"<dim>expect(</><red>received</><dim>).</>not<dim>.</>toHaveProperty<dim>(</><green>path</><dim>, </><green>value</><dim>)</>

Expected path: <green>\\"a.b\\"</>
Received path: <red>\\"a\\"</>

Expected value: not <green>undefined</>
Received value: <red>{}</>

<dim>Because a positive assertion passes for expected value undefined if the property does not exist, this negative assertion fails unless the property does exist and has a defined value</>"
`;

exports[`.toHaveProperty() {pass: true} expect({"a": 0}).toHaveProperty('a') 1`] = `
"<dim>expect(</><red>received</><dim>).</>not<dim>.</>toHaveProperty<dim>(</><green>path</><dim>)</>

Expand Down
3 changes: 1 addition & 2 deletions packages/expect/src/__tests__/matchers.test.js
Expand Up @@ -1659,7 +1659,6 @@ describe('.toHaveProperty()', () => {
[{a: {b: [1, 2, 3]}}, ['a', 'b', 1], expect.any(Number)],
[{a: 0}, 'a', 0],
[{a: {b: undefined}}, 'a.b', undefined],
[{a: {}}, 'a.b', undefined], // delete for breaking change in future major
[{a: {b: {c: 5}}}, 'a.b', {c: 5}],
[Object.assign(Object.create(null), {property: 1}), 'property', 1],
[new Foo(), 'a', undefined],
Expand Down Expand Up @@ -1699,7 +1698,7 @@ describe('.toHaveProperty()', () => {
[{a: {b: {c: 5}}}, 'a.b', {c: 4}],
[new Foo(), 'a', 'a'],
[new Foo(), 'b', undefined],
// [{a: {}}, 'a.b', undefined], // add for breaking change in future major
[{a: {}}, 'a.b', undefined],
].forEach(([obj, keyPath, value]) => {
test(`{pass: false} expect(${stringify(
obj,
Expand Down
8 changes: 8 additions & 0 deletions packages/expect/src/__tests__/utils.test.js
Expand Up @@ -21,13 +21,15 @@ const {
describe('getPath()', () => {
test('property exists', () => {
expect(getPath({a: {b: {c: 5}}}, 'a.b.c')).toEqual({
endPropIsDefined: true,
hasEndProp: true,
lastTraversedObject: {c: 5},
traversedPath: ['a', 'b', 'c'],
value: 5,
});

expect(getPath({a: {b: {c: {d: 1}}}}, 'a.b.c.d')).toEqual({
endPropIsDefined: true,
hasEndProp: true,
lastTraversedObject: {d: 1},
traversedPath: ['a', 'b', 'c', 'd'],
Expand All @@ -37,6 +39,7 @@ describe('getPath()', () => {

test('property doesnt exist', () => {
expect(getPath({a: {b: {}}}, 'a.b.c')).toEqual({
endPropIsDefined: false,
hasEndProp: false,
lastTraversedObject: {},
traversedPath: ['a', 'b'],
Expand All @@ -46,6 +49,7 @@ describe('getPath()', () => {

test('property exist but undefined', () => {
expect(getPath({a: {b: {c: undefined}}}, 'a.b.c')).toEqual({
endPropIsDefined: true,
hasEndProp: true,
lastTraversedObject: {c: undefined},
traversedPath: ['a', 'b', 'c'],
Expand All @@ -64,12 +68,14 @@ describe('getPath()', () => {
}

expect(getPath(new A(), 'a')).toEqual({
endPropIsDefined: true,
hasEndProp: true,
lastTraversedObject: new A(),
traversedPath: ['a'],
value: 'a',
});
expect(getPath(new A(), 'b.c')).toEqual({
endPropIsDefined: true,
hasEndProp: true,
lastTraversedObject: {c: 'c'},
traversedPath: ['b', 'c'],
Expand All @@ -82,6 +88,7 @@ describe('getPath()', () => {
A.prototype.a = 'a';

expect(getPath(new A(), 'a')).toEqual({
endPropIsDefined: true,
hasEndProp: true,
lastTraversedObject: new A(),
traversedPath: ['a'],
Expand All @@ -100,6 +107,7 @@ describe('getPath()', () => {

test('empty object at the end', () => {
expect(getPath({a: {b: {c: {}}}}, 'a.b.c.d')).toEqual({
endPropIsDefined: false,
hasEndProp: false,
lastTraversedObject: {},
traversedPath: ['a', 'b', 'c'],
Expand Down
32 changes: 5 additions & 27 deletions packages/expect/src/matchers.ts
Expand Up @@ -730,37 +730,15 @@ const matchers: MatchersObject = {
}

const result = getPath(received, expectedPath);
const {lastTraversedObject, hasEndProp} = result;
const {lastTraversedObject, endPropIsDefined, hasEndProp, value} = result;
const receivedPath = result.traversedPath;
const hasCompletePath = receivedPath.length === expectedPathLength;
const receivedValue = hasCompletePath ? result.value : lastTraversedObject;

const pass = hasValue
? equals(result.value, expectedValue, [iterableEquality])
: Boolean(hasEndProp); // theoretically undefined if empty path
// Remove type cast if we rewrite getPath as iterative algorithm.

// Delete this unique report if future breaking change
// removes the edge case that expected value undefined
// also matches absence of a property with the key path.
if (pass && !hasCompletePath) {
const message = () =>
matcherHint(matcherName, undefined, expectedArgument, options) +
'\n\n' +
`Expected path: ${printExpected(expectedPath)}\n` +
`Received path: ${printReceived(
expectedPathType === 'array' || receivedPath.length === 0
? receivedPath
: receivedPath.join('.'),
)}\n\n` +
`Expected value: not ${printExpected(expectedValue)}\n` +
`Received value: ${printReceived(receivedValue)}\n\n` +
DIM_COLOR(
'Because a positive assertion passes for expected value undefined if the property does not exist, this negative assertion fails unless the property does exist and has a defined value',
);

return {message, pass};
}
const pass =
hasValue && endPropIsDefined
? equals(value, expectedValue, [iterableEquality])
: Boolean(hasEndProp);

const message = pass
? () =>
Expand Down
5 changes: 3 additions & 2 deletions packages/expect/src/utils.ts
Expand Up @@ -16,6 +16,7 @@ import {

type GetPath = {
hasEndProp?: boolean;
endPropIsDefined?: boolean;
lastTraversedObject: unknown;
traversedPath: Array<string>;
value?: unknown;
Expand Down Expand Up @@ -84,8 +85,8 @@ export const getPath = (
// Does object have the property with an undefined value?
// Although primitive values support bracket notation (above)
// they would throw TypeError for in operator (below).
result.hasEndProp =
newObject !== undefined || (!isPrimitive(object) && prop in object);
result.endPropIsDefined = !isPrimitive(object) && prop in object;
result.hasEndProp = newObject !== undefined || result.endPropIsDefined;

if (!result.hasEndProp) {
result.traversedPath.shift();
Expand Down