diff --git a/packages/jest-matcher-utils/package.json b/packages/jest-matcher-utils/package.json index 36c716d7c473..6eb3b5127115 100644 --- a/packages/jest-matcher-utils/package.json +++ b/packages/jest-matcher-utils/package.json @@ -10,5 +10,8 @@ "main": "build/index.js", "scripts": { "test": "../../packages/jest-cli/bin/jest.js" + }, + "dependencies": { + "chalk": "^1.1.3" } } diff --git a/packages/jest-matcher-utils/src/__tests__/index-test.js b/packages/jest-matcher-utils/src/__tests__/index-test.js index 6e76b0fe2b3e..f7610ed238bb 100644 --- a/packages/jest-matcher-utils/src/__tests__/index-test.js +++ b/packages/jest-matcher-utils/src/__tests__/index-test.js @@ -53,4 +53,6 @@ describe('.getType()', () => { test('function', () => expect(getType(() => {})).toBe('function')); test('boolean', () => expect(getType(true)).toBe('boolean')); test('symbol', () => expect(getType(Symbol.for('a'))).toBe('symbol')); + test('regexp', () => expect(getType(/abc/)).toBe('regexp')); + }); diff --git a/packages/jest-matcher-utils/src/index.js b/packages/jest-matcher-utils/src/index.js index eda4028e83a1..16058849602c 100644 --- a/packages/jest-matcher-utils/src/index.js +++ b/packages/jest-matcher-utils/src/index.js @@ -10,7 +10,19 @@ 'use strict'; -import type {ValueType} from 'types/Values'; +export type ValueType = + | 'array' + | 'boolean' + | 'function' + | 'null' + | 'number' + | 'object' + | 'regexp' + | 'string' + | 'symbol' + | 'undefined'; + +const chalk = require('chalk'); // get the type of a value with handling the edge cases like `typeof []` // and `typeof null` @@ -30,6 +42,9 @@ const getType = (value: any): ValueType => { } else if (typeof value === 'string') { return 'string'; } else if (typeof value === 'object') { + if (value.constructor === RegExp) { + return 'regexp'; + } return 'object'; // $FlowFixMe https://github.com/facebook/flow/issues/1015 } else if (typeof value === 'symbol') { @@ -75,6 +90,9 @@ const stringify = (obj: any): string => { }); }; +// highlight an object +const h = (obj: any) => chalk.cyan.bold(stringify(obj)); + const ensureNoExpected = (expected: any, matcherName: string) => { matcherName || (matcherName = 'This'); if (typeof expected !== 'undefined') { @@ -108,10 +126,11 @@ const ensureNumbers = (actual: any, expected: any, matcherName: string) => { }; module.exports = { - getType, - stringify, - ensureNoExpected, ensureActualIsNumber, ensureExpectedIsNumber, + ensureNoExpected, ensureNumbers, + getType, + h, + stringify, }; diff --git a/packages/jest-matchers/src/__tests__/__snapshots__/toThrowMatchers-test.js.snap b/packages/jest-matchers/src/__tests__/__snapshots__/toThrowMatchers-test.js.snap new file mode 100644 index 000000000000..c9e0558d1c57 --- /dev/null +++ b/packages/jest-matchers/src/__tests__/__snapshots__/toThrowMatchers-test.js.snap @@ -0,0 +1,47 @@ +exports[`.toThrowError() error class did not throw at all 1`] = `"Expected the function to throw an error of \"Err\" type, but it didn\'t."`; + +exports[`.toThrowError() error class threw, but class did not match 1`] = ` +"Expected the function to throw an error of \"Err2\" type, but it didn\'t. +Actual error: + type: \"Err\" + message: \"apple\"" +`; + +exports[`.toThrowError() error class threw, but should not have 1`] = ` +"Expected the function to not throw an error of \"Err\" type, but it did. +Actual error: + type: \"Err\" + message: \"apple\"" +`; + +exports[`.toThrowError() regexp did not throw at all 1`] = `"Expected the function to throw an error matching \"/apple/\", but it didn\'t."`; + +exports[`.toThrowError() regexp threw, but message did not match 1`] = ` +"Expected the function to throw an error matching \"/banana/\", but it didn\'t. +Actual error: + type: \"Error\" + message: \"apple\"" +`; + +exports[`.toThrowError() regexp threw, but should not have 1`] = ` +"Expected the function to not throw an error matching \"/apple/\", but it did. +Actual error: + type: \"Error\" + message: \"apple\"" +`; + +exports[`.toThrowError() strings did not throw at all 1`] = `"Expected the function to throw an error matching \"apple\", but it didn\'t."`; + +exports[`.toThrowError() strings threw, but message did not match 1`] = ` +"Expected the function to throw an error matching \"banana\", but it didn\'t. +Actual error: + type: \"Error\" + message: \"apple\"" +`; + +exports[`.toThrowError() strings threw, but should not have 1`] = ` +"Expected the function to not throw an error matching \"apple\", but it did. +Actual error: + type: \"Error\" + message: \"apple\"" +`; diff --git a/packages/jest-matchers/src/__tests__/spy-matchers-test.js b/packages/jest-matchers/src/__tests__/spyMatchers-test.js similarity index 97% rename from packages/jest-matchers/src/__tests__/spy-matchers-test.js rename to packages/jest-matchers/src/__tests__/spyMatchers-test.js index edc99c027b11..867e7a090aaf 100644 --- a/packages/jest-matchers/src/__tests__/spy-matchers-test.js +++ b/packages/jest-matchers/src/__tests__/spyMatchers-test.js @@ -49,9 +49,7 @@ describe('.toHaveBeenCalledTimes()', () => { it('accept only numbers', () => { const foo = jasmine.createSpy('foo'); foo(); - - expect(() => jestExpect(foo).toHaveBeenCalledTimes(1)) - .not.toThrowError(); + jestExpect(foo).toHaveBeenCalledTimes(1); [{}, [], true, 'a', new Map(), () => {}].forEach(value => { expect(() => jestExpect(foo).toHaveBeenCalledTimes(value)) diff --git a/packages/jest-matchers/src/__tests__/toThrowMatchers-test.js b/packages/jest-matchers/src/__tests__/toThrowMatchers-test.js new file mode 100644 index 000000000000..4897a447ab16 --- /dev/null +++ b/packages/jest-matchers/src/__tests__/toThrowMatchers-test.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+jsinfra + */ + +'use strict'; + +describe('.toThrowError()', () => { + describe('strings', () => { + it('passes', () => { + expect(() => { throw new Error('apple'); }).toThrowError('apple'); + expect(() => { throw new Error('banana'); }).not.toThrowError('apple'); + expect(() => {}).not.toThrowError('apple'); + }); + + test('did not throw at all', () => { + let error; + try { + expect(() => {}).toThrowError('apple'); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + + test('threw, but message did not match', () => { + let error; + try { + expect(() => { throw new Error('apple'); }).toThrowError('banana'); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + + test('threw, but should not have', () => { + let error; + try { + expect(() => { throw new Error('apple'); }).not.toThrowError('apple'); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + }); + + describe('regexp', () => { + it('passes', () => { + expect(() => { throw new Error('apple'); }).toThrowError(/apple/); + expect(() => { throw new Error('banana'); }).not.toThrowError(/apple/); + expect(() => {}).not.toThrowError(/apple/); + }); + + test('did not throw at all', () => { + let error; + try { + expect(() => {}).toThrowError(/apple/); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + + test('threw, but message did not match', () => { + let error; + try { + expect(() => { throw new Error('apple'); }).toThrowError(/banana/); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot() + }); + + test('threw, but should not have', () => { + let error; + try { + expect(() => { throw new Error('apple'); }).not.toThrowError(/apple/); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + }); + + describe('error class', () => { + class Err extends Error {} + class Err2 extends Error {} + + it('passes', () => { + expect(() => { throw new Err(); }).toThrowError(Err); + expect(() => { throw new Err(); }).toThrowError(Error); + expect(() => { throw new Err(); }).not.toThrowError(Err2); + expect(() => {}).not.toThrowError(Err); + }); + + test('did not throw at all', () => { + let error; + try { + expect(() => {}).toThrowError(Err); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + + test('threw, but class did not match', () => { + let error; + try { + expect(() => { throw new Err('apple'); }).toThrowError(Err2); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + + test('threw, but should not have', () => { + let error; + try { + expect(() => { throw new Err('apple'); }).not.toThrowError(Err); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error.message).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/jest-matchers/src/index.js b/packages/jest-matchers/src/index.js index 3ef9280eae62..975281a324d8 100644 --- a/packages/jest-matchers/src/index.js +++ b/packages/jest-matchers/src/index.js @@ -20,9 +20,15 @@ import type { } from './types'; const matchers = require('./matchers'); -const spyMatchers = require('./spy-matchers'); +const spyMatchers = require('./spyMatchers'); +const toThrowMatchers = require('./toThrowMatchers'); + +const {stringify} = require('jest-matcher-utils'); + const GLOBAL_MATCHERS_OBJECT_SYMBOL = Symbol.for('$$jest-matchers-object'); +class JestAssertionError extends Error {} + if (!global[GLOBAL_MATCHERS_OBJECT_SYMBOL]) { Object.defineProperty( global, @@ -57,6 +63,8 @@ const makeThrowingMatcher = ( {args: arguments}, ); + _validateResult(result); + if ((result.pass && isNot) || (!result.pass && !isNot)) { // XOR let message = result.message; @@ -66,7 +74,7 @@ const makeThrowingMatcher = ( message = message(); } - const error = new Error(message); + const error = new JestAssertionError(message); // Remove this function from the stack trace frame. Error.captureStackTrace(error, throwingMatcher); throw error; @@ -78,9 +86,29 @@ const addMatchers = (matchersObj: MatchersObject): void => { Object.assign(global[GLOBAL_MATCHERS_OBJECT_SYMBOL], matchersObj); }; +const _validateResult = result => { + if ( + typeof result !== 'object' || + typeof result.pass !== 'boolean' || + !( + typeof result.message === 'string' || + typeof result.message === 'function' + ) + ) { + throw new Error( + 'Unexpected return from a matcher function.\n' + + 'Matcher functions should ' + + 'return an object in the following format:\n' + + ' {message: string | function, pass: boolean}\n' + + `'${stringify(result)}' was returned`, + ); + } +}; + // add default jest matchers addMatchers(matchers); addMatchers(spyMatchers); +addMatchers(toThrowMatchers); module.exports = { addMatchers, diff --git a/packages/jest-matchers/src/spy-matchers.js b/packages/jest-matchers/src/spyMatchers.js similarity index 100% rename from packages/jest-matchers/src/spy-matchers.js rename to packages/jest-matchers/src/spyMatchers.js diff --git a/packages/jest-matchers/src/toThrowMatchers.js b/packages/jest-matchers/src/toThrowMatchers.js new file mode 100644 index 000000000000..96386a91e746 --- /dev/null +++ b/packages/jest-matchers/src/toThrowMatchers.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2014, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +'use strict'; + +import type {MatchersObject} from './types'; + +const {h, getType} = require('jest-matcher-utils'); + +const matchers: MatchersObject = { + toThrowError(actual: Function, expected: string | Error | RegExp) { + let error; + + try { + actual(); + } catch (e) { + error = e; + } + + if (typeof expected === 'string') { + return toThrowMatchingStringOrRegexp(error, expected); + } else if (typeof expected === 'function') { + return toThrowMatchingError(error, expected); + } else if (expected instanceof RegExp) { + return toThrowMatchingStringOrRegexp(error, expected); + } else { + throw new Error( + 'Unexpected argument passed. Expected to get ' + + `"string", "Error type" or "regexp". Got: ${h(getType(expected))} ` + + `'${h(expected)}'`, + ); + } + }, +}; + +const toThrowMatchingStringOrRegexp = ( + error, + strOrRegExp: string | RegExp, +) => { + const pass = !!(error && error.message.match(strOrRegExp)); + let message = pass + ? 'Expected the function to not throw an error matching ' + + `${h(strOrRegExp)}, but it did.` + : 'Expected the function to throw an error matching ' + + `${h(strOrRegExp)}, but it didn't.`; + + if (error) { + message += _printThrownError(error); + } + + return {pass, message}; +}; + +const toThrowMatchingError = (error, ErrorClass) => { + const pass = !!(error && error instanceof ErrorClass); + let message = pass + ? `Expected the function to not throw an error of ${h(ErrorClass.name)} ` + + 'type, but it did.' + : `Expected the function to throw an error of ${h(ErrorClass.name)} ` + + `type, but it didn't.`; + + if (error) { + message += _printThrownError(error); + } + + return {pass, message}; +}; + +const _printThrownError = error => { + return '\n' + + 'Actual error:\n' + + ` type: ${h(error.constructor.name)}\n` + + ` message: ${h(error.message)}`; +}; + +module.exports = matchers; diff --git a/types/Values.js b/types/Values.js deleted file mode 100644 index 73984447f67e..000000000000 --- a/types/Values.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @flow - */ -'use strict'; - -// Types of javascript values returned by `jest-matcher-utils$getType()` -export type ValueType = - | 'array' - | 'boolean' - | 'function' - | 'null' - | 'number' - | 'object' - | 'string' - | 'symbol' - | 'undefined';