11

I want to write a test that asserts a given object does not have certain properties.

Say I have a function

function removeFooAndBar(input) {
  delete input.foo;
  delete input.bar;
  return input;
}

Now I want to write a test:

describe('removeFooAndBar', () => {
  it('removes properties `foo` and `bar`', () => {
    const data = {
      foo: 'Foo',
      bar: 'Bar',
      baz: 'Baz',
    };
    expect(removeFooAndBar(data))
      .toEqual(expect.objectContaining({
        baz: 'Baz', // what's left
        foo: expect.not.exists() // pseudo
        bar: undefined // this doesn't work, and not what I want
      }));
  });
});

What's the proper way to assert this?

joegomain
  • 536
  • 1
  • 5
  • 22

8 Answers8

14

Update after the discussion in the comments

You can use expect.not.objectContaining(). This approach works fine but has one unfortunate edge case: It matches when the property exists, but is undefined or null. To fix this you can explicitly add those values to be included in the check. You need the jest-extended package for the toBeOneOf() matcher.

expect({foo: undefined}).toEqual(expect.not.objectContaining(
    {foo: expect.toBeOneOf([expect.anything(), undefined, null])}
));

An example with nested props that fails:

const reallyAnything = expect.toBeOneOf([expect.anything(), undefined, null]);

expect({foo: undefined, bar: {baz: undefined}}).toEqual(
    expect.not.objectContaining(
        {
            foo: reallyAnything,
            bar: {baz: reallyAnything},
        }
    )
);

Original answer

What I'd do is to explicitly check whether the object has a property named bar or foo.

delete data.foo;
delete data.bar;
delete data.nested.property; 

expect(data).not.toHaveProperty('bar');
expect(data).not.toHaveProperty('foo');
expect(data.nested).not.toHaveProperty('property');
// or
expect(data).not.toHaveProperty('nested.property');

Or make this less repeating by looping over the properties that will be removed.

const toBeRemoved = ['foo', 'bar'];

toBeRemoved.forEach((prop) => {
    delete data[prop];
    expect(data).not.toHaveProperty(prop);
});

However, the loop approach isn't too great for possible nested objects. I believe what you are looking for is expect.not.objectContaining()

expect(data).toEqual(expect.not.objectContaining({foo: 'Foo', bar: 'Bar'}));

expect.not.objectContaining(object) matches any received object that does not recursively match the expected properties. That is, the expected object is not a subset of the received object. Therefore, it matches a received object which contains properties that are not in the expected object. - Jest Documentation

Behemoth
  • 5,389
  • 4
  • 16
  • 40
  • This looks promising. would `expect.not.objectContaining({ foo: expect.anything() })` match `{ foo: undefined }`? (it should not) – joegomain Aug 01 '22 at 06:33
  • 1
    Yes, that matches. It sounds weird but although `foo` is undefined here the property was still defined with a value and thus exists. Why should it not match though? Do you care about property values too? – Behemoth Aug 01 '22 at 06:57
  • I want to assert that the property does not exist at all. Not just that it would be `undefined` when try to access. – joegomain Aug 01 '22 at 14:16
  • Each time I look at that line I have to read it 3 times to understand that double negation. But yes, you're right, that would be an unfortunate edge case. – Behemoth Aug 03 '22 at 08:58
  • Take a look at my edit in the answer. – Behemoth Aug 03 '22 at 09:08
  • Impressive. Please expand the answer to cover nested props and I'll grant you the bounty. Thankyou. – joegomain Aug 03 '22 at 15:21
7

This answer is a paraphrase of the accepted answer. It is added only because of this exact suggestion to the accepted answer was rejected.

You can explicitly check whether the object has a property named bar or foo.

delete data.foo;
delete data.bar;

expect(data).not.toHaveProperty('bar');
expect(data).not.toHaveProperty('foo');

For nested properties:

delete data.nested.property; 

expect(data.nested).not.toHaveProperty('property');
// or
expect(data).not.toHaveProperty('nested.property');

Or make this less repeating by looping over the properties that will be removed.

const toBeRemoved = ['foo', 'bar', 'nested.property'];

toBeRemoved.forEach((prop) => {
    expect(data).not.toHaveProperty(prop);
});

However, the loop approach isn't too great for possible nested objects. What you are looking for is expect.not.objectContaining().

expect({baz: 'some value'}).toEqual(expect.not.objectContaining(
    {foo: expect.anything()}
));

This approach works fine but has one unfortunate edge case: It matches when the property exists, but is undefined or null:

expect({foo: undefined}).toEqual(expect.not.objectContaining(
    {foo: expect.anything()}
));

would also match. To fix this you can explicitly add those values to be included in the check. You need the jest-extended package for the toBeOneOf() matcher.

expect({foo: undefined}).toEqual(expect.not.objectContaining(
    {foo: expect.toBeOneOf([expect.anything(), undefined, null])}
));

An example with nested props that, expectedly, fails:

const reallyAnything = expect.toBeOneOf([expect.anything(), undefined, null]);

expect({foo: undefined, bar: {baz: undefined}}).toEqual(
    expect.not.objectContaining(
        {
            foo: reallyAnything,
            bar: {baz: reallyAnything},
        }
    )
);
joegomain
  • 536
  • 1
  • 5
  • 22
2

I'd just try because you know the data value to use it:

const data = {...};
const removed = {...data};
delete removed.foo;
delete removed.bar;
expect(removeFooAndBar(data)).toEqual(removed);

Edit 1: Because of Jest's expect.not, try something like:

const removed = removeFooAndBar(data);
expect(removed).not.toHaveProperty('foo');
expect(removed).not.toHaveProperty('bar');
expect(removed).toHaveProperty('baz');
0xLogN
  • 3,289
  • 1
  • 14
  • 35
  • This example is contrived. I'm looking for a Jest idiomatic way or maybe some custom matchers that asserts explicitly that a runtime object _does not_ have a list of (possibly nested) properties. – joegomain Aug 01 '22 at 05:06
  • @joegomain Please read my edits. – 0xLogN Aug 01 '22 at 15:43
1

can you check the result? example?

const result = removeFooAndBar(data)
expect(result.foo).toBeUndefined()
expect(result.bar).toBeUndefined()

you can check initially that the properties were there.

The other option is to extend the expect function: https://jestjs.io/docs/expect#expectextendmatchers

expect.extend({
  withUndefinedKeys(received, keys) {
    const pass = keys.every((k) => typeof received[k] === 'undefined')
      if (pass) {
        return {
          pass: true,
       }
    }
    return {
       message: () => `expected all keys ${keys} to not be defined in ${received}`,
       pass: false,
    }
  },
})
expect({ baz: 'Baz' }).withUndefinedKeys(['bar', 'foo'])
Buggies
  • 383
  • 1
  • 7
1

It is possible to check whether an object has selected fields (expect.objectContaining) and in a separate assertion whether it does not have selected fields (expect.not.objectContaining). However, it is not possible, by default, to check these two things in one assertion, at least I have not heard of it yet.

Goal: create a expect.missing matcher similar to standard expect.any or expect.anything which will check if the object does not have the selected field and can be used alongside matchers of existing fields.

My attempts to reach this goal are summarized below, maybe someone will find them useful or be able to improve upon them. I point out that this is a proof of concept and it is possible that there are many errors and cases that I did not anticipate.


AsymmetricMatchers in their current form lack the ability to check their context, for example, when checking the expect.any condition for a in the object { a: expect.any(String), b: [] }, expect.any knows nothing about the existence of b, the object in which a is a field or even that the expected value is assigned to the key a. For this reason, it is not enough to create only expect.missing but also a custom version of expect.objectContaining, which will be able to provide the context for our expect.missing matcher.

expect.missing draft:

import { AsymmetricMatcher, expect } from 'expect';  // npm i expect

class Missing extends AsymmetricMatcher<void> {
    asymmetricMatch(actual: unknown): boolean {
       // By default, here we have access only to the actual value of the selected field
        return !Object.hasOwn(/* TODO get parent object */, /* TODO get property name */);
    }
    toString(): string {
        return 'Missing';
    }
    toAsymmetricMatcher(): string {
        return this.toString(); // how the selected field will be marked in the diff view
    }
}

Somehow the matcher above should be given context: object and property name. We will create a custom expect.objectContaining - let's call it expect.objectContainingOrNot:

class ObjectContainingOrNot extends AsymmetricMatcher<Record<string, unknown>> {
    asymmetricMatch(actual: any): boolean {
        const { equals } = this.getMatcherContext();
        for (const [ property, expected ] of Object.entries(this.sample)) {
            const received = actual[ property ];

            if (expected instanceof Missing) {
                Object.assign(expected, { property, propertyContext: actual });
            } // TODO: this would be sufficient if we didn't care about nested values

            if (!equals(received, expected)) {
                return false;
            }
        }
        return true;
    }
    toString(): string {
        // borrowed from .objectContaining for sake of nice diff printing
        return 'ObjectContaining';
    }
    override getExpectedType(): string {
        return 'object';
    }
}

Register new matchers to the expect:

expect.missing = () => new Missing();
expect.objectContainingOrNot = (sample: Record<string, unknown>) => 
    new ObjectContainingOrNot(sample);

declare module 'expect' {
    interface AsymmetricMatchers {
        missing(): void;
        objectContainingOrNot(expected: Record<string, unknown>): void;
    }
}

Full complete code:

import { AsymmetricMatcher, expect } from 'expect'; // npm i expect


class Missing extends AsymmetricMatcher<void> {
    property?: string;
    propertyContext?: object;
    asymmetricMatch(_actual: unknown): boolean {
        if (!this.property || !this.propertyContext) {
            throw new Error(
                '.missing() expects to be used only' +
                ' inside .objectContainingOrNot(...)'
            );
        }
        return !Object.hasOwn(this.propertyContext, this.property);
    }
    toString(): string {
        return 'Missing';
    }
    toAsymmetricMatcher(): string {
        return this.toString();
    }
}

class ObjectContainingOrNot extends AsymmetricMatcher<Record<string, unknown>> {
    asymmetricMatch(actual: any): boolean {
        const { equals } = this.getMatcherContext();
        for (const [ property, expected ] of Object.entries(this.sample)) {
            const received = actual[ property ];
            assignPropertyCtx(actual, property, expected);
            if (!equals(received, expected)) {
                return false;
            }
        }
        return true;
    }
    toString(): string {
        return 'ObjectContaining';
    }
    override getExpectedType(): string {
        return 'object';
    }
}

// Ugly but is able to assign context for nested `expect.missing`s
function assignPropertyCtx(ctx: any, key: PropertyKey, value: unknown): unknown {
    if (value instanceof Missing) {
        return Object.assign(value, { property: key, propertyContext: ctx });
    }
    const newCtx = ctx?.[ key ];
    if (Array.isArray(value)) {
        return value.forEach((e, i) => assignPropertyCtx(newCtx, i, e));
    }
    if (value && (typeof value === 'object')) {
        return Object.entries(value)
            .forEach(([ k, v ]) => assignPropertyCtx(newCtx, k, v));
    }
}

expect.objectContainingOrNot = (sample: Record<string, unknown>) =>
    new ObjectContainingOrNot(sample);
expect.missing = () => new Missing();

declare module 'expect' {
    interface AsymmetricMatchers {
        objectContainingOrNot(expected: Record<string, unknown>): void;
        missing(): void;
    }
}

Usage examples:

expect({ baz: 'Baz' }).toEqual(expect.objectContainingOrNot({
    baz: expect.stringMatching(/^baz$/i),
    foo: expect.missing(),
})); // pass

expect({ baz: 'Baz', foo: undefined }).toEqual(expect.objectContainingOrNot({
    baz: 'Baz',
    foo: expect.missing(),
})); // fail

// works with nested!
expect({ arr: [ { id: '1' }, { no: '2' } ] }).toEqual(expect.objectContainingOrNot({
    arr: [ { id: '1' }, { no: expect.any(String), id: expect.missing() } ],
})); // pass


When we assume that the field is also missing when it equals undefined ({ a: undefined } => a is missing) then the need to pass the context to expect.missing disappears and the above code can be simplified to:

import { AsymmetricMatcher, expect } from 'expect';


class ObjectContainingOrNot extends AsymmetricMatcher<Record<string, unknown>> {
    asymmetricMatch(actual: any): boolean {
        const { equals } = this.getMatcherContext();
        for (const [ property, expected ] of Object.entries(this.sample)) {
            const received = actual[ property ];
            if (!equals(received, expected)) {
                return false;
            }
        }
        return true;
    }
    toString(): string {
        return `ObjectContaining`;
    }
    override getExpectedType(): string {
        return 'object';
    }
}

expect.extend({
    missing(actual: unknown) {
        // However, it still requires to be used only inside
        // expect.objectContainingOrNot.
        // expect.objectContaining checks if the objects being compared
        // have matching property names which happens before the value
        // of those properties reaches this matcher
        return {
            pass: actual === undefined,
            message: () => 'It seems to me that in the' +
                ' case of this matcher this message is never used',
        };
    },
});
expect.objectContainingOrNot = (sample: Record<string, unknown>) =>
    new ObjectContainingOrNot(sample);

declare module 'expect' {
    interface AsymmetricMatchers {
        missing(): void;
        objectContainingOrNot(expected: Record<string, unknown>): void;
    }
}

// With these assumptions, assertion below passes
expect({ baz: 'Baz', foo: undefined }).toEqual(expect.objectContainingOrNot({
    baz: 'Baz',
    foo: expect.missing(),
}));

It was fun, have a nice day!

kajkal
  • 504
  • 5
  • 8
  • The primary goal was to assert that `foo` in `{ foo: undefinded }` is not _missing_ and only missing in `{ bar: "defined" }`. This is very interesting approach and should extend to recursive asymmetric matchers. – joegomain Mar 02 '23 at 04:10
  • The challenge here is probably getting this custom `objectContainingOrNot` to also work with existing matchers. – joegomain Mar 02 '23 at 04:13
  • Your primary goal can be achieved by: `expect({ foo: undefined }).not.toEqual(expect.objectContainingOrNot({ foo: expect.missing() }));` `expect({ bar: 'defined' }).toEqual(expect.objectContainingOrNot({ foo: expect.missing() }));` – kajkal Mar 02 '23 at 17:14
  • If you take a look at source code: https://github.com/facebook/jest/blob/main/packages/expect/src/asymmetricMatchers.ts you will find that `ObjectContainingOrNot` does not really change that much from the original. As for working with existing matchers - the new matcher is basically identical to the `objectContaining` in this regard. You could even instead of registering a new matcher: `expect.objectContainingOrNot = [...]`, override existing `expect.objectContaining = [...]` and use `expect.missing` inside `objectContaining` just like you would use `expect.any` or `expect.anything`. – kajkal Mar 02 '23 at 17:19
0

Do not check object.foo === undefined as others suggest. This will result to true if the object has the property foo set to undefined

eg.

const object = {
  foo: undefined
}

Have you tried use the hasOwnProperty function?

this will give you the following results

const object = {foo: ''};
expect(Object.prototype.hasOwnProperty.call(object, 'foo')).toBe(true);

object.foo = undefined;
expect(Object.prototype.hasOwnProperty.call(object, 'foo')).toBe(true);

delete object.foo;
expect(Object.prototype.hasOwnProperty.call(object, 'foo')).toBe(false);
0

I'm not sure if this was an intended feature, but you can actually slap a .not on expect.anything().

describe('removeFooAndBar', () => {
  it('removes properties `foo` and `bar`', () => {
    const data = {
      foo: 'Foo',
      bar: 'Bar',
      baz: 'Baz',
    };
    expect(removeFooAndBar(data))
      .toEqual(expect.objectContaining({
        baz: 'Baz',
        foo: expect.anything().not,
        bar: expect.anything().not,
      }));
  });
});
Cameron Hudson
  • 3,190
  • 1
  • 26
  • 38
-1

I would just try:

expect(removeFooAndBar(data))
    .toEqual({
        baz: 'Baz'
    })
Taylor Belk
  • 162
  • 1
  • 16