dsherret / ts-morph

TypeScript Compiler API wrapper for static analysis and programmatic code changes.
https://ts-morph.com
MIT License
5k stars 195 forks source link

Official testing framework/utilities #686

Open lazarljubenovic opened 5 years ago

lazarljubenovic commented 5 years ago

Is your feature request related to a problem? Please describe.

A lack of clear way to test code that uses ts-morph to manipulate/generate code.

Describe the solution you'd like

Import something from ts-morph/testing or such, set it up with some configuration such as project configuration (ES version to use, etc), and then use its method to easily test code.

These functions would be optimized for quick testing.

Describe alternatives you've considered

I've quickly written a most straightforward test setup I could think of.

Setup
```ts import * as tsm from 'ts-morph' import * as chai from 'chai' import * as tags from 'common-tags' export function assertTransform (sourceFileText: string, doTest: (sourceFile: tsm.SourceFile) => void, expectedResultFileText: string) { const project = new tsm.Project({ useVirtualFileSystem: true }) const file = project.createSourceFile('test.ts', tags.stripIndent(sourceFileText)) doTest(file) const actualResultFileText = file.getText() chai.assert.equal(actualResultFileText, tags.stripIndent(expectedResultFileText)) } ``` `common-tags` is a utility module that I use to strips away indentation from the multiline literals (since my code indentation affects them).

Here's how I use it:

Usage
```ts it(`injects all arguments into arrow function`, () => { assertTransform( ` const add = (x, y) => x + y `, file => { const arrowFunction = file .getVariableDeclarationOrThrow('add') .getInitializerIfKindOrThrow(tsm.SyntaxKind.ArrowFunction) injectArguments(arrowFunction, ['first', 'second']) }, ` const add = (x, y) => (first) + (second) `, ) }) ```

I noticed that using the virtual system greatly improved performance of tests (~80ms to ~30ms), even though I never saved a file. I suspect that a lot of other things could be improved if the library internally knew that I would only create these files to quickly test something. I'm also unsure if I need to do some clean-up to avoid memory leaks, etc. Also, a new project is created on every test, which I'm pretty sure does a lot of useless work.

Of course, I could build a factory where I would re-use the same file and change its text for every unit test, but this is precisely what I'm opening this issue for: it would be nice for the library to provide the way to test common scenarios for unit testing.


I have no idea how other people are using the library, but if a few other people chimed in with their own testing utilities and experiences with testing codemods in general, we might be able come up with a better testing framework by looking at various use-cases that people have.

I understand that this is not an easy task, but I wanted to start the discussion. It was difficult to search through issues the keywords like "test" are overused, so please direct me if a similar one exists already.

JonathanTurnock commented 2 weeks ago

I have a similar need, at the moment I am just testing using a header section at the top of my file

Example

```typescript import { SchemaObject } from "openapi3-ts/oas30"; import { TestTools } from "../__tests__/test-tools"; import { toNumberSchema } from "./toNumberSchema"; // Valid Types type _NumberLiteral = 1; const _NumberLiteralSchema: SchemaObject = { type: "number", enum: [1] }; type _UnionNumberLiteral = 1 | 2; const _UnionNumberLiteralSchema: SchemaObject = { type: "number", enum: [1, 2], }; type _NullableNumber = number | null; const _NullableNumberSchema: SchemaObject = { type: "number", nullable: true }; type _Number = number; const _NumberSchema: SchemaObject = { type: "number" }; enum _NumberEnum { asc = 1, desc = 2, } const _NumberEnumSchema: SchemaObject = { type: "number", enum: [1, 2], }; // Invalid Types type _NullableStringNumber = string | number | null; enum _NumberEnumWithString { asc = 1, desc = 2, one = "one", } describe("toNumberSchema", () => { const testFile = TestTools.forTestFile(__filename).getRoot(); const getAliasType = (name: string) => testFile.getTypeAliasDeclaration(name)?.getType(); const getEnumType = (name: string) => testFile.getEnumDeclaration(name)?.getType(); it.each([ ["_NumberLiteral", getAliasType, _NumberLiteralSchema], ["_UnionNumberLiteral", getAliasType, _UnionNumberLiteralSchema], ["_NullableNumber", getAliasType, _NullableNumberSchema], ["_Number", getAliasType, _NumberSchema], ["_NumberEnum", getEnumType, _NumberEnumSchema], ])("should return schema for %s", (name, fetcher, expected) => { expect(toNumberSchema(fetcher(name))).toEqual(expected); }); it.each([ ["_NullableStringNumber", getAliasType], ["_NumberEnumWithString", getEnumType], ])("should throw an error for %s", (name, fetcher) => { expect(() => toNumberSchema(fetcher(name))).toThrowError(); }); }); ```

Test tools is just a wrapper around the project files to expose some apis for interfacing with test files

TestTools

```typescript import { Node, Project, SourceFile, SyntaxKind } from "ts-morph"; import assert from "node:assert"; export class TestBlock { private readonly block: Node; constructor( private readonly sourceFile: SourceFile, private readonly blockId?: string, ) { if (blockId) { const callExpressions = sourceFile.getDescendantsOfKind( SyntaxKind.CallExpression, ); const callExpressionWithId = callExpressions .find( (it) => it.getArguments()[0]?.getType().getLiteralValue() === blockId, ) ?.getArguments()[1]; assert( callExpressionWithId, `Block '${this.blockId}' not found in source file`, ); this.block = callExpressionWithId; } else { this.block = sourceFile; } } getTypeAliasDeclaration(typeName: string) { const typeAliasDeclaration = this.block .getDescendantsOfKind(SyntaxKind.TypeAliasDeclaration) .find((it) => it.getName() === typeName); assert( typeAliasDeclaration, `TypeAliasDeclaration '${typeName}' not found in block '${this.blockId}'`, ); return typeAliasDeclaration; } getInterfaceDeclaration(interfaceName: string) { const interfaceDeclaration = this.block .getDescendantsOfKind(SyntaxKind.InterfaceDeclaration) .find((it) => it.getName() === interfaceName); assert( interfaceDeclaration, `InterfaceDeclaration '${interfaceName}' not found in block '${this.blockId}'`, ); return interfaceDeclaration; } getEnumDeclaration(enumName: string) { const enumDeclaration = this.block .getDescendantsOfKind(SyntaxKind.EnumDeclaration) .find((it) => it.getName() === enumName); assert( enumDeclaration, `EnumDeclaration '${enumName}' not found in block '${this.blockId}'`, ); return enumDeclaration; } getClassDeclaration(className: string) { const classDeclaration = this.block .getDescendantsOfKind(SyntaxKind.ClassDeclaration) .find((it) => it.getName() === className); assert( classDeclaration, `ClassDeclaration '${className}' not found in block '${this.blockId}'`, ); return classDeclaration; } } export class TestTools { private readonly project: Project; private readonly sourceFile: SourceFile; constructor(private readonly path: string) { this.project = new Project({ compilerOptions: { strictNullChecks: true } }); this.sourceFile = this.project.addSourceFileAtPath(path); } /** * Get a block from the test file * A block is a call expression that has a string literal as the first argument i.e. `describe("blockId", () => {})` * @param blockId The string literal value of the block */ getBlock(blockId?: string) { return new TestBlock(this.sourceFile, blockId); } getRoot() { return new TestBlock(this.sourceFile); } static forTestFile(path: string) { return new TestTools(path); } } ```

But it would be great to at least document some patterns, it was probably the scariest part for me when picking up the library