Skip to content

Commit

Permalink
Expect spy support
Browse files Browse the repository at this point in the history
  • Loading branch information
skovhus committed Apr 24, 2017
1 parent ff7c383 commit 1aee7eb
Show file tree
Hide file tree
Showing 6 changed files with 405 additions and 120 deletions.
305 changes: 221 additions & 84 deletions src/transformers/expect.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { JEST_MATCHER_TO_MAX_ARGS } from '../utils/consts';
import { JEST_MATCHER_TO_MAX_ARGS, JEST_MOCK_PROPERTIES } from '../utils/consts';
import detectQuoteStyle from '../utils/quote-style';
import { getRequireOrImportName, removeRequireAndImport } from '../utils/imports';
import updateJestImports from '../utils/jest-imports';
import {
getRequireOrImportName,
addRequireOrImportOnceFactory,
removeRequireAndImport,
} from '../utils/imports';
findParentCallExpression,
findParentVariableDeclaration,
} from '../utils/recast-helpers';
import logger from '../utils/logger';
import proxyquireTransformer from '../utils/proxyquire';

Expand Down Expand Up @@ -48,26 +49,27 @@ const matchersWithKeys = new Set([
'toNotIncludeKeys',
]);

const spyFunctions = new Set(['spyOn']);

const expectPackageName = 'expect';
const expectSpyFunctions = new Set(['createSpy', 'spyOn', 'isSpy', 'restoreSpies']);
const unsupportedSpyFunctions = new Set(['isSpy', 'restoreSpies']);

export default function expectTransformer(fileInfo, api, options) {
const j = api.jscodeshift;
const ast = j(fileInfo.source);
const { standaloneMode } = options;

const expectFunctionName = getRequireOrImportName(j, ast, expectPackageName);
const expectFunctionName = getRequireOrImportName(j, ast, 'expect');

if (!expectFunctionName) {
// No expect require/import were found
return fileInfo.source;
}

if (!standaloneMode) {
removeRequireAndImport(j, ast, expectPackageName);
removeRequireAndImport(j, ast, 'expect');
}

const logWarning = (msg, node) => logger(fileInfo, msg, node);

function balanceMatcherNodeArguments(matcherNode, matcher, path) {
const newJestMatcherName = matcher.name.replace('not.', '');
const maxArgs = JEST_MATCHER_TO_MAX_ARGS[newJestMatcherName];
Expand All @@ -89,105 +91,240 @@ export default function expectTransformer(fileInfo, api, options) {
return;
}

logger(
fileInfo,
logWarning(
`Too many arguments given to "${newJestMatcherName}". Expected max ${maxArgs} but got ${matcherNode.arguments.length}`,
path
);
}

ast
.find(j.MemberExpression, {
object: {
type: 'CallExpression',
callee: { type: 'Identifier', name: expectFunctionName },
},
property: { type: 'Identifier' },
})
.forEach(path => {
if (path.parentPath.parentPath.node.type === 'MemberExpression') {
logger(
fileInfo,
'Chaining except matchers is currently not supported',
const updateMatchers = () =>
ast
.find(j.MemberExpression, {
object: {
type: 'CallExpression',
callee: { type: 'Identifier', name: expectFunctionName },
},
property: { type: 'Identifier' },
})
.forEach(path => {
if (path.parentPath.parentPath.node.type === 'MemberExpression') {
logWarning(
'Chaining except matchers is currently not supported',
path
);
return;
}

if (!standaloneMode) {
path.parentPath.node.callee.object.callee.name = 'expect';
}

const matcherNode = path.parentPath.node;
const matcher = path.node.property;
const matcherName = matcher.name;

const matcherArgs = matcherNode.arguments;
const expectArgs = path.node.object.arguments;

const isNot =
matcherName.indexOf('Not') !== -1 ||
matcherName.indexOf('Exclude') !== -1;

if (matcherRenaming[matcherName]) {
matcher.name = matcherRenaming[matcherName];
}

if (matchersToBe.has(matcherName)) {
if (matcherArgs[0].type === 'Literal') {
expectArgs[0] = j.unaryExpression('typeof', expectArgs[0]);
matcher.name = isNot ? 'not.toBe' : 'toBe';
}
}

if (matchersWithKey.has(matcherName)) {
expectArgs[0] = j.template.expression`Object.keys(${expectArgs[0]})`;
matcher.name = isNot ? 'not.toContain' : 'toContain';
}

if (matchersWithKeys.has(matcherName)) {
const keys = matcherArgs[0];
matcherArgs[0] = j.identifier('e');
matcher.name = isNot ? 'not.toContain' : 'toContain';
j(path.parentPath).replaceWith(
j.template.expression`\
${keys}.forEach(e => {
${matcherNode}
})`
);
}

if (matcherName === 'toMatch' || matcherName === 'toNotMatch') {
const arg = matcherArgs[0];
if (arg.type === 'ObjectExpression') {
matcher.name = isNot ? 'not.toMatchObject' : 'toMatchObject';
}
}

balanceMatcherNodeArguments(matcherNode, matcher, path);
});

const updateSpies = () => {
ast
.find(j.CallExpression, {
callee: {
type: 'Identifier',
name: name => expectSpyFunctions.has(name),
},
})
.forEach(path => {
logWarning(
`"${path.value.callee.name}" is currently not supported ` +
`(use "expect.${path.value.callee.name}" instead for transformation to work)`,
path
);
});

// Update expect.createSpy calls and warn about restoreSpies
ast
.find(j.MemberExpression, {
object: {
type: 'Identifier',
name: expectFunctionName,
},
property: { type: 'Identifier' },
})
.forEach(path => {
const { name } = path.value.property;
if (name === 'createSpy') {
path.value.property.name = 'fn';
}

if (unsupportedSpyFunctions.has(name)) {
logWarning(
`"${path.value.property.name}" is currently not supported`,
path
);
}
});

// Update mock chain calls
const updateSpyProperty = (path, property) => {
if (!property) {
return;
}

if (!standaloneMode) {
path.parentPath.node.callee.object.callee.name = 'expect';
if (property.name === 'andReturn') {
const callExpression = findParentCallExpression(path, property.name)
.value;
callExpression.arguments = [
j.arrowFunctionExpression(
[j.identifier('()')],
callExpression.arguments[0]
),
];
}

const matcherNode = path.parentPath.node;
const matcher = path.node.property;
const matcherName = matcher.name;
if (property.name === 'andThrow') {
const callExpression = findParentCallExpression(path, property.name)
.value;
const throughExpression = callExpression.arguments[0];
callExpression.arguments = [
j.arrowFunctionExpression(
[j.identifier('()')],
j.blockStatement([j.throwStatement(throughExpression)])
),
];
}

const matcherArgs = matcherNode.arguments;
const expectArgs = path.node.object.arguments;
if (property.name === 'andCallThrough') {
logWarning(`"${property.name}" is currently not supported`, path);
}

const isNot =
matcherName.indexOf('Not') !== -1 ||
matcherName.indexOf('Exclude') !== -1;
const propertyNameMap = {
andCall: 'mockImplementation',
andReturn: 'mockImplementation',
andThrow: 'mockImplementation',
calls: 'mock.calls',
reset: 'mockClear',
restore: 'mockReset',
};

if (matcherRenaming[matcherName]) {
matcher.name = matcherRenaming[matcherName];
const newPropertyName = propertyNameMap[property.name];
if (newPropertyName) {
property.name = newPropertyName;
}

if (matchersToBe.has(matcherName)) {
if (matcherArgs[0].type === 'Literal') {
expectArgs[0] = j.unaryExpression('typeof', expectArgs[0]);
matcher.name = isNot ? 'not.toBe' : 'toBe';
}
}
// Remap mock.calls[x].arguments[y] to mock.calls[x][y]
const potentialArgumentsNode = path.parentPath.parentPath.value;
if (
property.name === 'mock.calls' &&
potentialArgumentsNode.property &&
potentialArgumentsNode.property.name === 'arguments'
) {
const outherNode = path.parentPath.parentPath.parentPath;

if (matchersWithKey.has(matcherName)) {
expectArgs[0] = j.template.expression`Object.keys(${expectArgs[0]})`;
matcher.name = isNot ? 'not.toContain' : 'toContain';
}
const variableName = path.value.object.name;
const callsArg = path.parentPath.value.property.name;
const argumentsArg = outherNode.value.property.name;

if (matchersWithKeys.has(matcherName)) {
const keys = matcherArgs[0];
matcherArgs[0] = j.identifier('e');
matcher.name = isNot ? 'not.toContain' : 'toContain';
j(path.parentPath).replaceWith(
j.template.expression`\
${keys}.forEach(e => {
${matcherNode}
})`
outherNode.replace(
j.memberExpression(
j.memberExpression(
j.memberExpression(
j.identifier(variableName),
j.identifier('mock.calls')
),
j.identifier(callsArg),
true
),
j.identifier(argumentsArg),
true
)
);
}
};

if (matcherName === 'toMatch' || matcherName === 'toNotMatch') {
const arg = matcherArgs[0];
if (arg.type === 'ObjectExpression') {
matcher.name = isNot ? 'not.toMatchObject' : 'toMatchObject';
const spyVariables = [];
ast
.find(j.MemberExpression, {
object: {
type: 'Identifier',
name: expectFunctionName,
},
property: {
type: 'Identifier',
name: name => JEST_MOCK_PROPERTIES.has(name),
},
})
.forEach(path => {
const spyVariable = findParentVariableDeclaration(path);
if (spyVariable) {
spyVariables.push(spyVariable.value.id.name);
}
}

balanceMatcherNodeArguments(matcherNode, matcher, path);
});

const addRequireOrImportOnce = addRequireOrImportOnceFactory(j, ast);

ast
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: { type: 'Identifier', name: expectFunctionName },
property: { name: p => spyFunctions.has(p) },
},
})
.forEach(path => {
const { callee } = path.node;
if (standaloneMode) {
const mockLocalName = 'mock';
addRequireOrImportOnce(mockLocalName, 'jest-mock');
callee.object = mockLocalName;
} else {
callee.object = 'jest';
}
});
const { property } = path.parentPath.parentPath.value;

updateSpyProperty(path, property);
});

// Update spy variable methods
ast
.find(j.MemberExpression, {
object: {
type: 'Identifier',
name: name => spyVariables.indexOf(name) >= 0,
},
property: { type: 'Identifier' },
})
.forEach(path => {
const { property } = path.value;
updateSpyProperty(path, property);
});
};

updateMatchers();
updateSpies();
updateJestImports(j, ast, standaloneMode, expectFunctionName);
proxyquireTransformer(fileInfo, j, ast);

const quote = detectQuoteStyle(j, ast) || 'single';
Expand Down
Loading

0 comments on commit 1aee7eb

Please sign in to comment.