Refactor code (Part 2)
In the previous chapter, we wrote a step called rename-tests and extracted a couple of utilities. Before we call the codemod done, we'll write integration and unit tests to document their inputs and outputs.
Goals:
- Write integration tests
- Write unit tests
Write integration tests
Recall from Chapter 2 that tests for the steps live in the tests/steps folder. These tests are analogous to the integration tests that we write for components in Ember. The folder structure for /tests/steps should match that for /src/steps.
For steps like rename-tests, where files are read and updated, we can take one of two approaches:
- Store fixture projects in
/tests/fixtures/steps/<step-name>. - Hard-code the fixture projects as JSONs in tests (preferred if a project is simple).
The fixture projects for integration tests are allowed to be different (even simplified) from those for acceptance tests. For each step, we can also create multiple fixture projects so that the project names clearly indicate what is being tested. A related idea is forming a basis, i.e. finding a minimum set of tests that can check the step thoroughly.
To test rename-tests, we will create 3 projects:
edge-cases(edge cases)javascript(base cases with JavaScript files)typescript(base cases with TypeScript files)
The javascript and typescript projects will cover different entity types, but they won't cover all because we already have a good acceptance test and will write unit tests. To create the 3 projects, please cherry-pick the commit chore: Added fixtures (rename-tests) from my solution repo.
git remote add solution git@github.com:ijlee2/ember-codemod-rename-test-modules.git
git fetch solution
git cherry-pick 88b4995
git remote remove solutionCreate the following test file for the javascript project.
import {
assertFixture,
convertFixtureToJson,
loadFixture,
test,
} from '@codemod-utils/tests';
import { renameTests } from '../../../src/steps/index.js';
import {
codemodOptions,
options,
} from '../../helpers/shared-test-setups/sample-project.js';
test('steps | rename-tests > javascript', function () {
const inputProject = convertFixtureToJson(
'steps/rename-tests/javascript/input',
);
const outputProject = convertFixtureToJson(
'steps/rename-tests/javascript/output',
);
loadFixture(inputProject, codemodOptions);
renameTests(options);
assertFixture(outputProject, codemodOptions);
});A few remarks:
convertFixtureToJsonassumes that fixture projects are located in/tests/fixtures. That's why we provide the relative paths'steps/rename-tests/javascript/input'and'steps/rename-tests/javascript/output'.We see the Arrange-Act-Assert (AAA, "triple-A") testing pattern.
ts// Arrange loadFixture(inputProject, codemodOptions); // Act renameTests(options); // Assert assertFixture(outputProject, codemodOptions);The
codemodOptionsandoptionsare really meant for the project namedsample-project. We reused them for thejavascriptproject out of convenience in this tutorial. For your actual codemod project, please use the right ones so that your tests run under the correct assumptions.
Now your turn. Use the other projects (edge-cases and typescript) to write two more tests.
Solution
import {
assertFixture,
convertFixtureToJson,
loadFixture,
test,
} from '@codemod-utils/tests';
import { renameTests } from '../../../src/steps/index.js';
import {
codemodOptions,
options,
} from '../../helpers/shared-test-setups/sample-project.js';
- test('steps | rename-tests > javascript', function () {
+ test('steps | rename-tests > edge-cases', function () {
const inputProject = convertFixtureToJson(
- 'steps/rename-tests/javascript/input',
+ 'steps/rename-tests/edge-cases/input',
);
const outputProject = convertFixtureToJson(
- 'steps/rename-tests/javascript/output',
+ 'steps/rename-tests/edge-cases/output',
);
loadFixture(inputProject, codemodOptions);
renameTests(options);
assertFixture(outputProject, codemodOptions);
});import {
assertFixture,
convertFixtureToJson,
loadFixture,
test,
} from '@codemod-utils/tests';
import { renameTests } from '../../../src/steps/index.js';
import {
codemodOptions,
options,
} from '../../helpers/shared-test-setups/sample-project.js';
- test('steps | rename-tests > javascript', function () {
+ test('steps | rename-tests > typescript', function () {
const inputProject = convertFixtureToJson(
- 'steps/rename-tests/javascript/input',
+ 'steps/rename-tests/typescript/input',
);
const outputProject = convertFixtureToJson(
- 'steps/rename-tests/javascript/output',
+ 'steps/rename-tests/typescript/output',
);
loadFixture(inputProject, codemodOptions);
renameTests(options);
assertFixture(outputProject, codemodOptions);
});Write unit tests
Recall from Chapter 2 that tests for the utilities live in the /tests/utils folder. These tests are analogous to the unit tests that we write for utilities in Ember. The folder structure for /tests/utils should match that for /src/utils.
We have two utilities: renameModule and parseEntity. I will use renameModule to show how to write unit tests. You can then write tests for parseEntity.
renameModule
Let's check the base case for JavaScript files. Create the following test file for the javascript project.
import { assert, test } from '@codemod-utils/tests';
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | rename-module > javascript', function () {
const oldFile = `module('Old name', function (hooks) {});`;
const newFile = renameModule(oldFile, {
isTypeScript: false,
moduleName: 'New name',
});
assert.strictEqual(newFile, `module('New name', function (hooks) {});`);
});A few remarks:
We see the AAA pattern again.
ts// Arrange const oldFile = `module('Old name', function (hooks) {});`; // Act const newFile = renameModule(oldFile, { isTypeScript: false, moduleName: 'New name', }); // Assert assert.strictEqual(newFile, `module('New name', function (hooks) {});`);The
assertobject that@codemod-utils/testsprovides comes from Node.js.Make strong assertions whenever possible, by using
assert.deepStrictEqual,assert.strictEqual, andassert.throws. Avoid weak assertions likeassert.matchandassert.ok, which create a "room for interpretation" and can make tests pass when they shouldn't (false negatives).Although the implementation for
renameModuleis complex (we had to parse and update abstract syntax trees), the test for it is simple, becauserenameModuleprovided a good interface.The input and output files were simple enough that we could write their content in one line without sacrificing readability. Should they have many lines, create an array of strings and use
normalizeFilefrom@codemod-utils/testsinstead. This way, you can simulate what one would see in an actual file.tsimport { normalizeFile } from '@codemod-utils/tests'; const oldFile = normalizeFile([ `module('Old name', function (hooks) {`, ` module('Old name', function (nestedHooks) {});`, `});`, ``, ]); // ... assert.strictEqual( newFile, normalizeFile([ `module('New name', function (hooks) {`, ` module('Old name', function (nestedHooks) {});`, `});`, ``, ]), );
Use the test file for javascript to write 5 more tests:
typescript.test.ts: Base case for TypeScript files. Checks thatrenameModulecan handle TypeScript files.edge-case-file-is-empty.test.ts: When the file is empty,renameModulereturns the same file content and doesn't run into an error.edge-case-module-does-not-exist.test.ts: When the file doesn't have amodulecall,renameModulereturns the same file content and doesn't run into an error.edge-case-module-has-incorrect-arguments.test.ts: Whenmodulehas incorrect arguments (e.g. the 2nd argument is missing),renameModulereturns the same file content and doesn't run into an error.edge-case-nested-modules.test.ts: When there are nested modules,renameModulerenames only the parent(-most) module.
Solution
import { assert, test } from '@codemod-utils/tests';
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | rename-module > edge case (file is empty)', function () {
const oldFile = '';
const newFile = renameModule(oldFile, {
isTypeScript: true,
moduleName: 'New name',
});
assert.strictEqual(newFile, '');
});import { assert, test } from '@codemod-utils/tests';
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | rename-module > edge case (module does not exist)', function () {
const oldFile = `test('Old name', function (assert) {});`;
const newFile = renameModule(oldFile, {
isTypeScript: true,
moduleName: 'New name',
});
assert.strictEqual(newFile, `test('Old name', function (assert) {});`);
});import { assert, test } from '@codemod-utils/tests';
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | rename-module > edge case (module has incorrect arguments)', function () {
const oldFile = `module('Old name');`;
const newFile = renameModule(oldFile, {
isTypeScript: true,
moduleName: 'New name',
});
assert.strictEqual(newFile, `module('Old name');`);
});import { assert, normalizeFile, test } from '@codemod-utils/tests';
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | rename-module > edge case (nested modules)', function () {
const oldFile = normalizeFile([
`module('Old name', function (hooks) {`,
` module('Old name', function (nestedHooks) {});`,
`});`,
``,
]);
const newFile = renameModule(oldFile, {
isTypeScript: true,
moduleName: 'New name',
});
assert.strictEqual(
newFile,
normalizeFile([
`module('New name', function (hooks) {`,
` module('Old name', function (nestedHooks) {});`,
`});`,
``,
]),
);
});import { assert, test } from '@codemod-utils/tests';
import { renameModule } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | rename-module > typescript', function () {
const oldFile = `module('Old name', function (hooks) {});`;
const newFile = renameModule(oldFile, {
isTypeScript: true,
moduleName: 'New name',
});
assert.strictEqual(newFile, `module('New name', function (hooks) {});`);
});parseEntity
Now, try writing tests for parseEntity. The function returns an object, a data structure that is "complex," so you will want to use assert.deepStrictEqual.
base-case.test.ts: Entity type is knownedge-case-entity-type-is-unknown.test.ts: Entity type is unknown
Solution
import { assert, test } from '@codemod-utils/tests';
import { parseEntity } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | parse-entity > base case', function () {
const folderToEntityType = new Map([
['components', 'Component'],
['helpers', 'Helper'],
['modifiers', 'Modifier'],
]);
const output = parseEntity('components/ui/form', folderToEntityType);
assert.deepStrictEqual(output, {
entityType: 'Component',
remainingPath: 'ui/form',
});
});import { assert, test } from '@codemod-utils/tests';
import { parseEntity } from '../../../../src/utils/rename-tests/index.js';
test('utils | rename-tests | parse-entity > edge case (entity type is unknown)', function () {
const folderToEntityType = new Map([
['adapters', 'Adapter'],
['controllers', 'Controller'],
['initializers', 'Initializer'],
['instance-initializers', 'Instance Initializer'],
['mixins', 'Mixin'],
['models', 'Model'],
['routes', 'Route'],
['serializers', 'Serializer'],
['services', 'Service'],
['utils', 'Utility'],
]);
const output = parseEntity('resources/remote-data', folderToEntityType);
assert.deepStrictEqual(output, {
entityType: undefined,
remainingPath: 'resources/remote-data',
});
});TIP
The tests for parseEntity show realistic values for dir and folderToEntityType. Such values improve documentation.
Avoid values like 'foo', 'bar', and 1, which don't clearly communicate what a function needs to everyone.