Skip to content

Instantly share code, notes, and snippets.

@tschaub
Last active June 24, 2026 19:21
Show Gist options
  • Select an option

  • Save tschaub/a64d7bd5ca377cff2a4dbf63391c3e69 to your computer and use it in GitHub Desktop.

Select an option

Save tschaub/a64d7bd5ca377cff2a4dbf63391c3e69 to your computer and use it in GitHub Desktop.
Replace the use of expect.js with assert from chai.js
/**
* A transform for use with jscodeshift that replaces expect.js assertions with
* assert style assertions from Chai.
*
* Example use on a directory:
*
* git checkout -- test && npx jscodeshift --transform use-chai.js --extensions js test
*
*/
module.exports = function (info, api) {
const j = api.jscodeshift;
const root = j(info.source);
let modified = false;
function assertCall(method, args) {
return j.callExpression(
j.memberExpression(j.identifier('assert'), j.identifier(method)),
args,
);
}
function assertStmt(method, args) {
return j.expressionStatement(assertCall(method, args));
}
function getExpectCall(node) {
while (node && node.type === 'MemberExpression') {
node = node.object;
}
if (
node &&
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'expect'
) {
return node;
}
return null;
}
function getChain(node) {
const chain = [];
let current = node;
while (current && current.type === 'MemberExpression') {
chain.unshift(current.property.name);
current = current.object;
}
return chain;
}
function getActual(node) {
const expectCall = getExpectCall(node);
return expectCall ? expectCall.arguments[0] : null;
}
function transformExpectCallExpression(expr) {
const actual = getActual(expr.callee);
if (!actual) {
if (
expr.callee.type === 'Identifier' &&
expr.callee.name === 'expect' &&
expr.arguments.length === 1
) {
return assertCall('ok', [expr.arguments[0]]);
}
return null;
}
const chain = getChain(expr.callee);
const key = chain.join('.');
const args = expr.arguments;
switch (key) {
case 'to.be':
return args.length === 1
? assertCall('strictEqual', [actual, args[0]])
: null;
case 'to.be.ok':
return assertCall('ok', [actual]);
case 'to.not.be.ok':
case 'not.to.be.ok':
return assertCall('notOk', [actual]);
case 'to.equal':
return assertCall('equal', [actual, args[0]]);
case 'to.eql':
case 'eql':
return assertCall('deepEqual', [actual, args[0]]);
case 'to.be.eql':
return assertCall('deepEqual', [actual, args[0]]);
case 'to.be.an':
case 'to.be.a':
case 'to.an':
case 'to.a':
if (
args[0] &&
(args[0].type === 'Literal' || args[0].type === 'StringLiteral')
) {
const typeChecks = {
array: 'isArray',
object: 'isObject',
string: 'isString',
number: 'isNumber',
function: 'isFunction',
boolean: 'isBoolean',
};
const method = typeChecks[args[0].value];
if (method) {
return assertCall(method, [actual]);
}
}
return assertCall('instanceOf', [actual, args[0]]);
case 'not.to.be.a':
return assertCall('notInstanceOf', [actual, args[0]]);
case 'not.to.be':
return args.length === 1
? assertCall('notEqual', [actual, args[0]])
: null;
case 'to.not.be':
return args.length === 0
? assertCall('isFalse', [actual])
: assertCall('notEqual', [actual, args[0]]);
case 'to.roughlyEqual':
case 'roughlyEqual':
return assertCall('approximately', [actual, args[0], args[1]]);
case 'not.to.roughlyEqual':
return assertCall('notApproximately', [actual, args[0], args[1]]);
case 'to.have.length':
case 'to.length':
case 'have.length':
return assertCall('lengthOf', [actual, args[0]]);
case 'to.be.empty':
return assertCall('isEmpty', [actual]);
case 'to.not.be.empty':
return assertCall('isNotEmpty', [actual]);
case 'to.throwException':
case 'to.throwError':
return assertCall(
'throws',
args.length > 0 ? [actual, args[0]] : [actual],
);
case 'to.not.throwException':
case 'to.not.throwError':
case 'not.to.throwException':
case 'not.to.throwError':
return assertCall(
'doesNotThrow',
args.length > 0 ? [actual, args[0]] : [actual],
);
case 'to.not.equal':
return assertCall('notStrictEqual', [actual, args[0]]);
case 'to.be.greaterThan':
case 'to.greaterThan':
case 'to.be.above':
return assertCall('isAbove', [actual, args[0]]);
case 'to.be.lessThan':
case 'to.be.below':
case 'to.below':
return assertCall('isBelow', [actual, args[0]]);
case 'to.be.within':
return assertCall('isWithin', [actual, args[0], args[1]]);
case 'to.xmleql':
return assertCall(
'xmlEqual',
args.length > 1 ? [actual, args[0], args[1]] : [actual, args[0]],
);
case 'to.not.xmleql':
return assertCall(
'notXmlEqual',
args.length > 1 ? [actual, args[0], args[1]] : [actual, args[0]],
);
case 'to.contain':
case 'contain':
return assertCall('include', [actual, args[0]]);
case 'to.not.contain':
case 'not.to.contain':
case 'not.contain':
return assertCall('notInclude', [actual, args[0]]);
case 'to.not.eql':
case 'not.to.eql':
return assertCall('notDeepEqual', [actual, args[0]]);
case 'to.have.property':
return args.length === 2
? assertCall('propertyVal', [actual, args[0], args[1]])
: assertCall('property', [actual, args[0]]);
case 'to.not.have.property':
return assertCall('notProperty', [actual, args[0]]);
case 'to.have.key':
return assertCall('property', [actual, args[0]]);
case 'to.only.have.key':
return assertCall('hasAllKeys', [actual, j.arrayExpression(args)]);
case 'to.only.have.keys':
return assertCall('hasAllKeys', [actual, args[0]]);
case 'to.match':
return assertCall('match', [actual, args[0]]);
case 'fail':
return assertCall('fail', []);
default:
return null;
}
}
function transformExpectMemberExpression(expr) {
const actual = getActual(expr);
if (!actual) {
return null;
}
const chain = getChain(expr);
const key = chain.join('.');
switch (key) {
case 'to.be.null':
return assertCall('isNull', [actual]);
default:
return null;
}
}
function applyTransform(path, replacement) {
if (!replacement) {
return;
}
if (path.value.type === 'ExpressionStatement') {
j(path).replaceWith(j.expressionStatement(replacement));
} else {
j(path).replaceWith(replacement);
}
modified = true;
}
root.find(j.ExpressionStatement).forEach((path) => {
const expr = path.value.expression;
if (expr.type === 'CallExpression') {
applyTransform(path, transformExpectCallExpression(expr));
} else if (expr.type === 'MemberExpression') {
applyTransform(path, transformExpectMemberExpression(expr));
}
});
root.find(j.ArrowFunctionExpression).forEach((path) => {
if (!path.value.expression) {
return;
}
const body = path.value.body;
if (body.type === 'CallExpression') {
const replacement = transformExpectCallExpression(body);
if (replacement) {
path.value.body = replacement;
modified = true;
}
} else if (body.type === 'MemberExpression') {
const replacement = transformExpectMemberExpression(body);
if (replacement) {
path.value.body = replacement;
modified = true;
}
}
});
root.find(j.ImportDeclaration).forEach((path) => {
const source = path.value.source.value;
if (typeof source === 'string' && source.endsWith('expect.js')) {
j(path).remove();
modified = true;
}
});
const hasChaiImport =
root.find(j.ImportDeclaration, {
source: {value: 'chai'},
}).length > 0;
const usesAssert =
modified ||
hasChaiImport ||
root.find(j.MemberExpression, {
object: {type: 'Identifier', name: 'assert'},
}).length > 0;
if (usesAssert && !hasChaiImport) {
const body = root.find(j.Program).get('body');
body.unshift(
j.importDeclaration(
[j.importSpecifier(j.identifier('assert'))],
j.literal('chai'),
),
);
modified = true;
}
if (!modified) {
return null;
}
return root.toSource({quote: 'single'});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment