What's the best approach to test ts-morph AST transformations?
What's the best approach to test ts-morph AST transformations?
- Writing specific assertions by querying nodes?
- Or using snapshot testing to compare the generated source?
Definitely do snapshot testing of before code and after code.
I've been doing this for many years, and you're just not going to be able to cover enough scenarios by checking individual AST nodes. Also, it will be difficult to fully check if the transform did something to the code that you didn't expect by just inspecting a few select AST nodes.
Hope this helps!
Interesting topic.
The thing about snapshot testing is that you first need to run the tests to generate the snapshots.
Then, you have to manually review the snapshots to verify if they're actually correct.
So while it’s convenient that the snapshots are auto-generated, you still need to validate them and fix them if the output isn’t what you expect — and sometimes it's not easy to spot the issue right away.
In contrast, if you write a query and assert specific conditions in your test — like checking that a certain node doesn’t exist — it tends to be more reliable.
For example, if I expect an import to be removed as a result of a transformation, asserting length === 0 is a much more explicit and clear signal that the transformation worked.
Also, looking at the existing test files in the project, it seems like most tests rely on querying AST nodes rather than using snapshot tests.
I'm still debating this myself, but those are my thoughts.
Ah, sorry, I should have been more clear in my advice above.
- Do not use Jest snapshot testing, or any other similar automatic snapshot mechanism for these tests
- Do test by creating a snapshot of the "before" code, and expecting a snapshot of the "transformed" code.
Here is an example test that outlines this concept:
import dedent from 'dedent';
import { expect } from 'chai';
import { Project } from 'ts-morph';
import { renameButton } from '../src/rename-button';
it(`should rename a Button import from 'some-lib' to NewButton`, () => {
const project = new Project();
const sourceFile = project.createSourceFile('test.tsx', dedent`
import { Button } from 'some-lib';
import { OtherComponent } from 'other-lib';
export const MyComponent = () => {
return <Button>Click me</Button>;
};
`);
// transform
renameButton(sourceFile);
expect(sourceFile.getText()).to.equal(dedent`
import { NewButton } from 'some-lib';
import { OtherComponent } from 'other-lib';
export const MyComponent = () => {
return <NewButton>Click me</NewButton>;
};
`);
});
Testing against the entire "before" and "after" code is the strongest guarantee that your AST transform is correct, and that it doesn't affect things that you don't want it to. It also makes it very clear for humans reading the code to see exactly what is transformed, whereas it can be a little difficult to mentally "piece together" expectations on the AST itself (not to mention, you might miss checking some of the AST nodes).
As an example of a case where we'd want to check that other things aren't affected, we might write a test to check that our transform doesn't affect a variable that shadows the import in MyComponent2 below:
import dedent from 'dedent';
import { expect } from 'chai';
import { Project } from 'ts-morph';
import { renameButton } from '../src/rename-button';
it(`should rename a Button import to NewButton, but not affect shadowing vars`, () => {
const project = new Project();
const sourceFile = project.createSourceFile('test.tsx', dedent`
import { Button } from 'some-lib';
import { OtherComponent } from 'other-lib';
export const MyComponent1 = () => {
return <Button>Click me</Button>;
};
export const MyComponent2 = () => {
const Button = () => <span>Shadowing Button Component</span>;
return <Button>Click me</Button>;
};
`);
// transform
renameButton(sourceFile);
expect(sourceFile.getText()).to.equal(dedent`
import { NewButton } from 'some-lib';
import { OtherComponent } from 'other-lib';
export const MyComponent1 = () => {
return <NewButton>Click me</NewButton>;
};
export const MyComponent2 = () => {
const Button = () => <span>Shadowing Button Component</span>;
return <Button>Click me</Button>;
};
`);
});
And just in case your interested, here is my quick AST transform that makes these tests pass (although it's somewhat incomplete - doesn't currently handle named import aliases):
renameButton() function
import { Node, SourceFile } from 'ts-morph';
/**
* Renames all imports of `Button` from 'some-lib' to `NewButton`.
*/
export function renameButton(sourceFile: SourceFile) {
// Check if the file contains an import declaration for Button from 'some-lib'
const importDecls = sourceFile.getImportDeclarations();
const buttonImportDecl = importDecls.find(importDeclaration =>
importDeclaration.getModuleSpecifierValue() === 'some-lib' &&
importDeclaration.getNamedImports().some(namedImport => namedImport.getName() === 'Button')
);
// If found, rename it to NewButton
if (buttonImportDecl) {
const buttonImportSpecifier = buttonImportDecl.getNamedImports()
.find(namedImport => namedImport.getName() === 'Button');
if (buttonImportSpecifier) {
const buttonIdentifier = buttonImportSpecifier.getNameNode();
if (Node.isIdentifier(buttonIdentifier)) {
const references = buttonIdentifier.findReferencesAsNodes();
references.forEach((reference) => {
reference.replaceWithText('NewButton');
});
}
}
}
}