Jest:期望对象没有属性

fdx2calv  于 2022-12-08  发布在  Jest
关注(0)|答案(7)|浏览(149)

我想写一个测试,Assert一个给定的对象 * 没有 * 某些属性。
假设我有一个函数

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

现在我要写一个测试:

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
      }));
  });
});

正确的方式是什么?

3lxsmp7m

3lxsmp7m1#

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

hgncfbus

hgncfbus2#

  • 此答案是已接受答案的释义。添加此答案只是因为已接受答案的此确切建议被拒绝。*

您可以明确检查对象是否具有名为barfoo的属性。

delete data.foo;
delete data.bar;

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

对于嵌套属性:

delete data.nested.property; 

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

或者,通过循环访问将要删除的属性,减少重复性。

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

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

然而,循环方法对于可能的嵌套对象来说并不太好。

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

这种方法工作正常,但有一个不幸的边缘情况:当属性存在但为undefinednull时,它会相符:

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

要解决这个问题,您可以显式地添加这些值,使其包含在检查中。您需要toBeOneOf()匹配器的jest-extended包。

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

下面是一个包含嵌套 prop 的示例,该示例在预期中会失败:

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

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

0h4hbjxa3#

你能检查一下结果吗?

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

您可以首先检查属性是否存在。
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'])
uurity8g

uurity8g4#

我只想试试:

expect(removeFooAndBar(data))
    .toEqual({
        baz: 'Baz'
    })
6l7fqoea

6l7fqoea5#

我会尝试,因为您知道使用它的data值:

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

**编辑1:**由于Jest的expect.not,请尝试以下内容:

const removed = removeFooAndBar(data);
expect(removed).not.toHaveProperty('foo');
expect(removed).not.toHaveProperty('bar');
expect(removed).toHaveProperty('baz');
gtlvzcf8

gtlvzcf86#

不要像其他人建议的那样选中object.foo === undefined。如果对象的属性foo设置为undefined,则结果为true
例如

const object = {
  foo: undefined
}

您是否尝试过使用hasOwnProperty函数?
这将为您提供以下结果

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);
bf1o4zei

bf1o4zei7#

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!

相关问题