"Hey Siri", Can you write unit tests for me?

ยท

5 min read

As developers, we strive to fine-tune our development workflow as much as possible so that we can churn out code quickly and efficiently. Me being a lazy sloth, I always try to automate the heck out of everything (even though it takes lesser time to do it manually ๐Ÿ˜‰). Recently when working in angular I noticed that I had been writing a lot of boilerplate code in my unit tests. It was time-consuming and painful to stub dependencies for the components and services, so I decided to automate this.

The schematics in Angular CLI scaffolds empty unit tests but I expected the tool to do much more. I wanted to create a tool that generates unit tests, where it

  • Analyzes the code and creates the necessary stubs.
  • Configures the TestBed.
  • Generates basic tests for methods in the component or service.

I decided to create a Visual Studio Code extension as it would be more comfortable to use when integrated with an IDE. Without further ado, let's get started.

unit test generator.gif

To create a scaffolding tool we would need to follow the below steps

  1. Create the necessary templates
  2. Analyse the code to extract necessary details
  3. Apply the extracted details in the template

Create the necessary templates

Generally, every scaffolding tool starts with a pre-defined template and it fills in necessary data in the pre-defined template according to the information available in the current context. For example Angular CLI's component schematics or React's create-react-app use existing templates to scaffold code. Similar to these scaffolding tools, we will start with static pre-defined templates for each item type (like component, directive, pipe, and service) and populate these templates with dynamically extracted information at runtime.

Since we are working with templates it would be easier to use an available template engine, so that we do not need to do all the heavy lifting of compiling and interpolating the templates ๐Ÿ™‚.

Template engine is a program that combines templates with data to produce result documents. There are many template engines available in Javascript. The most popular ones include EJS, Handlebars , Pug , Mustache , etc.

We will use the EJS template engine, as it is easy to set up and uses plain Javascript. Below is the EJS template for generating unit tests for an Angular component. It resembles a normal spec file where the dynamic bits are embedded within scriptlet tags <% %>.

/* component.ejs */
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { of, Subject } from 'rxjs';
<% imports.forEach(importItem => { %>
import { <%-importItem.import%> } from '<%-importItem.path%>';<%});%>
import { <%-name%> } from './<%-fileName%>';
<% stub.forEach(item => { %>
const <%-item%>;
<%});%>

describe('<%= name %>', () => {
  let component: <%= name %>;
  let fixture: ComponentFixture<<%= name %>>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ <%= name %> ],
      schemas: [NO_ERRORS_SCHEMA],
      providers: [
      <% providers.forEach(provider => { %><%-provider%>,
      <%});%>]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(<%= name %>);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

<% Object.keys(methods).forEach(methodName => {var methodDetails = methods[methodName];%>
  describe('<%-methodName%>', () => {
    it('makes expected calls', () => {<% var paramNames = []; methodDetails.params.forEach(param => { paramNames.push(param.name);%>
      const <%-param.value%>;<%});%><% Object.keys(methodDetails.spies).forEach(spyProperty => { %>
      const injected<%-spyProperty%>: <%-spyProperty%> = TestBed.get(<%-spyProperty%>);<% methodDetails.spies[spyProperty].forEach(({spyFn}) => { %>
      spyOn(injected<%-spyProperty%>, '<%-spyFn%>');
<%});%><%});%>
      component.<%-methodName%>(<%-paramNames.join(", ")%>);
<% Object.keys(methodDetails.spies).forEach(spyProperty => { %><% methodDetails.spies[spyProperty].forEach(({spyFn}) => { %>
      expect(injected<%-spyProperty%>.<%-spyFn%>).toHaveBeenCalled();<%});%><%});%>
    });
  });
<%});%>
});

Analyse the code to extract necessary details

Now that we have a template and a template engine to process the template, we can extract dynamic data and populate the template with the necessary details. To extract information from the source code, we will parse the file and convert the code into an Abstract syntax tree (AST).

AST is a hierarchical tree-like structure that is used to represent the syntax of a source code. The AST can be easily traversed and manipulated, so it is easier to extract information from each part of the source code such as class, method, variable, comment, etc.

For a simplified view of how a compiler creates an AST check this article.

Since we are using Angular, we can use the Typescript compiler to parse the source and convert it to AST. But instead, we will use another library called Ts-morph. Ts-morph is a wrapper around the TypeScript Compiler API. It provides an easier way to programmatically navigate and manipulate TypeScript and JavaScript code.

Below is a sample code to parse a file and traverse each node in the generate AST using ts-morph.

import { Project, SourceFile, SyntaxKind } from "ts-morph";

const filePath = 'path/to/your/file.ts';
const project = new Project({
  tsConfigFilePath: "path/to/tsconfig.json",
  addFilesFromTsConfig: false
});

project.addSourceFileAtPath(filePath);

const src: SourceFile = project.getSourceFileOrThrow(filePath);
src.forEachDescendant((node) => {
  switch (node.getKind()) {
    case SyntaxKind.VariableDeclaration:
      console.log('This is a variable node');
      break;
    case SyntaxKind.ClassDeclaration:
      console.log('This is a class node');
      break;
    case SyntaxKind.FunctionDeclaration:
      console.log('This is a function node');
      break;
    case SyntaxKind.InterfaceDeclaration:
      console.log('This is a interface node');
      break;
    }
  return node;
});

Apply the extracted details in the template

Ts-morph makes it simple to set up and parse the source code. Once we have parsed our source code, we can traverse the generated AST and extract details from each part of the source (such as variable, function call, argument, etc). Now that we have the AST extracted data, we can use it to render the EJS template. We can use the render function from ejs and pass the template with the extracted data.

import * as fs from "fs-extra";
import { render } from "ejs";
import { extractSourceData, getSpecFilePath, toCamelCase } from "../helpers";

const extractedSrcData = extractSourceData();

export async function generateSpec(extractedSrcData: KeyValue) {
  const template = await fs.readFile(extractedSrcData.templatePath, "utf8");
  const generatedSpec = render(template, { ...extractedSrcData, helpers: { toCamelCase }});
  fs.writeFileSync(getSpecFilePath(extractedSrcData.filePath), generatedSpec);
}

We can now create a visual studio code extension to use our library so that we can generate unit tests without leaving the IDE. The source code is available in ng-generate-tests and ng-generate-tests-vs-code-extn repositories.

Hope you enjoyed reading this. Ciao for now :)

ย