Schematics: Building Blocks 
Tags: javascript, angular
Published: 2019-08-16 19:19:36

This is part 2 of Schematics: Building blocks. Make sure to check [part one](https://dev.to/thisdotmedia/schematics-building-blocks-2mg3) if you haven't. We will continue with our previous work.

## Chaining schematics
I'll use component generation, using the Angular CLI, as an example.
If you've used it before, you'll know that, when running the `ng g c my-component`, a number of operations will happen.

![ng g component terminal output](https://thepracticaldev.s3.amazonaws.com/i/nqcpij1kt1i8z8k40zkf.png)

We can see that two things are happening. First, a group of files is created, and then the module where it's located is updated.

These two operations could be split in two schematics.
- Create files from templates
- Update Module

Let's create a new schematic.
```bash
schematics blank component
```

We'll compose this schematic from two other schematics. Remember that a single file can contain more than a single factory function, and only the schematics added to `collection.json` will be available.

```ts
import { Rule, SchematicContext, Tree, chain } from '@angular-devkit/schematics';

export function component(options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    return chain([
      createFiles(options),
      updateModule(options)
    ])(tree, context);
  };
}

export function createFiles(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Will create files from templates');
    // create files implementation
    return tree;
}
}

export function updateModule(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    _context.logger.info('Will update module');
    // update module implementation
    return tree;
  };
}
```

I'm skipping some implementation details as we want to focus in the main function (`component`). The `chain` method imported from schematics will allow us to concatenate schematics. They will run in sequence one after the other.

If we build and run our schematic now (`schematics .:component`), we'll see the messages logged in the desired order.

## noop
You may want to skip certain steps of this chain, based on some user input. You can easily add this functionality by importing the noop method also provided by the `schematics` package.

```ts
export function component(options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    return chain([
      createFiles(options),
      options.skipModule ? noop() : updateModule(options)
    ])(tree, context);
  };
}
```
This way, you can chain multiple schematics, and pick the ones you need to run.


## Importing schematics
You might be tempted to import, and extend other schematics of your collection the same way we chained our functions in the previous example.
Let's create a new schematic to see it in action.
```bash
schematics blank extended-schematic
```

```ts
import { Rule, SchematicContext, Tree, chain, schematic } from '@angular-devkit/schematics';
import { createFromTemplate } from '../create-from-template';

export function extendedSchematic(options: any): Rule {

  return (tree: Tree, context: SchematicContext) => {
    return chain([
      createFromTemplate(options),
        extend()
    ])(tree, context)
  };
}

export function extend(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Extending schematic');
    return tree;
  };
}
```
If we build it, and test, but forget to add the folder argument, it will fail.
If you remember from our previous examples, a schematic might have a schema that defines a set of requirements, and adds extra information on fields, and how to request for that data(prompts). By importing that function, you'll be missing all of these settings. The appropriate way of importing an internal schematic is using the `schematic` method.

```ts
import { Rule, SchematicContext, Tree, chain, schematic } from '@angular-devkit/schematics';

export function extendedSchematic(options: any): Rule {

  return (tree: Tree, context: SchematicContext) => {
    return chain([
      schematic('create-from-template', {
      ...options
    }),
    extend()
  ])(tree, context)
  };
}

export function extend(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Extending schematic');
    return tree;
  };
}
```

Now, if we run our schematic, you'll be prompted (if set) from the required arguments of the schematics that have been extended. Validation and parsing will also work as expected.

## Extending external schematics
Extending our own schematics is a nice feature, but we might also need to extend schematics that do not belong to our collection. We know from our previous example that it would not be possible to add the collection and import the schematic that we would want to extend.
To solve this problem, we are required to use a similar function to the `schematic` function used before. This function is `externalSchematic`. Let's see it in action.

```bash
schematics blank extend-external-schematic
```

```ts
import {
  Rule,
  SchematicContext,
  Tree,
  chain,
  externalSchematic
} from "@angular-devkit/schematics";

export function external(options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    return chain([
      externalSchematic("@schematics/angular", "component", {... options}),
      extend()
    ])(tree, context);
  };
}

export function extend(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info("Extending schematic");
    return tree;
  };
}
```

We need to pass at least three parameters to the external schematic function: the name of the package that we will be using, the schematic name to run, and options.
If we build and run the schematic, we will get an error, because the package (@schematics/angular) is not installed, and because the collection is created to run inside an Angular project.


## Tasks
When running our schematics, we may need to perform other operations without modifying our tree. For example, we may want to install our dependencies or run our linter. The `@angular-devkit/schematics` package comes with some of these tasks.
Let's create a new schematic.

```bash
schematic blank tasks
```

```ts
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'

export function tasks(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.addTask(new NodePackageInstallTask({ packageName: '@schematics/angular' }));
    return tree;
  };
}
```

We are adding a new task to our context (`NodePackageInstallTask`) that will effectively run the `install` command of our preferred package manager.
If a task depends on another task to be completed, `addTask` accepts an array of dependencies (other tasks ids) as a second argument.

```ts
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { NodePackageInstallTask, TslintFixTask } from '@angular-devkit/schematics/tasks'

export function tasks(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const taskId = context.addTask(new NodePackageInstallTask({ packageName: '@schematics/angular' }));
    context.addTask(new TslintFixTask({}), [taskId])
    return tree;
  };
}
```

In this example, `TsLintFixTask` will not run until 
`NodePackageInstallTask` has finished because it's listed as a dependency.

> To run `tasks` schematic using globally installed `schematics-cli` make sure you have `tslint` and `typescript` installed globally otherwise `TsLintFixTask` will fail.

## Tests
So far, we've accomplished a lot of different operations in the file system, and we've extended our schematics, and external schematics. However, we're missing an important part of our schematics collection to be ready. Testing. How do we test schematics?
Let's start with the first of our schematics, `create-file` and the auto-generated test file.

```ts
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('create-file', () => {
  it('works', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('create-file', {}, Tree.empty());

    expect(tree.files).toEqual([]);
  });
});
```

We created a test runner, and gave it the path to our collection schema. Then we ran our schematic on a given tree. In this example, an empty tree.
If we run this test as it is - it will fail.

![Failed test](https://thepracticaldev.s3.amazonaws.com/i/ii6g5kwd7uyqjh3ar9us.png)

Remember, we added a required `path` argument in our schema when we created it. Now that we now that the test fails, let's write a test that checks if it fails, and also another one for when it succeeds.

```ts
// create-file/index.spec.ts
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('create-file', () => {
  it('Should throw if path argument is missing', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let errorMessage;
    try {
      runner.runSchematic('create-file', {}, Tree.empty());
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/required property 'path'/);
  });

  it('Should create a file in the given path', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('create-file', { path: 'my-file.ts' }, Tree.empty());
    expect(tree.files).toEqual(['/my-file.ts']);
  });
});
```

Test all possible errors. When modifying a file, test its contents.

```ts
// ts-ast/index.spec.ts
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('ts-ast', () => {
  it('Should throw if path argument is missing', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let errorMessage;
    try {
      runner.runSchematic('ts-ast', {}, Tree.empty());
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/required property 'path'/);
  });

  it("Should throw if file in the given path does not exist", () => {
    const runner = new SchematicTestRunner("schematics", collectionPath);
    let errorMessage;
    try {
      runner.runSchematic("ts-ast", { path: "my-file.ts" }, Tree.empty());
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/File my-file.ts not found/);
  });

  it("Should throw if no interface is present", () => {
    const runner = new SchematicTestRunner("schematics", collectionPath);
    const sourceTree = Tree.empty();
    sourceTree.create('test.ts', 
      `export class MyClass { }`
    );
    let errorMessage;
    try {
      runner.runSchematic('ts-ast', { path: 'test.ts' }, sourceTree);
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/No Interface found/);
  });

  it('Should update a file in the given path', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const sourceTree = Tree.empty();
    sourceTree.create('test.ts', 
      `export interface MyInterface {
        name: string;
      }`
    );
    const tree = runner.runSchematic('ts-ast', { path: 'test.ts' }, sourceTree);
    expect(tree.files).toEqual(['/test.ts']);
    expect(tree.readContent('/test.ts')).toEqual(
      `export interface MyInterface {
        first: string;
        name: string;
        last: string;
      }`
     );
  });
});
```

You can find all the tests in [the repository](https://github.com/flakolefluk/schematics-tutorial)

## Schematics and the Angular CLI
So far, we've used schematics without the Angular CLI. Schematics can have any name, but there are a few ones that have a special meaning when used with the `ng` command.
For example, running `ng add <package_name>` will download the package, will check for a collection reference in the `schematics` key inside `package.json`, and will run the `ng-add` schematic of that collection.

Let's create a new schematic.
```bash
schematics blank ng-add
```

This is the first time we will have to think about how our schematic will have to interact with an angular workspace. We must take into account what's required to run it.
In this example, we'll make a simple modification to the workspace `README.md` file

Let's take a look at the implementation.

```ts
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';

export function ngAdd(_options:any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree.overwrite('README.md', 'overwritten file');
  };
}
```

This looks very simple, but when testing it, we think that this should run inside an angular workspace. This is a simple example, but when modifying projects, this will become more evident.
We could create this new angular workspace manually, but there's a better approach. We'll use the `@schematics/angular` package to create a workspace, just like the Angular CLI does.
Let's install the package first.

```bash
npm install --save-dev @schematics/angular
```

```ts
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';
import { Tree } from '@angular-devkit/schematics';

const collectionPath = path.join(__dirname, '../collection.json');

describe('ng-add', () => {
  const workspaceOptions = {
    name: 'workspace',
    newProjectRoot: 'projects',
    version: '8.0.0',
  };

  const runner = new SchematicTestRunner('schematics', collectionPath);

  it('should throw if no readme is not found', async () => {
    let errorMessage;
    try{
      runner.runSchematic('ng-add', { }, Tree.empty());
    } catch(e){
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/Path "\/README.md" does not exist./);
    
  });

  it('overwrite workspace README file', async () => {
    const sourceTree = await runner.runExternalSchematicAsync('@schematics/angular','workspace', workspaceOptions).toPromise();
    const tree = runner.runSchematic('ng-add', {}, sourceTree);
    expect(tree.files).toContain('/README.md');
    expect(tree.readContent('/README.md')).toMatch(/overwritten file/);
  });
});
```

The second test is running an external schematic for the installed package to create a workspace. Then, we run our `ng-add` schematic to modify the tree that contains an angular workspace. There are more things that you can do with the `@schematics/angular` package to prepare your tree to test, like creating new projects or components. It's a great way to mimic a real project.
Our previous schematics were very generic, if we wanted to run them inside of an angular project, we would have to recreate the environment where we expect them to be used when testing.


## Final words
* You can find the code [here](https://github.com/flakolefluk/schematics-tutorial)
* Split your schematics into simpler ones if possible. You may need to reuse them somewhere else and they can always be chained.
* Always test your schematics and recreate the environment where they will run the best if you can. If they will run on an angular workspace, create it. If there are other schematics available to do that task, use them. That's one of the features of schematics: to avoid repetitive tasks.
* Always use the `schematic` and `externalShematic` functions when importing them from somewhere else. 
* In part 3, we will create a schematic to add TailwindCSS to an Angular project.

## References
* [Angular Schematics](https://github.com/angular/angular-cli/tree/master/packages/schematics/angular)
* [CDK Schematics](https://github.com/angular/components/tree/master/src/cdk)
* [ngrx Schematics](https://github.com/ngrx/platform/tree/master/modules/schematics)


## Related blog posts
* https://blog.angular.io/angular-schematics-unit-testing-3a0a9aaab186

*This article was written by Ignacio Falk who is a software engineer at [This Dot](https://thisdot.co).*   

*You can follow him on Twitter at @flakolefluk.*

*Need JavaScript consulting, mentoring, or training help? Check out our list of services at [This Dot Labs](https://thisdot.co).*