Skip to content

Latest commit

 

History

History
485 lines (335 loc) · 14.9 KB

09-refactor-code-part-2.md

File metadata and controls

485 lines (335 loc) · 14.9 KB

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 [email protected]:ijlee2/ember-codemod-rename-test-modules.git
git fetch solution
git cherry-pick 88b4995
git remote remove solution

Create the file tests/steps/rename-tests/javascript.test.ts, then copy-paste the following starter code.

Starter code
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:

  • convertFixtureToJson() assumes 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.

    // Arrange
    loadFixture(inputProject, codemodOptions);
    
    // Act
    renameTests(options);
    
    // Assert
    assertFixture(outputProject, codemodOptions);
  • The codemodOptions and options are really meant for the project named sample-project. We reused them for the javascript project 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.

It's now your turn. Use the other 2 projects to write two more tests.

Solution: tests/steps/rename-tests/edge-cases.test.ts
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);
});
Solution: tests/steps/rename-tests/typescript.test.ts
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.

In the previous chapter, we extracted 2 utilities: renameModule() and parseEntity(). To show you how to write unit tests, I will walk you through renameModule(), then let you write tests for parseEntity().

renameModule()

Let's check the base case for a JavaScript file. Create the file tests/utils/rename-tests/rename-module/javascript.test.ts, then copy-paste the following starter code.

Starter code
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) {});\n`;

  const newFile = renameModule(oldFile, {
    isTypeScript: false,
    moduleName: 'New name',
  });

  assert.strictEqual(newFile, `module('New name', function (hooks) {});\n`);
});

A few remarks:

  • We see the AAA pattern again.

    // Arrange
    const oldFile = `module('Old name', function (hooks) {});\n`;
    
    // Act
    const newFile = renameModule(oldFile, {
      isTypeScript: false,
      moduleName: 'New name',
    });
    
    // Assert
    assert.strictEqual(newFile, `module('New name', function (hooks) {});\n`);
  • The assert object that @codemod-utils/tests provides comes from Node.js.

    Make strong assertions whenever possible, using methods such as assert.deepStrictEqual(), assert.strictEqual(), and assert.throws(). Weak assertions like assert.match() and assert.ok(), which create a "room for interpretation" and can make tests pass when they shouldn't (false negatives), should be avoided.

  • Although the implementation for renameModule() is complex (we had to parse and update abstract syntax trees), the test for it is simple, because renameModule() provided 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 the join() method instead. This way, you can simulate what one would see in an actual file.

    const oldFile = [
      `module('Old name', function (hooks) {`,
      `  module('Old name', function (nestedHooks) {});`,
      `});`,
      ``,
    ].join('\n');
    
    // ...
    
    assert.strictEqual(
      newFile,
      [
        `module('New name', function (hooks) {`,
        `  module('Old name', function (nestedHooks) {});`,
        `});`,
        ``,
      ].join('\n'),
    );

Use the starter code to write 5 more tests:

  • A base case for a TypeScript file
  • An edge case, where the file is empty
  • An edge case, where module() does not exist
  • An edge case, where module() has incorrect arguments (e.g. the 2nd argument is missing)
  • An edge case with nested modules
Solution: tests/utils/rename-tests/rename-module/typescript.test.ts

This test checks that renameModule() can handle TypeScript files.

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) {});\n`;

  const newFile = renameModule(oldFile, {
    isTypeScript: true,
    moduleName: 'New name',
  });

  assert.strictEqual(newFile, `module('New name', function (hooks) {});\n`);
});
Solution: tests/utils/rename-tests/rename-module/edge-case-file-is-empty.test.ts

This test checks that, when the file is empty, renameModule() returns the same file content and doesn't run into an error.

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, '');
});
Solution: tests/utils/rename-tests/rename-module/edge-case-module-does-not-exist.test.ts

This test checks that, when the file doesn't have a module() call, renameModule() returns the same file content and doesn't run into an error.

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) {});\n`;

  const newFile = renameModule(oldFile, {
    isTypeScript: true,
    moduleName: 'New name',
  });

  assert.strictEqual(newFile, `test('Old name', function (assert) {});\n`);
});
Solution: tests/utils/rename-tests/rename-module/edge-case-module-has-incorrect-arguments.test.ts

This test checks that, when the module() call is incorrect, renameModule() returns the same file content and doesn't run into an error.

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');\n`;

  const newFile = renameModule(oldFile, {
    isTypeScript: true,
    moduleName: 'New name',
  });

  assert.strictEqual(newFile, `module('Old name');\n`);
});
Solution: tests/utils/rename-tests/rename-module/edge-case-nested-modules.test.ts

This test checks that renameModule() renames only the parent module.

import { assert, 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 = [
    `module('Old name', function (hooks) {`,
    `  module('Old name', function (nestedHooks) {});`,
    `});`,
    ``,
  ].join('\n');

  const newFile = renameModule(oldFile, {
    isTypeScript: true,
    moduleName: 'New name',
  });

  assert.strictEqual(
    newFile,
    [
      `module('New name', function (hooks) {`,
      `  module('Old name', function (nestedHooks) {});`,
      `});`,
      ``,
    ].join('\n'),
  );
});

parseEntity()

Now, see if you can write unit tests for parseEntity(). The function returns an object, a data structure that is "complex," so you will want to use assert.deepStrictEqual() to make an assertion.

  • A base case where the entity type is known
  • An edge case where the entity type is unknown
Solution: tests/utils/rename-tests/parse-entity/base-case.test.ts
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',
  });
});
Solution: tests/utils/rename-tests/parse-entity/edge-case-entity-type-is-unknown.test.ts
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',
  });
});

Note that dir and folderToEntityType, the test setup for parseEntity(), show "realistic" values to help with documentation. Avoid values like 'foo', 'bar', and 1, which don't clearly communicate to all contributors what the function needs.