Migrating a large Angular project from Jasmine to Jest incrementally
Migrating a large codebase to a new framework can be a daunting task. When we first thought about moving our Angular project (~1000 Typescript files, most of them with tests) from Karma/Jasmine to Jest, we felt no different.
Motivation
We needed a way to implement the migration incrementally rather than in one huge chunk. This meant that we would have to have the old Jasmine and the new Jest tests run side by side for the length of the transition. What’s more, we had ESLint set up for all our spec files, and we would need to split those configurations apart for Jest-based spec files and Jasmine-based spec files so each could use their specific linter plugin.
The challenge was to be able to make all these migrations in bite-sized chunks (in our case we decided to do it module by module) with minimal administrative overhead for every step.
Running Karma/Jasmine and Jest side by side: The general idea
Of course the exact details of what needs to be changed can vary depending on project setup, but in our case (and probably for most standard Angular CLI apps) covering the following bases turned out to be the way to go:
- Separate tsconfig files for Jest and Jasmine spec files respectively, mainly to provide each with their appropriate typings
- A single source of truth regarding which parts (in our case: modules) of the app should use Jest and which ones should use Karma/Jasmine as their designated test runner/framework
- A way to ignore all Jest-based specs in the Karma/Jasmine run
- A way to ignore all Karma/Jasmine-based specs in the Jest run
- A strategy to apply the actual changes necessary to convert Jasmine tests into Jest tests
- Separate ESLint configs for Jest-based specs and Karma/Jasmine-based specs (pulling the information as to which config to use for which files from the specification made in point 2.)
What it looks like in code
We started by duplicating the tsconfig.spec.json
file, naming its copy tsconfig.jest.json
. Initially, there were only two minor differences in the types and files arrays, respectively. Starting out, both files looked like this:
// tsconfig.spec.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"baseUrl": "",
"strict": false,
"types": ["jasmine", "jasminewd2", "node"]
},
"files": ["test.ts", "polyfills.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"],
"exclude": []
}// tsconfig.jest.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"baseUrl": "",
"strict": false,
"types": ["jest", "node"]
},
"files": ["polyfills.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"]
}
Having split the Typescript configurations for Karma and Jest apart, we would now begin to use the exclude
array in tsconfig.spec.json
to list all folders within our app which have already been migrated to Jest. This would exclude them from Karma execution, but we would also need to take care of the other problems mentioned in the list above, mainly (1) to tell Jest to only execute tests in these folders and (2) to tell ESLint to apply different rules depending on whether or not a spec file is inside one of these folders.
The reason we used this file as the source of truth is that it is a plain JSON file and we cannot include this information from anywhere else here, but all other configuration files we needed to touch are indeed JS or TS files and we can execute code there to include this piece of information from the tsconfig.spec.json
.
Once we excluded some folders for Karma execution this way, we still noticed Karma not completely ignoring these folders. To fix that, we made sure the test.ts file skipped all tests inside these directories:
// test.ts
// …
const tsconfigSpec = require('./tsconfig.spec.json');
// …
/* Replace the line "context.keys().map(context);" with the following: */
context
.keys()
.filter((file) => {
return tsconfigSpec.exclude
.map((excludeGlobPattern) => {
return excludeGlobPattern.substr(0, excludeGlobPattern.indexOf('**'));
})
.every((excludedSpecPrefix) => {
return !file.startsWith(excludedSpecPrefix);
});
})
.map(context);
Similarly, we configured Jest to use tsconfig.jest.json
as its Typescript configuration file and to only execute tests inside the directories listed under exclude inside tsconfig.spec.json
:
// jest.config.js
const tsconfigSpec = require('./src/tsconfig.spec.json');
const testMatchList = tsconfigSpec.exclude.map((exclusionPattern) => {
return `<rootDir>/src${exclusionPattern.substr(1)}`;
});module.exports = {
// …
testMatch: testMatchList,
globals: {
'ts-jest': {
tsconfig: '<rootDir>/src/tsconfig.jest.json',
},
},
// …
};
Finally, we needed to take care of linting. In the eslintrc.js
file, the following adjustments were made:
// eslintrc.js
const tsconfigSpec = require('./src/tsconfig.spec.json');
const jestFilePatterns = tsconfigSpec.exclude
.filter((excludePattern) => {
return excludePattern.startsWith('./app/');
})
.map((excludePattern) => {
return `src${excludePattern.substr(1)}`;
});module.exports = {
// …
overrides: [
{
files: ['src/**/*.spec.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './src/tsconfig.spec.json',
ecmaVersion: 2020,
sourceType: 'module',
},
},
// Config for new jest tests
{
files: jestFilePatterns,
parser: '@typescript-eslint/parser',
parserOptions: {
project: './src/tsconfig.jest.json',
ecmaVersion: 2020,
sourceType: 'module',
},
extends: ['plugin:jest/recommended'],
plugins: ['jest'],
env: {
jest: true,
},
},
]
};
And that’s basically the whole setup needed to run Jest and Karma alongside each other. But how do you make the actual transition, you ask?
Migrating your tests module by module
Migrating the tests in a folder/module from Karma/Jasmine to Jest consisted of the following steps:
- Add the glob pattern for spec files of that module to the exclude array inside
tsconfig.spec.json
, for example./app/my-module/**/*.spec.ts
- Commit your changes, because the next step requires a clean repository.
- Run
npx jest-codemods /path/to/your/module
. This command will ask you 4 question, which for a standard Angular project you should answer with “Typescript”, “Jasmine: globals”, “Yes, and I’m not afraid of false positive transformations” and “Yes, use the globals provided by Jest”. This will apply some basic transformations to your spec files, replacing some of the Jasmine-specific code with its Jest counterpart. - Run
jest
and fix all errors that still occur. - Run
eslint
and fix all errors that still occur.
The transformations that jest-codemods
does for you are a good starting point, but there are more code replacements that could be automated. We ended up running the following module-wide replacements in between steps 3 and 4, you could obviously write a script that combines jest-codemods
with these replacements:
- Replace
.and.returnValue
with.mockReturnValue
- Replace
.and.callFake
with.mockImplementation
- Replace
.calls.reset
with.mockClear
- Replace
.toBeTrue()
with.toBe(true)
- Replace
.toBeFalse()
with.toBe(false)
Of course it’s hard to give general instruction on how to fix the problems in steps 4 and 5, but a good programming instinct should allow you to work through it all. You will be rewarded with another completed chunk of your Jest migration!
And that’s it! Once you’re done transforming all your modules this way, you can remove all the Karma/Jasmine-related pieces of configuration again, clean up your tsconfig files (removing one of them), throwing away the test.ts
entirely, as well as all the stuff linking it up inside angular.json
.
Good luck and happy testing!