Automating Callbacks to Async/Await Migrations for Mocha Tests: Part 2
In the previous article we showed how to automate the migration from callbacks to async/await for Mocha tests using jscodeshift. We started from the jscodeshift-ts-template repo and added the final implementation to the ts-codemods repo.
We know by now that not all code can be automatically migrated. So is there anything else we can do? If you happen to use some known test libraries or frameworks that come with their code structure, you can migrate them too with jscodeshift in most cases. In this article, we’ll have a look at supertest and show how to create a codemod, built on top of the previous one, that migrates supertest code too.
The problem
We want to convert supertest calls inside callback-based Mocha tests to async/await calls. We already built the async-it codemod to deal with Mocha tests, so we can reuse it in the new transformer.
Our files now look like this:
import { describe } from 'mocha';
import { expect } from 'chai';
import { agent } from 'supertest';
describe('my api resource', function () {
it('replies with code 200', function (done) {
agent()
.get('https://myapi.com/resource')
.end(function (err, res) {
if (err) return done(err);
expect(res.statusCode).to.equal(200);
expect(res.body.email).to.equal('hello@myapi.com');
done();
});
});
});
a good conversion of this test file could look like:
import { describe } from 'mocha';
import { expect } from 'chai';
import { agent } from 'supertest';
describe('my api resource', function () {
it('replies with code 200', async function() {
const res = await agent()
.get('https://myapi.com/resource');;
expect(res.statusCode).to.equal(200);
expect(res.body.email).to.equal('hello@myapi.com');
});
});
To perform this migration, we need to:
- Locate all supertest
end
calls inside Mochait
functions and make them async - Remove the typical
if (err) return done(err);
statement found insideend
calls - Extract the
end
call’s callback body to the outer function’s body - Make the
end
call async and assign its result to a variable - Remove the
end
call, while keeping the other parts of the fluent call chain
Building the transformer
Let’s go over the codemod development workflow steps together.
1. Creating a test case
Let’s say this is our input code:
import { describe } from 'mocha';
import { expect } from 'chai';
import { agent } from 'supertest';
describe('my api resource', function () {
it('replies with code 200', function (done) {
agent()
.get('https://myapi.com/resource')
.end(function (err, res) {
if (err) return done(err);
expect(res.statusCode).to.equal(200);
expect(res.body.email).to.equal('hello@myapi.com');
done();
});
});
});
this is what we’d expect as output:
import { describe } from 'mocha';
import { expect } from 'chai';
import { agent } from 'supertest';
describe('my api resource', function () {
it('replies with code 200', async function() {
const res = await agent()
.get('https://myapi.com/resource');;
expect(res.statusCode).to.equal(200);
expect(res.body.email).to.equal('hello@myapi.com');
});
});
In real-life scenarios we might also want to test whether the codemod produces code with conflicting variables declarations. It can happen when having multiple supertest calls inside the same it
call.
Be careful when using codemods, and always check the transformed code.
2. Exploring the AST
Let’s go to AST explorer and have a look at the AST of our test case’s input code.
These are the nodes we’re looking for (assuming we have already identified it
functions):
- All CallExpressions whose callee is an MemberExpression and its property name is
end
. We might also check that the object of the expression is a CallExpression to be a little more confident we are in a fluent call chain. - From here it’s trivial to get the argument, a FunctionExpression, and its respective parameters.
- In the body of this function, we look for IfStatements whose condition is an Identifier having the same name as the
err
parameter. We do this so the codemod works even when that argument is not calledend
.
3. Implementing the codemod
We’ll add the new transformer in the ts-codemods repository, so we can import the transformer we built in the previous article.
Let’s go over it step by step:
-
Calling the
async-it
transformer to migrate Mocha tests code to async/await:export default function asyncSupertestTransformer(file: FileInfo, api: API) { const j = api.jscodeshift; const root = j(file.source); const options: AsyncItTransformerOptions = { describeFuncName: 'describe', itFuncName: 'it', skipFuncName: 'skip', doneFuncName: 'done', rmDoneFunc: true, rmDoneFuncMode: 'rm' }; const endFuncName = 'end'; // make 'it' func args async and remove all 'done' func calls const itFuncCalls = asyncItAstTransformer(j, root, options); }
the transformer returns the collection of
it
function calls nodes in the current file. We’ll start looking for our nodes inside the children of that collection. -
Finding all supertest
end
calls:itFuncCalls .find(j.CallExpression, { callee: { type: 'MemberExpression', property: { name: endFuncName }, object: { type: 'CallExpression' } }, })
we also want to check the arguments of these calls, but not with the jscodeshift
find
method. We’ll do it in theforEach
that processes our nodes because when replacing AST nodes inside a loop, sometimes a new loop iteration might be triggered with elements that need to be filtered out.Let’s add our loop with this type guard to prevent the issue:
// ... .forEach(call => { // this is the type guard if (call?.node?.arguments?.length !== 1 || (call.node.arguments[0]?.type !== 'FunctionExpression' && call.node.arguments[0].type !== 'ArrowFunctionExpression') || call.node.arguments[0].async || call.node.arguments[0].params?.length !== 2 || (call.node.arguments[0] as FunctionExpression | ArrowFunctionExpression) .params.some(x => x?.type !== 'Identifier')) return; }
-
Removing the return statement in the
end
callback:// ... const errArgName = (endFuncArg.params[0] as Identifier).name; const resArgName = (endFuncArg.params[1] as Identifier).name; j(endFuncArg.body) .find(j.IfStatement, { test: { type: 'Identifier', name: errArgName } }) .remove();
note that we also parse the callback parameters’ names here to make sure we remove relevant code.
-
Extracting the body of the
end
callback into the body of the outer function (supposedly theit
function’s callback):// ... for (const statement of endFuncArg.body.body) { itFuncArgBody.push(statement); }
-
Replacing the old
end
call with the new async version:// ... const baseCall = (call.node.callee as MemberExpression).object as CallExpression; const assignAsyncResult = j.variableDeclaration('const', [ j.variableDeclarator( j.identifier(resArgName), j.awaitExpression(baseCall) ) ]); j(call).replaceWith(assignAsyncResult);
to preserve the call chain, we replace the
end
call with a subset of the whole call chain. -
Writing the output code, with a final check to prevent writing when there are no changes to the input AST.
return itFuncCalls.size() > 0 ? root.toSource({ lineTerminator: '\n', quote: 'single' }) : null;
since we’re calling the
async-it
transformer, we need to check if it detected any callback-basedit
calls.
4. Running tests
Now we’re ready to run our tests with Jest:
$ npm test
> ts-codemods@1.0.0 test
> jest
PASS __tests__/async-supertest.test.ts
async-supertest transformer
./src/async-supertest
√ transforms correctly using "async-supertest/function" data (138 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.225 s, estimated 9 s
Ran all test suites.
Everything looks good, so our workflow ends here. Of course, in a real-world scenario, you might want to add more than one test to be sure your codemod does its job without breaking the source code.
Conclusion
We showed how to implement our transformer with jscodeshift. It can help automate more code when migrating away from callback-based Mocha tests.
You can find the full implementation and a ready-to-use script in the ts-codemods repository. If you have any questions/suggestions/ideas, please feel free to open an issue there!