Skip to content

Commit

Permalink
Publish new package @adeira/jest-disallow-console
Browse files Browse the repository at this point in the history
This is a followup after jestjs/jest#11456. Basically, our previous solution stopped working reliably and after a discussion with Jest team I realized that it's an ugly hack and we are lucky it worked for so long.

This change introduces a new `@adeira/jest-disallow-console` package which helps with testing console warnings.

The code is heavily inspired by `warnings.js` from `facebook/relay` (see: https://github.com/facebook/relay/blob/57dde664137a221a4633d1c02592b2a4e17d3feb/packages/relay-test-utils-internal/warnings.js) but it's modified to work with `global.console` instead of `warning` module.
  • Loading branch information
mrtnzlml committed Jun 20, 2021
1 parent 3d49629 commit 2b79561
Show file tree
Hide file tree
Showing 17 changed files with 205 additions and 114 deletions.
67 changes: 0 additions & 67 deletions scripts/jest/setupTests.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
// @flow strict

/* eslint-disable no-console */

import util from 'util';
import os from 'os';
import chalk from 'chalk';

/**
* @see: https://facebook.github.io/jest/docs/expect.html#expectextendmatchers
Expand All @@ -31,66 +27,3 @@ expect.extend({
};
},
});

type MaybeSpy =
| {
+calls?: {
count: mixed,
...
},
...
}
| { ... };

const isSpy = (spy: MaybeSpy): boolean %checks => {
return spy.calls != null && typeof spy.calls.count === 'function';
};

// TODO: .toWarnDev() ?
// Inspired by React itself: https://github.com/facebook/react/blob/7841d0695ae4bde9848cf8953baf34d312d0cced/scripts/jest/setupTests.js
['error', 'warn', 'log', 'info', 'groupCollapsed'].forEach((methodName) => {
const unexpectedConsoleCallStacks = [];
const newMethod = function (format, ...args) {
const stack = new Error().stack;
unexpectedConsoleCallStacks.push([
stack.substr(stack.indexOf('\n') + 1),
util.format(format, ...args),
]);
};

// $FlowExpectedError[cannot-write]: these properties are not normally writable but it's expected in this case
console[methodName] = newMethod;

// TODO: which back to beforeEach, see https://github.com/facebook/jest/issues/11456
global.beforeAll(() => {
unexpectedConsoleCallStacks.length = 0;
});

// TODO: which back to afterEach, see https://github.com/facebook/jest/issues/11456
global.afterAll(() => {
if (console[methodName] !== newMethod && !isSpy(console[methodName])) {
throw new Error(
`Test did not tear down console.${methodName} mock properly. Did you call spy.mockRestore() or jest.restoreAllMocks()?`,
);
}

if (unexpectedConsoleCallStacks.length > 0) {
const messages = unexpectedConsoleCallStacks.map(
([stack, message]) =>
`${chalk.red(message)}\n` +
`${stack
.split('\n')
.map((line) => chalk.gray(line))
.join('\n')}`,
);

const message =
`Expected test not to call ${chalk.bold(`console.${methodName}()`)}.\n\n` +
`If the console output is expected, test for it explicitly by mocking it out using ${chalk.bold(
'jest.spyOn',
)}(console, '${methodName}').mockImplementation(...) and test that the output occurs.`;

throw new Error(`${message}\n\n${messages.join('\n\n')}`);
}
});
});
1 change: 1 addition & 0 deletions scripts/publishedPackages.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@adeira/graphql-relay",
"@adeira/graphql-relay-fauna",
"@adeira/graphql-resolve-wrapper",
"@adeira/jest-disallow-console",
"@adeira/js",
"@adeira/monorepo-npm-publisher",
"@adeira/monorepo-utils",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// @flow
/**
* @flow
* @jest-environment jsdom
*/

import { graphql, QueryRenderer } from '@adeira/relay';
import { create, act } from 'react-test-renderer';
Expand Down
1 change: 1 addition & 0 deletions src/fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"cross-fetch": "^3.1.4"
},
"devDependencies": {
"@adeira/jest-disallow-console": "^0.1.0",
"@sinonjs/fake-timers": "^7.1.2",
"form-data": "^4.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @flow

import { disallowWarnings, expectWarningWillFire } from '@adeira/jest-disallow-console';

import fetch from '../fetch';
import fetchWithRetries from '../fetchWithRetries';
import flushPromises from './_flushPromises';
Expand All @@ -19,8 +21,10 @@ function mockResponse(status) {
return { status };
}

disallowWarnings();

it('gives up if response failed after retries', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');

const handleNext = jest.fn();
const handleCatch = jest.fn();
Expand Down Expand Up @@ -57,10 +61,4 @@ it('gives up if response failed after retries', async () => {
'fetchWithRetries: Still no successful response after 2 retries, giving up.',
);
expect(errorArg.response).toEqual(failedResponse);

expect(consoleSpy).toHaveBeenCalledWith(
// hm, not sure about this message (why timeout?):
'fetchWithRetries: HTTP timeout (https://localhost), retrying.',
);
consoleSpy.mockRestore();
});
15 changes: 8 additions & 7 deletions src/fetch/src/__tests__/fetchWithRetries.retries.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @flow

import { disallowWarnings, expectWarningWillFire } from '@adeira/jest-disallow-console';

import fetch from '../fetch';
import fetchWithRetries from '../fetchWithRetries';
import flushPromises from './_flushPromises';
Expand All @@ -15,10 +17,15 @@ afterEach(() => {
jest.useRealTimers();
});

disallowWarnings();

it('retries the request if the previous attempt timed-out', async () => {
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');

let retries;
const handleNext = jest.fn();
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const retryDelays = [1000, 3000, 5000];

fetchWithRetries('https://localhost', { retryDelays }).catch(handleNext);
Expand Down Expand Up @@ -72,10 +79,4 @@ it('retries the request if the previous attempt timed-out', async () => {
expect(handleNext.mock.calls[0][0].message).toEqual(
'fetchWithRetries: Failed to get response from server (https://localhost), tried 4 times.',
);

expect(consoleSpy).toHaveBeenCalledTimes(3);
expect(consoleSpy).toHaveBeenCalledWith(
'fetchWithRetries: HTTP timeout (https://localhost), retrying.',
);
consoleSpy.mockRestore();
});
13 changes: 6 additions & 7 deletions src/fetch/src/__tests__/fetchWithRetries.retryAfterFail.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @flow

import { disallowWarnings, expectWarningWillFire } from '@adeira/jest-disallow-console';

import fetch from '../fetch';
import fetchWithRetries from '../fetchWithRetries';
import flushPromises from './_flushPromises';
Expand All @@ -15,12 +17,15 @@ afterEach(() => {
jest.useRealTimers();
});

disallowWarnings();

function mockResponse(status) {
return { status };
}

it('retries the request if the previous attempt failed', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// hm, not sure about this message (why timeout?):
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');

const handleNext = jest.fn();
const failedResponse = mockResponse(500);
Expand All @@ -45,10 +50,4 @@ it('retries the request if the previous attempt failed', async () => {
await flushPromises();
jest.runAllTimers();
expect(handleNext).toBeCalledWith(successfulResponse);

expect(consoleSpy).toHaveBeenCalledWith(
// hm, not sure about this message (why timeout?):
'fetchWithRetries: HTTP timeout (https://localhost), retrying.',
);
consoleSpy.mockRestore();
});
23 changes: 7 additions & 16 deletions src/fetch/src/__tests__/fetchWithRetries.timings.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
// @flow

import fakeTimers from '@sinonjs/fake-timers';
import { disallowWarnings, expectWarningWillFire } from '@adeira/jest-disallow-console';

import fetch from '../fetch';
import fetchWithRetries from '../fetchWithRetries';

jest.mock('../fetch');

disallowWarnings();

it('works with timeouts and retry delays correctly', () => {
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');
expectWarningWillFire('fetchWithRetries: HTTP timeout (https://localhost), retrying.');

const clock = fakeTimers.install();
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});

const DELTA = 1;

Expand Down Expand Up @@ -41,19 +47,4 @@ it('works with timeouts and retry delays correctly', () => {
expect(fetch.mock.calls).toHaveLength(4);

clock.uninstall();

expect(consoleSpy.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"fetchWithRetries: HTTP timeout (https://localhost), retrying.",
],
Array [
"fetchWithRetries: HTTP timeout (https://localhost), retrying.",
],
Array [
"fetchWithRetries: HTTP timeout (https://localhost), retrying.",
],
]
`);
consoleSpy.mockRestore();
});
3 changes: 3 additions & 0 deletions src/jest-disallow-console/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__tests__
BUILD.bazel
BUILD
22 changes: 22 additions & 0 deletions src/jest-disallow-console/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2019-present, Adeira
Copyright (c) 2013-present, Facebook, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1 change: 1 addition & 0 deletions src/jest-disallow-console/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TKTK
24 changes: 24 additions & 0 deletions src/jest-disallow-console/__tests__/consoleWarn.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @flow strict

import { disallowWarnings, expectWarningWillFire, expectToWarn } from '../index';

disallowWarnings();

// eslint-disable-next-line jest/prefer-todo
it('should pass when there are no warnings', () => {
// no warnings here
});

it('should pass when all warnings are expected (`expectWarningWillFire`)', () => {
expectWarningWillFire('yadada 1');
expectWarningWillFire('yadada 2');

console.warn('yadada 1'); // eslint-disable-line no-console
console.warn('yadada 2'); // eslint-disable-line no-console
});

it('should pass when all warnings are expected (`expectToWarn`)', () => {
expectToWarn('yadada', () => {
console.warn('yadada'); // eslint-disable-line no-console
});
});
19 changes: 19 additions & 0 deletions src/jest-disallow-console/__tests__/usage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// @flow strict

import { expectToWarn, expectWarningWillFire } from '../index';

it('throws an error when `expectToWarn` are nested', () => {
expect(() => {
expectToWarn('aaa', () => {
expectToWarn('bbb', () => {});
});
}).toThrowErrorMatchingInlineSnapshot(`"Cannot nest \`expectToWarn()\` calls."`);
});

it('throws an error when `expectWarningWillFire` is called without `disallowWarnings`', () => {
expect(() => {
expectWarningWillFire('aaa');
}).toThrowErrorMatchingInlineSnapshot(
`"\`disallowWarnings\` needs to be called before \`expectWarningWillFire\`"`,
);
});
Loading

0 comments on commit 2b79561

Please sign in to comment.