@lexa79/jest-matchers
Version:
Collection of personal additions to Jest for unit testing React applications
439 lines (398 loc) • 14 kB
JavaScript
/**
* Copyright (c) 2019 <alexander.urban@cygni.se>
*
* 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.
*/
const fs = require( "fs" );
const path = require( "path" );
const toDiffableHtml = require( "diffable-html" );
let globalRenderHook = null;
/**
* Initialise matchers with valid renderer (e.g. ReactDOMServer)
* so that a given React component can be automatically rendered into HTML code.
*
* @param {object|function} renderer
* Instance of ReactDOMServer, so that function renderToString() can be accessed; or
* Instance of enzyme, so that function shallow() can be accessed; or
* Callback function which takes a React component and returns its HTML code.
*/
function connectRenderer( renderer ) {
if ( renderer == null ) {
return;
}
switch ( typeof renderer ) {
case "object":
if ( typeof renderer.renderToString === "function" ) {
globalRenderHook = renderer.renderToString;
}
if ( typeof renderer.shallow === "function" ) {
globalRenderHook = component => renderer.shallow( component ).html();
}
break;
case "function":
globalRenderHook = renderer;
break;
default:
throw new Error( "Can't use given renderer - invalid type" );
}
}
/**
* Ensures that the given callback crashs.
* Any output produced with console.error() is suppressed.
*
* Negation behaves like a "toSucceedWithSuppressedOutput":
* Ensures that the given callback doesn't crash.
* Any output produced with console.error() is suppressed.
*
* @param {function} callback
* Callback function which shall be checked
*
* @returns {object}
* Description of the test result in the form { message: ..., pass: ... }
*/
function toThrowWithSuppressedOutput( callback ) {
let hasThrown = false;
/* eslint-disable no-console */
const originalConsole = console.error;
console.error = () => null;
try {
callback.call();
} catch ( error ) {
hasThrown = true;
}
console.error = originalConsole;
/* eslint-enable no-console */
return {
message: () => `expected callback "${callback.toString()}" to ${hasThrown ? "succeed" : "fail"}`,
pass: hasThrown,
};
}
/**
* Ensures that the given callback doesn't crash
* and that in the same time some output is produced with console.error().
*
* Negation:
* Ensures that the callback crashs or
* at least doesn't generate any output with console.error().
*
* @param {function} callback
* Callback function which shall be checked
*
* @returns {object}
* Description of the test result in the form { message: ..., pass: ... }
*/
function toSucceedWithMessages( callback ) {
let hasThrown = false;
const output = [];
/* eslint-disable no-console */
const originalConsole = console.error;
console.error = ( ...msg ) => Array.prototype.push.apply( output, msg ) && null;
try {
callback.call();
} catch ( error ) {
hasThrown = true;
}
console.error = originalConsole;
/* eslint-enable no-console */
const pass = !hasThrown && output.length > 0;
return {
message: () => {
if ( pass ) {
return `Expected callback "${callback.toString()}" to fail or at least not produce any output`;
}
if ( hasThrown ) {
return `Expected callback "${callback.toString()}" to succeed, `
+ `but it failed with those errors:\n${output.join( "\n" )}\n`;
}
return `Expected callback "${callback.toString()}" to produce some output`;
},
pass,
};
}
/**
* Ensures that the given callback doesn't crash
* and doesn't produce any output with console.error().
*
* Negation:
* Ensures that the callback crashs or
* at least produces some output with console.error().
*
* @param {function} callback
* Callback function which shall be checked
*
* @returns {object}
* Description of the test result in the form { message: ..., pass: ... }
*/
function toSucceedWithoutMessages( callback ) {
let hasThrown = false;
const output = [];
/* eslint-disable no-console */
const originalConsole = console.error;
console.error = ( ...msg ) => Array.prototype.push.apply( output, msg ) && null;
try {
callback.call();
} catch ( error ) {
hasThrown = true;
}
console.error = originalConsole;
/* eslint-enable no-console */
const pass = !hasThrown && output.length === 0;
return {
message: () => {
if ( pass ) {
return `Expected callback "${callback.toString()}" to fail or at least produce some output`;
}
return hasThrown
? `Expected callback "${callback.toString()}" to succeed, `
+ `but it failed with those errors:\n${output.join( "\n" )}\n`
: `Expected callback "${callback.toString()}" to not produce any output`;
},
pass,
};
}
/**
* Helper function which wraps some additional text around the given content
* depending on the given template.
*
* @param {string} content
* Rendered content, e.g. in HTML format
* @param {null|string} description
* Additional information regarding the current test
* @param {null|string} template
* Type of embedding, e.g. "html"; or
* Null if the content shall be returned without changes.
*
* @returns {string}
* Updated content
*
* @this
*/
function applyTemplate( content, description, template ) {
if ( template === "html" ) {
const fullDescription = typeof description === "string" && description !== ""
? `<br/><br/>${description.replace( "<", "<" ).replace( ">", ">" )}\n`
: "";
return toDiffableHtml( "<!DOCTYPE html>\n"
+ "<html style=\"height: 100%;\"><head></head>"
+ "<body style=\"display: flex; flex-flow: column nowrap; "
+ "justify-content: center; align-items: center; height: 100%;\">"
+ `<h2>${this.currentTestName}</h2>\n`
+ "<!--[ Content from unit test: ]-->\n"
+ `${content}\n`
+ "<!--[ End of included content ]-->\n"
+ `${fullDescription}</body></html>` );
}
return content;
}
/**
* Uses "jest-matchers-utils" to compose a diff of the two given strings.
*
* The improve readability, the result may only contain an extract of the actual content.
*
* @param {string} oldContent
* Old version of the text
* @param {string} newContent
* New version of the text
*
* @returns {string}
* Description of (some) differences between the two string, e.g.
* - New content
* + Old content
*
* - ...0/svg" width="95" height="95" style="background-color:yellow">
* + ...0/svg" width="125" height="125" style="background-color:yellow">
*
* @this
*/
function diffStrings( oldContent, newContent ) {
const { utils } = this;
let diffStart = null;
let oldDiff = null;
let newDiff = null;
do {
diffStart = diffStart === null ? 0 : diffStart + 50;
oldDiff = ( diffStart > 0 ? "..." : "" )
+ oldContent.substr( diffStart, 250 )
+ ( oldContent.length > diffStart + 250 ? "..." : "" );
newDiff = ( diffStart > 0 ? "..." : "" )
+ newContent.substr( diffStart, 250 )
+ ( newContent.length > diffStart + 250 ? "..." : "" );
} while (
oldDiff.substr( 0, 50 ) === newDiff.substr( 0, 50 )
&& diffStart + 50 < oldContent.length
&& diffStart + 50 < newContent.length
);
return utils.printDiffOrStringify( newDiff, oldDiff, "New content", "Old content", true );
}
/**
* Reads the given file and ensures that its content matches the given string (if file exists); or
* Writes the given string into a new file with the given name (if file doesn't exist)
*
* Important:
* This test works asynchronously. Remember to always return its result in the test, i.e.
* "return expect( content ).toMatchNamedSnapshot( filename );"
*
* Negation:
* Ensures that the given file exists and that its content DOESN'T match the given string
*
* @param {string|object} contentOrComponent
* Expected content of the snapshot-file; or
* React component whose rendered code shall match the snapshot-file
* @param {object} optionsBag
* @param {string} optionsBag.filename
* Path and name of the snapshot-file
* @param {null|string} optionsBag.description
* Additional information regarding the current test
* which will be included in the snapshot-file
* @param {null|string} optionsBag.template
* Null if new content shall not be preprocessed; or
* "html" if new content shall be wrapped into the body of a new HTML document.
*
* @returns {Promise<object>}
* Resolves with a description of the test result in the form { message: ..., pass: ... }
*
* @this
*/
function toAsyncMatchNamedSnapshot( contentOrComponent, optionsBag ) { // eslint-disable-line max-lines-per-function
const { filename, description, template } = optionsBag;
// eslint-disable-next-line max-statements
return new Promise( resolve => { // eslint-disable-line max-lines-per-function
const { snapshotState, testPath } = this;
let resolved = false;
let fileExisted = true;
// @TODO Find a better way to determine the current update-mode!
// (Current disadvantages: Accessing private property, interactive updates with key "u" don't work.)
const doUpdateAll = snapshotState._updateSnapshot === "all"; // eslint-disable-line no-underscore-dangle
const basename = path.basename( filename )
+ ( path.extname( filename ) === "" ? `.${template || "snap"}` : "" );
const dirname = path.basename( filename ) === filename ? path.dirname( testPath ) : path.dirname( filename );
const subdirname = path.join( dirname, "__snapshots__" );
const fullFilename = path.join( subdirname, basename );
/**
* Helper: Resolve with information about failed test, but does nothing if called again
* @param {Error|string} error Description of problem, e.g. "Can't find directory"
*/
const resolveError = error => {
if ( !resolved ) {
resolve( {
pass: false,
message: () => error.message || error,
} );
resolved = true;
}
};
let fullContent = null;
try {
if ( typeof contentOrComponent === "string" || contentOrComponent == null ) {
fullContent = contentOrComponent || "";
} else if (
contentOrComponent != null
&& typeof contentOrComponent === "object"
&& typeof globalRenderHook === "function"
) {
fullContent = globalRenderHook( contentOrComponent );
if ( typeof fullContent !== "string" && fullContent != null ) {
resolveError( "Failed to automatically render component: "
+ `Unexpected return type ${typeof fullContent}`
+ `(${JSON.stringify( fullContent )})` );
return;
}
} else {
resolveError( "Invalid type of content" );
return;
}
fullContent = applyTemplate.call( this, fullContent, description, template );
} catch ( error ) {
resolveError( error );
return;
}
fs.promises.stat( dirname ).catch( error => {
resolveError( error.code === "ENOENT"
? `FATAL: Can't find directory - check the path of your snapshot-file (${dirname})`
: `FATAL: ${error.message}` );
} )
.then( () => ( resolved
? null
: fs.promises.mkdir( subdirname ).catch( error => {
if ( error.code !== "EEXIST" ) {
resolveError( `FATAL: ${error.code} Failed to prepared snapshot dir (${subdirname})` );
}
} )
) )
.then( () => ( resolved || doUpdateAll
? null
: fs.promises.readFile( fullFilename, "utf8" ).catch( error => {
if ( error.code === "ENOENT" ) {
fileExisted = false;
} else {
resolveError( error );
}
} )
) )
.then( data => ( resolved || ( fileExisted && !doUpdateAll )
? data
: fs.promises.writeFile( fullFilename, fullContent )
.then( () => fs.promises.readFile( fullFilename, "utf8" ) )
) )
.then( data => {
if ( resolved ) {
return;
}
if ( data !== null && data === fullContent ) {
resolve( {
pass: true,
message: () => ( fileExisted
? `ERROR: Old content of file "${basename}" was expected to differ from new content`
: `ERROR: File "${basename}" was expected to already exist with some different content` ),
} );
} else {
const diff = diffStrings.call( this, data, fullContent );
resolve( {
pass: false,
message: () => `ERROR: Old content of file "${basename}" `
+ `was expected to equal the new content:\n${diff}`,
} );
}
} )
.catch( resolveError );
} );
}
module.exports = {
connectRenderer,
// eslint-disable-next-line require-jsdoc
toThrowWithSuppressedOutput( callback ) {
return toThrowWithSuppressedOutput.call( this, callback );
},
// eslint-disable-next-line require-jsdoc
toSucceedWithMessages( callback ) {
return toSucceedWithMessages.call( this, callback );
},
// eslint-disable-next-line require-jsdoc
toSucceedWithoutMessages( callback ) {
return toSucceedWithoutMessages.call( this, callback );
},
// eslint-disable-next-line require-jsdoc
toAsyncMatchNamedSnapshot( content, filename ) {
return toAsyncMatchNamedSnapshot.call( this, content, { filename } );
},
// eslint-disable-next-line require-jsdoc
toAsyncMatchNamedHTMLSnapshot( content, filename, description ) {
return toAsyncMatchNamedSnapshot.call( this, content, { filename, description, template: "html" } );
},
};