Testing code transformation with Jest .toMatchInlineSnapshot()


Context

I have been working on a VSCode extension to simplify chores and improve refactoring experience when working with NestJS projects. The VSCode extension includes code transformations.

For example, injecting a dependency in a constructor. The normal workflow would be:

  1. Create a constructor if not exists
  2. Add a constructor parameter private readonly x: X
  3. Add an import statement for class X

While we can accomplish such transformation with string manipulation techniques, there are edge cases and possibly error prone.

AST is a much much better data represetation to work with transformations. With an AST transformation library like ts-morph, my transformation function can be simple.

A transform function

Below is a transform function that inject classToInject into constructorToBeInjected.

export async function injectDependency(
  constructorToBeInjected: ConstructorDeclaration,
  classToInject: ClassDeclaration
) {
  const { camelCase } = await import("change-case");

  const className = classToInject.getName();
  const classSourceFile = classToInject.getSourceFile();
  const constructorSourceFile = constructorToBeInjected.getSourceFile();

  // Add an import statement
  constructorSourceFile.addImportDeclaration({
    namedImports: [{ name: className }],
    moduleSpecifier: constructorSourceFile.getRelativePathAsModuleSpecifierTo(
      classSourceFile.getFilePath()
    ),
  });

  // Add a parameter to constructor
  constructorToBeInjected.addParameter({
    scope: Scope.Private,
    isReadonly: true,
    name: camelCase(className),
    type: className,
  });
}

Testing code transformation

To test:

  1. Setup a Project.
  2. Add source files.
  3. Call the transformation function.
  4. Expect the result. (This is a tricky one)

When I write the expectation with toEqual, there are a lot of unknowns in regards to the formatting of the transformed source code.

For example,

  • Is there going to be an empty line between import statement and the class definition?
  • Is the inserted dependency going to be wrapped to a new line?
  • etc.

The source code formatting is not my concern as it’ll be handled by formatters. My concerns are:

  • There must be an import statement of class B.
  • There must be a constructor parameter with private scope and readonly modifier of name b with type B.

Here is the test function so that we know what we’re working with. 😄

test("Inject a dependency into an empty constructor", () => {
  // Arrange
  const project = new Project({ useInMemoryFileSystem: true });
  const aSourceFile = project.createSourceFile(
    "a.ts",
    `
class A {
    constructor() {}
}
  `
  );
  const bSourceFile = project.createSourceFile(
    "b.ts",
    `
export class B {}
  `
  );
  const constructorToBeInjected = aSourceFile
    .getClass("A")!
    .getConstructors()[0];
  const classToInject = bSourceFile.getClass("B")!;

  // Act
  injectDependency(constructorToBeInjected, classToInject);
  project.saveSync();

  // Assert
  const aSourceFileTransformed = project.getSourceFile("a.ts");
  expect(aSourceFileTransformed!.getText()).toEqual(`
import { B } from "./b";
class A {
    constructor(private readonly b: B) {}
}
`);
});

.toMatchInlineSnapshot() to the rescue 🛟

From Jest’s Documentation

Inline snapshots behave identically to external snapshots (.snap files), except the snapshot values are written automatically back into the source code. This means you can get the benefits of automatically generated snapshots without having to switch to an external file to make sure the correct value was written.

I can write an expectation with empty argument to .toMatchInlineSnapshot().

expect(aSourceFileTransformed!.getText()).toMatchInlineSnapshot();

Then, I run the test. The test file will be updated with an argument to the .toMatchInlineSnapshot() function using the value from the expect() function argument.

expect(aSourceFileTransformed!.getText()).toMatchInlineSnapshot(`
"import { B } from "./b";

class A {
    constructor(private readonly b: B) {}
}
  "
`);

After the test completes, I can review if the snapshot is correct. If it’s correct, hooray! job done. Otherwise, fix the transform function until I got a correct transformed source code.

Remove the argument of .toMatchInlineSnapshot(). And run the test one last time to save the snapshot.

Conclusion

Given that the output of the transform function is small, it’s better to have it inlined in the test file. One can read the test file and understand what the transform function does.

This is somewhat similar to babel-plugin-tester package but different in the scope of the transformation. While babel transform an AST, the transform function transform an AST with the knowledge of multiple ASTs and their source files.

Thus, the test has more setup code in the “Arrange” stage of the test. That’s it for this blog. It has been fun writing the extension.

See you next time!. 👋