diff --git a/CHANGELOG.md b/CHANGELOG.md index 091499af1e48..7f20bf41a7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ promises ([#5670](https://github.com/facebook/jest/pull/5670)) * `[expect]` Add isError to utils ([#5670](https://github.com/facebook/jest/pull/5670)) +* `[expect]` Add inverse matchers (`expect.not.arrayContaining`, etc., + [#5517](https://github.com/facebook/jest/pull/5517)) ### Fixes diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index 2d9a3897e4f6..c2bc17ff81d2 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -273,6 +273,37 @@ test('prepareState prepares a valid state', () => { The `expect.hasAssertions()` call ensures that the `prepareState` callback actually gets called. +### `expect.not.arrayContaining(array)` + +`expect.not.arrayContaining(array)` matches a received array which contains none +of the elements in the expected array. That is, the expected array **is not a +subset** of the received array. + +It is the inverse of `expect.arrayContaining`. + +### `expect.not.objectContaining(object)` + +`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. + +It is the inverse of `expect.objectContaining`. + +### `expect.not.stringContaining(string)` + +`expect.not.stringContaining(string)` matches any received string that does not +contain the exact expected string. + +It is the inverse of `expect.stringContaining`. + +### `expect.not.stringMatching(regexp)` + +`expect.not.stringMatching(regexp)` matches any received string that does not +match the expected regexp. + +It is the inverse of `expect.stringMatching`. + ### `expect.objectContaining(object)` `expect.objectContaining(object)` matches any received object that recursively @@ -302,8 +333,6 @@ test('onPress gets called with the right thing', () => { ### `expect.stringContaining(string)` -##### available in Jest **19.0.0+** - `expect.stringContaining(string)` matches any received string that contains the exact expected string. diff --git a/packages/expect/src/__tests__/asymmetric_matchers.test.js b/packages/expect/src/__tests__/asymmetric_matchers.test.js index 54a3760b7c76..9b7a030d4d54 100644 --- a/packages/expect/src/__tests__/asymmetric_matchers.test.js +++ b/packages/expect/src/__tests__/asymmetric_matchers.test.js @@ -13,9 +13,13 @@ const { any, anything, arrayContaining, + arrayNotContaining, objectContaining, + objectNotContaining, stringContaining, + stringNotContaining, stringMatching, + stringNotMatching, } = require('../asymmetric_matchers'); test('Any.asymmetricMatch()', () => { @@ -89,6 +93,27 @@ test('ArrayContaining throws for non-arrays', () => { }).toThrow(); }); +test('ArrayNotContaining matches', () => { + jestExpect(arrayNotContaining(['foo']).asymmetricMatch(['bar'])).toBe(true); +}); + +test('ArrayNotContaining does not match', () => { + [ + arrayNotContaining([]).asymmetricMatch('jest'), + arrayNotContaining(['foo']).asymmetricMatch(['foo']), + arrayNotContaining(['foo']).asymmetricMatch(['foo', 'bar']), + arrayNotContaining([]).asymmetricMatch({}), + ].forEach(test => { + jestExpect(test).toEqual(false); + }); +}); + +test('ArrayNotContaining throws for non-arrays', () => { + jestExpect(() => { + arrayNotContaining('foo').asymmetricMatch([]); + }).toThrow(); +}); + test('ObjectContaining matches', () => { [ objectContaining({}).asymmetricMatch('jest'), @@ -139,28 +164,74 @@ test('ObjectContaining throws for non-objects', () => { jestExpect(() => objectContaining(1337).asymmetricMatch()).toThrow(); }); +test('ObjectNotContaining matches', () => { + [ + objectNotContaining({}).asymmetricMatch('jest'), + objectNotContaining({foo: 'foo'}).asymmetricMatch({bar: 'bar'}), + objectNotContaining({foo: 'foo'}).asymmetricMatch({foo: 'foox'}), + objectNotContaining({foo: undefined}).asymmetricMatch({}), + ].forEach(test => { + jestExpect(test).toEqual(true); + }); +}); + +test('ObjectNotContaining does not match', () => { + [ + objectNotContaining({foo: 'foo'}).asymmetricMatch({ + foo: 'foo', + jest: 'jest', + }), + objectNotContaining({foo: undefined}).asymmetricMatch({foo: undefined}), + objectNotContaining({ + first: objectNotContaining({second: {}}), + }).asymmetricMatch({first: {second: {}}}), + ].forEach(test => { + jestExpect(test).toEqual(false); + }); +}); + +test('ObjectNotContaining throws for non-objects', () => { + jestExpect(() => objectNotContaining(1337).asymmetricMatch()).toThrow(); +}); + test('StringContaining matches string against string', () => { jestExpect(stringContaining('en*').asymmetricMatch('queen*')).toBe(true); jestExpect(stringContaining('en').asymmetricMatch('queue')).toBe(false); - jestExpect(stringContaining('en').asymmetricMatch({})).toBe(false); }); test('StringContaining throws for non-strings', () => { jestExpect(() => { stringContaining([1]).asymmetricMatch('queen'); }).toThrow(); + + jestExpect(() => { + stringContaining('en*').asymmetricMatch(1); + }).toThrow(); +}); + +test('StringNotContaining matches string against string', () => { + jestExpect(stringNotContaining('en*').asymmetricMatch('queen*')).toBe(false); + jestExpect(stringNotContaining('en').asymmetricMatch('queue')).toBe(true); +}); + +test('StringNotContaining throws for non-strings', () => { + jestExpect(() => { + stringNotContaining([1]).asymmetricMatch('queen'); + }).toThrow(); + + jestExpect(() => { + stringNotContaining('en*').asymmetricMatch(1); + }).toThrow(); }); test('StringMatching matches string against regexp', () => { jestExpect(stringMatching(/en/).asymmetricMatch('queen')).toBe(true); jestExpect(stringMatching(/en/).asymmetricMatch('queue')).toBe(false); - jestExpect(stringMatching(/en/).asymmetricMatch({})).toBe(false); }); test('StringMatching matches string against string', () => { jestExpect(stringMatching('en').asymmetricMatch('queen')).toBe(true); jestExpect(stringMatching('en').asymmetricMatch('queue')).toBe(false); - jestExpect(stringMatching('en').asymmetricMatch({})).toBe(false); }); test('StringMatching throws for non-strings and non-regexps', () => { @@ -168,3 +239,31 @@ test('StringMatching throws for non-strings and non-regexps', () => { stringMatching([1]).asymmetricMatch('queen'); }).toThrow(); }); + +test('StringMatching throws for non-string actual values', () => { + jestExpect(() => { + stringMatching('en').asymmetricMatch(1); + }).toThrow(); +}); + +test('StringNotMatching matches string against regexp', () => { + jestExpect(stringNotMatching(/en/).asymmetricMatch('queen')).toBe(false); + jestExpect(stringNotMatching(/en/).asymmetricMatch('queue')).toBe(true); +}); + +test('StringNotMatching matches string against string', () => { + jestExpect(stringNotMatching('en').asymmetricMatch('queen')).toBe(false); + jestExpect(stringNotMatching('en').asymmetricMatch('queue')).toBe(true); +}); + +test('StringNotMatching throws for non-strings and non-regexps', () => { + jestExpect(() => { + stringNotMatching([1]).asymmetricMatch('queen'); + }).toThrow(); +}); + +test('StringNotMatching throws for non-string actual values', () => { + jestExpect(() => { + stringNotMatching('en').asymmetricMatch(1); + }).toThrow(); +}); diff --git a/packages/expect/src/__tests__/utils.test.js b/packages/expect/src/__tests__/utils.test.js index 9e850b325a70..95f5d796a1cc 100644 --- a/packages/expect/src/__tests__/utils.test.js +++ b/packages/expect/src/__tests__/utils.test.js @@ -9,7 +9,7 @@ 'use strict'; const {stringify} = require('jest-matcher-utils'); -const {getObjectSubset, getPath} = require('../utils'); +const {emptyObject, getObjectSubset, getPath} = require('../utils'); describe('getPath()', () => { test('property exists', () => { @@ -107,3 +107,18 @@ describe('getObjectSubset()', () => { ); }); }); + +describe('emptyObject()', () => { + test('matches an empty object', () => { + expect(emptyObject({})).toBe(true); + }); + + test('does not match an object with keys', () => { + expect(emptyObject({foo: undefined})).toBe(false); + }); + + test('does not match a non-object', () => { + expect(emptyObject(null)).toBe(false); + expect(emptyObject(34)).toBe(false); + }); +}); diff --git a/packages/expect/src/asymmetric_matchers.js b/packages/expect/src/asymmetric_matchers.js index 717d5f552c05..b8e7a9992264 100644 --- a/packages/expect/src/asymmetric_matchers.js +++ b/packages/expect/src/asymmetric_matchers.js @@ -15,8 +15,11 @@ import { isUndefined, } from './jasmine_utils'; +import {emptyObject} from './utils'; + class AsymmetricMatcher { $$typeof: Symbol; + inverse: boolean; constructor() { this.$$typeof = Symbol.for('jest.asymmetricMatcher'); @@ -113,29 +116,33 @@ class Anything extends AsymmetricMatcher { class ArrayContaining extends AsymmetricMatcher { sample: Array; - constructor(sample: Array) { + constructor(sample: Array, inverse: boolean = false) { super(); this.sample = sample; + this.inverse = inverse; } asymmetricMatch(other: Array) { if (!Array.isArray(this.sample)) { throw new Error( - "You must provide an array to ArrayContaining, not '" + + `You must provide an array to ${this.toString()}, not '` + typeof this.sample + "'.", ); } - return ( + const result = this.sample.length === 0 || (Array.isArray(other) && - this.sample.every(item => other.some(another => equals(item, another)))) - ); + this.sample.every(item => + other.some(another => equals(item, another)), + )); + + return this.inverse ? !result : result; } toString() { - return 'ArrayContaining'; + return `Array${this.inverse ? 'Not' : ''}Containing`; } getExpectedType() { @@ -146,34 +153,50 @@ class ArrayContaining extends AsymmetricMatcher { class ObjectContaining extends AsymmetricMatcher { sample: Object; - constructor(sample: Object) { + constructor(sample: Object, inverse: boolean = false) { super(); this.sample = sample; + this.inverse = inverse; } asymmetricMatch(other: Object) { if (typeof this.sample !== 'object') { throw new Error( - "You must provide an object to ObjectContaining, not '" + + `You must provide an object to ${this.toString()}, not '` + typeof this.sample + "'.", ); } - for (const property in this.sample) { - if ( - !hasProperty(other, property) || - !equals(this.sample[property], other[property]) - ) { - return false; + if (this.inverse) { + for (const property in this.sample) { + if ( + hasProperty(other, property) && + equals(this.sample[property], other[property]) && + !emptyObject(this.sample[property]) && + !emptyObject(other[property]) + ) { + return false; + } + } + + return true; + } else { + for (const property in this.sample) { + if ( + !hasProperty(other, property) || + !equals(this.sample[property], other[property]) + ) { + return false; + } } - } - return true; + return true; + } } toString() { - return 'ObjectContaining'; + return `Object${this.inverse ? 'Not' : ''}Containing`; } getExpectedType() { @@ -184,24 +207,27 @@ class ObjectContaining extends AsymmetricMatcher { class StringContaining extends AsymmetricMatcher { sample: string; - constructor(sample: string) { + constructor(sample: string, inverse: boolean = false) { super(); if (!isA('String', sample)) { throw new Error('Expected is not a string'); } this.sample = sample; + this.inverse = inverse; } asymmetricMatch(other: string) { if (!isA('String', other)) { - return false; + throw new Error('Actual is not a string'); } - return other.includes(this.sample); + const result = other.includes(this.sample); + + return this.inverse ? !result : result; } toString() { - return 'StringContaining'; + return `String${this.inverse ? 'Not' : ''}Containing`; } getExpectedType() { @@ -212,25 +238,28 @@ class StringContaining extends AsymmetricMatcher { class StringMatching extends AsymmetricMatcher { sample: RegExp; - constructor(sample: string | RegExp) { + constructor(sample: string | RegExp, inverse: boolean = false) { super(); if (!isA('String', sample) && !isA('RegExp', sample)) { throw new Error('Expected is not a String or a RegExp'); } this.sample = new RegExp(sample); + this.inverse = inverse; } asymmetricMatch(other: string) { if (!isA('String', other)) { - return false; + throw new Error('Actual is not a string'); } - return this.sample.test(other); + const result = this.sample.test(other); + + return this.inverse ? !result : result; } toString() { - return 'StringMatching'; + return `String${this.inverse ? 'Not' : ''}Matching`; } getExpectedType() { @@ -242,9 +271,17 @@ export const any = (expectedObject: any) => new Any(expectedObject); export const anything = () => new Anything(); export const arrayContaining = (sample: Array) => new ArrayContaining(sample); +export const arrayNotContaining = (sample: Array) => + new ArrayContaining(sample, true); export const objectContaining = (sample: Object) => new ObjectContaining(sample); +export const objectNotContaining = (sample: Object) => + new ObjectContaining(sample, true); export const stringContaining = (expected: string) => new StringContaining(expected); +export const stringNotContaining = (expected: string) => + new StringContaining(expected, true); export const stringMatching = (expected: string | RegExp) => new StringMatching(expected); +export const stringNotMatching = (expected: string | RegExp) => + new StringMatching(expected, true); diff --git a/packages/expect/src/index.js b/packages/expect/src/index.js index 51517855b673..c4da96f53352 100644 --- a/packages/expect/src/index.js +++ b/packages/expect/src/index.js @@ -29,9 +29,13 @@ import { any, anything, arrayContaining, + arrayNotContaining, objectContaining, + objectNotContaining, stringContaining, + stringNotContaining, stringMatching, + stringNotMatching, } from './asymmetric_matchers'; import { INTERNAL_MATCHER_FLAG, @@ -258,6 +262,14 @@ expect.extend = (matchers: MatchersObject): void => expect.anything = anything; expect.any = any; + +expect.not = { + arrayContaining: arrayNotContaining, + objectContaining: objectNotContaining, + stringContaining: stringNotContaining, + stringMatching: stringNotMatching, +}; + expect.objectContaining = objectContaining; expect.arrayContaining = arrayContaining; expect.stringContaining = stringContaining; diff --git a/packages/expect/src/utils.js b/packages/expect/src/utils.js index 35031a2264a2..12eceffd953f 100644 --- a/packages/expect/src/utils.js +++ b/packages/expect/src/utils.js @@ -209,3 +209,7 @@ export const isError = (value: any) => { return value instanceof Error; } }; + +export function emptyObject(obj: any) { + return obj && typeof obj === 'object' ? !Object.keys(obj).length : false; +}