Update integration tests
Now that we have a way to update acceptance tests, we expect that we can update integration tests in a similar manner. But how much code that we copy-paste will "just work"? Should we extract utility functions to avoid code duplication?
It's hard to answer these questions, because we're still learning how to write codemods. Furthermore, integration tests have 3 subcases (components, helpers, and modifiers) instead of one.
Let's discover the answers by implementing the step, step-by-step.
Goals:
- Learn by repetition
- Prefer duplication over premature abstraction
- Go with simple
Take small steps (again)
We'll create a step called rename-integration-tests. Now that we have some experience, we can take larger small steps.
Scaffold the step
In Chapter 4, we scaffolded (partially implemented) rename-acceptance-tests by taking 4 steps:
- Export an empty function.
- Find files.
- Read and write files (identity function).
- Extract the function
renameModule.
See if you can similarly scaffold rename-integration-tests. Feel free to look back at the chapter.
Solution
- import { createOptions, renameAcceptanceTests } from './steps/index.js';
+ import {
+ createOptions,
+ renameAcceptanceTests,
+ renameIntegrationTests,
+ } from './steps/index.js';
import type { CodemodOptions } from './types/index.js';
export function runCodemod(codemodOptions: CodemodOptions): void {
const options = createOptions(codemodOptions);
renameAcceptanceTests(options);
+ renameIntegrationTests(options);
}export * from './create-options.js';
export * from './rename-acceptance-tests.js';
+ export * from './rename-integration-tests.js';import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { findFiles } from '@codemod-utils/files';
import type { Options } from '../types/index.js';
function renameModule(file: string): string {
return file;
}
export function renameIntegrationTests(options: Options): void {
const { projectRoot } = options;
const filePaths = findFiles('tests/integration/**/*-test.{js,ts}', {
projectRoot,
});
filePaths.forEach((filePath) => {
const oldPath = join(projectRoot, filePath);
const oldFile = readFileSync(oldPath, 'utf8');
const newFile = renameModule(oldFile);
writeFileSync(oldPath, newFile, 'utf8');
});
}Don't forget to check that lint and test pass.
Transform code
In Chapter 5, we used AST Explorer to try out ideas, then moved the implementation to our codemod. We ended up with two functions, getModuleName and renameModule.
Starter code
type Data = {
isTypeScript: boolean;
moduleName: string;
};
function getModuleName(filePath: string): string {
let { dir, name } = parseFilePath(filePath);
dir = relative('tests/acceptance', dir);
name = name.replace(/-test$/, '');
const entityName = join(dir, name).replaceAll(sep, '/');
// a.k.a. friendlyTestDescription
return ['Acceptance', entityName].join(' | ');
}
function renameModule(file: string, data: Data): string {
const traverse = AST.traverse(data.isTypeScript);
const ast = traverse(file, {
visitCallExpression(node) {
if (
node.value.callee.type !== 'Identifier' ||
node.value.callee.name !== 'module'
) {
return false;
}
if (node.value.arguments.length !== 2) {
return false;
}
switch (node.value.arguments[0].type) {
case 'Literal': {
node.value.arguments[0] = AST.builders.literal(data.moduleName);
break;
}
case 'StringLiteral': {
node.value.arguments[0] = AST.builders.stringLiteral(data.moduleName);
break;
}
}
return false;
},
});
return AST.print(ast);
}Try copy-pasting the starter code to rename-integration-tests, then remove references to acceptance tests.
Solution
I highlighted only how getModuleName and renameModule differ between rename-acceptance-tests and rename-integration-tests.
import { readFileSync, writeFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { AST } from '@codemod-utils/ast-javascript';
import { findFiles, parseFilePath } from '@codemod-utils/files';
import type { Options } from '../types/index.js';
type Data = {
isTypeScript: boolean;
moduleName: string;
};
function getModuleName(filePath: string): string {
let { dir, name } = parseFilePath(filePath);
- dir = relative('tests/acceptance', dir);
+ dir = relative('tests/integration', dir);
name = name.replace(/-test$/, '');
const entityName = join(dir, name).replaceAll(sep, '/');
// a.k.a. friendlyTestDescription
- return ['Acceptance', entityName].join(' | ');
+ return ['Integration', entityName].join(' | ');
}
function renameModule(file: string, data: Data): string {
const traverse = AST.traverse(data.isTypeScript);
const ast = traverse(file, {
visitCallExpression(node) {
if (
node.value.callee.type !== 'Identifier' ||
node.value.callee.name !== 'module'
) {
return false;
}
if (node.value.arguments.length !== 2) {
return false;
}
switch (node.value.arguments[0].type) {
case 'Literal': {
node.value.arguments[0] = AST.builders.literal(data.moduleName);
break;
}
case 'StringLiteral': {
node.value.arguments[0] = AST.builders.stringLiteral(data.moduleName);
break;
}
}
return false;
},
});
return AST.print(ast);
}
export function renameIntegrationTests(options: Options): void {
const { projectRoot } = options;
const filePaths = findFiles('tests/integration/**/*-test.{js,ts}', {
projectRoot,
});
filePaths.forEach((filePath) => {
const oldPath = join(projectRoot, filePath);
const oldFile = readFileSync(oldPath, 'utf8');
const data = {
isTypeScript: filePath.endsWith('.ts'),
moduleName: getModuleName(filePath),
};
const newFile = renameModule(oldFile, data);
writeFileSync(oldPath, newFile, 'utf8');
});
}Let's run test to check the names of test modules.
Expected output
❯ pnpm test
failures:
---- index > sample-project message ----
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected ... Lines skipped
{
'.gitkeep': '',
...
"import { module, test } from 'qunit';\n" +
'\n' +
+ "module('Integration | components/navigation-menu', function (hooks) {\n" +
- "module('Integration | Component | <NavigationMenu>', function (hooks) {\n" +
' setupRenderingTest(hooks);\n' +
'\n' +
...
"import { module, test } from 'qunit';\n" +
'\n' +
+ "module('Integration | components/products/product/card', function (hooks) {\n" +
- "module('<Products::Product::Card>', function (hooks) {\n" +
' setupRenderingTest(hooks);\n' +
' setupIntl(hooks);\n' +
...
"import sinon from 'sinon';\n" +
'\n' +
+ "module('Integration | components/products/product/details', function (hooks) {\n" +
- "module('Integration | Component | products | product | details', function (hooks) {\n" +
' setupRenderingTest(hooks);\n' +
' setupIntl(hooks);\n' +
...From the "actual" lines (marked with +), we see that the codemod did update test module names, but the names are incorrect (they are not what Ember CLI would generate).
// Module names don't include the entity type
module('Integration | components/navigation-menu', function (hooks) {});
module('Integration | components/products/product/card', function (hooks) {});
module('Integration | components/products/product/details', function (hooks) {});// Module names include the entity type
module('Integration | Component | navigation-menu', function (hooks) {});
module('Integration | Component | products/product/card', function (hooks) {});
module('Integration | Component | products/product/details', function (hooks) {});We conclude that getModuleName, which worked for acceptance tests, failed to work for integration tests. This illustrates why we want to avoid premature abstractions (e.g. by extracting utility functions early).
The good news is, getModuleName got us quite far with implementing rename-integration-tests. We just need to figure out how to update the function.
Correct overshoots
We say that an iterative method has "overshot," if an iteration (a step) helped us near the solution, but made us go a bit too far. An overshoot occurred when we had copy-pasted getModuleName from rename-acceptance-tests.
function getModuleName(filePath: string): string {
let { dir, name } = parseFilePath(filePath);
dir = relative('tests/integration', dir);
name = name.replace(/-test$/, '');
const entityName = join(dir, name).replaceAll(sep, '/');
// a.k.a. friendlyTestDescription
return ['Integration', entityName].join(' | ');
}Let's correct the overshoot by adding the entity type (here, we represent it as a string such as 'Component', 'Helper', or 'Modifier') before the entity name (e.g. 'navigation-menu', 'products/product/card').
function getModuleName(filePath: string): string {
let { dir, name } = parseFilePath(filePath);
dir = relative('tests/integration', dir);
name = name.replace(/-test$/, '');
const entityType = /* ... */;
const entityName = /* ... */;
// a.k.a. friendlyTestDescription
return ['Integration', entityType, entityName].join(' | ');
}Go with simple
The code for getModuleName shows a variable called dir. This variable represents the test file's relative folder path, e.g. 'components', 'components/products/product', or 'modifiers'.
You might see how we can derive the entity's type and name from dir.
import { join } from 'node:path';
function parseEntity(dir: string) {
// ...
}
const { entityType, remainingPath } = parseEntity(dir);
const entityName = join(remainingPath, name);Let me first show you the solution for parseEntity, then explain the particular approach.
import { sep } from 'node:path';
const folderToEntityType = new Map([
['components', 'Component'],
['helpers', 'Helper'],
['modifiers', 'Modifier'],
]);
function parseEntity(dir: string): {
entityType: string | undefined;
remainingPath: string;
} {
const [folder, ...remainingPaths] = dir.split(sep);
const entityType = folderToEntityType.get(folder!);
return {
entityType,
remainingPath: remainingPaths.join(sep),
};
}I hard-coded the mapping between folders and entity types, despite knowing that Ember CLI pluralizes the entity type to name the folder. I didn't try to create a regular expression or install a package that has methods like singularize and capitalize.
In other words, take the simplest approach to quickly implement a step. Later, once the codemod is finished and has more tests, we can revisit the steps and come up with a better approach.
Solution
The implementations for renameModule and renameIntegrationTests remain unchanged and have been hidden for simplicity.
import { readFileSync, writeFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { AST } from '@codemod-utils/ast-javascript';
import { findFiles, parseFilePath } from '@codemod-utils/files';
import type { Options } from '../types/index.js';
type Data = {
isTypeScript: boolean;
moduleName: string;
};
+ const folderToEntityType = new Map([
+ ['components', 'Component'],
+ ['helpers', 'Helper'],
+ ['modifiers', 'Modifier'],
+ ]);
+
+ function parseEntity(dir: string): {
+ entityType: string | undefined;
+ remainingPath: string;
+ } {
+ const [folder, ...remainingPaths] = dir.split(sep);
+ const entityType = folderToEntityType.get(folder!);
+
+ return {
+ entityType,
+ remainingPath: remainingPaths.join(sep),
+ };
+ }
+
function getModuleName(filePath: string): string {
let { dir, name } = parseFilePath(filePath);
dir = relative('tests/integration', dir);
name = name.replace(/-test$/, '');
- const entityName = join(dir, name).replaceAll(sep, '/');
+ const { entityType, remainingPath } = parseEntity(dir);
+ const entityName = join(remainingPath, name).replaceAll(sep, '/');
// a.k.a. friendlyTestDescription
- return ['Integration', entityName].join(' | ');
+ return ['Integration', entityType, entityName].join(' | ');
}
function renameModule(file: string, data: Data): string {
// ...
}
export function renameIntegrationTests(options: Options): void {
// ...
}Run test once more to check the names of test modules. This time, we should see the names that Ember CLI gives when we generate an integration test.
Expected output
❯ pnpm test
failures:
---- index > sample-project message ----
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected ... Lines skipped
{
'.gitkeep': '',
...
"import { module, test } from 'qunit';\n" +
'\n' +
+ "module('Integration | Component | navigation-menu', function (hooks) {\n" +
- "module('Integration | Component | <NavigationMenu>', function (hooks) {\n" +
' setupRenderingTest(hooks);\n' +
'\n' +
...
"import { module, test } from 'qunit';\n" +
'\n' +
+ "module('Integration | Component | products/product/card', function (hooks) {\n" +
- "module('<Products::Product::Card>', function (hooks) {\n" +
' setupRenderingTest(hooks);\n' +
' setupIntl(hooks);\n' +
...
"import sinon from 'sinon';\n" +
'\n' +
+ "module('Integration | Component | products/product/details', function (hooks) {\n" +
- "module('Integration | Component | products | product | details', function (hooks) {\n" +
' setupRenderingTest(hooks);\n' +
' setupIntl(hooks);\n' +
...Now that we're satisfied, we can run update-test-fixtures.sh to update the output fixture files.
NOTE
You might have noticed that I ignored what happens when entityType is undefined. We will address this edge case in Chapter 8.