Skip to content

push-based/node-cli-testing

Repository files navigation

node-cli-testing

A e2e-testing library for node CLI and processes including sandbox generation on the fly

npm

Motivation

Testing node code is easy as long as you can stick to unit tests (testing input and output of a single function). When it comes to more complex scenarios it get's pretty hard. Node processes, file outputs, integration tests, and console output is a hassle and involves many moving gears that are hard to setup an keep stable.

This is what this library tackles. A smooth out of the box experience for testing node CLIs and processes with unit, integration and e2e tests.

Features

  • 🚥 Testing node process output (stdout, stderr, exitCode)
  • 🧠 Handle .rc.jons files
  • ⌨️ Simulate keyboard interaction
  • 💬 Test console output
  • 🥸 Initializing a sandbox environment for each test
  • ⚙️ Automatically creating files needed for the test
  • 🧹 Cleanup after tests
  • 🦮 Helpers to check the generated files and folders of a node process

Install

You can install the node-cli-testing over npm or yarn as following:

npm install --save @push-based/node-cli-testing
# or
yarn add @push-based/node-cli-testing

Setup

The node-cli-testing lib can be imported as following:

import { CliProject } from '@push-based/node-cli-testing/cli-project';

let projectSandbox: CliProject;

const cfg: ProjectConfig = {
  root: './',
  bin: 'cli.js'
};

describe('The CLI configuration in default mode', () => {
  beforeEach(async () => {
    projectSandbox = await CliProjectFactory.create(cfg);
  });
  afterEach(async () => {
    projectSandbox = await CliProjectFactory.teardown(cfg);
  });

  it('should work', async () => {
    const { exitCode, stdout, stderr } = await projectSandbox.exec();
  });

});

Basic Usage of the CliProject wrapper

Setup a basic project and tests

The CliProject class makes it easy to execute a node file and handle it's in and outputs as well as process arguments. Let's set up a simple test for a CLI:

import { CliProject, CliProjectFactory } from '@push-based/node-cli-testing/cli-project';

let projectSandbox: CliProject;

const cfg: ProjectConfig = {
  root: './', // the directory in which the test should take place
  bin: 'cli.js' // the bin file the gets executed as node process
};

describe('The CLI', () => {
  beforeEach(async () => {
    projectSandbox = await CliProjectFactory.create(cfg);
  });
  afterEach(async () => {
    projectSandbox = await CliProjectFactory.teardown(cfg);
  });

  it('should work', async () => {
    const { exitCode, stdout, stderr } = await projectSandbox.exec();
    expect(stdin).toContain('some console output');
    expect(stderr).toBe('');
    expect(exitCode).toBe(0);
  });

});

Use setup helper

As is it kind of repetitive to set up beforeEach and afterEach this library provides a helper. This comes in handy when there are many different setups in one describe block.

The helper consumes a configuration for the project and handles setup and teardown internally.

import { ProjectConfig, withProject } from '@push-based/node-cli-testing/cli-project';

const cfg: ProjectConfig = {
  root: './',
  bin: 'cli.js' 
};
const cfg2: ProjectConfig = {
  root: './other/',
  bin: 'cli.js' 
};

describe('The CLI', () => {

  it('should work in version 1', withProject(cfg, async (prj) => {
    const { exitCode } = await prj.exec();
    expect(exitCode).toBe(0);
   })
  );
  
  it('should work in version 2', withProject(cfg2, async (prj) => {
    const { exitCode } = await prj.exec();
    expect(exitCode).toBe(0);
   })
  );

});

Use process arguments

Node processes can retrieve arguments over process.argv to be configurable. In the next snippet we pass arguments to a node process:

import { CliProject } from '@push-based/node-cli-testing/cli-project';

// Set up here.
// Details see the above example for setup a basic CLI project and test it

describe('The CLI', () => {
  beforeEach(/* same as in above */);
  afterEach(/* same as in above */);

  it('should work with params', async () => {
    // We can pass proces params as simple object ans it transporms it to standard process param style
    const { exitCode, stdout, stderr } = await projectSandbox.exec({verbose: true, count: 42, names: ['Srashti', 'Eliran', 'Mike']});
    
    expect(stdin).toContain('verbose mode is active');
    expect(stdin).toContain('count is 42');
    expect(stdin).toContain('names are Srashti, Eliran, Mike');
    
  });

});

The example above takes an object to define the process args like this:

{ verbose: false, count: 42, names: ['Srashti', 'Eliran', 'Mike'] }

Internally it converts them to the following string and passes it to the defined process:

--no-verbose --count=42 --name=Srashti --name=Eliran --name=Mike 

Test keyboard interaction

Often processes running in the console prompt to users and ask for some input. The lib exports some helper constants to interact simulate keyboard interaction.

import { CliProject, DOWN, SPACE, ENTER, DECLINE_BOOLEAN } from '@push-based/node-cli-testing/cli-project';

describe('The CLI', () => {
  beforeEach(/* ... */);
  afterEach(/* ... */);

  it('should work with params', async () => {
    const { stdout } = await projectSandbox.exec({prompt: true}, [DOWN, SPACE, ENTER, DECLINE_BOOLEAN]);
    
    expect(stdin).toContain('You selected the first option and hit enter');
    expect(stdin).toContain('You declined the option to generate a new file');
  });

});

Test file interaction

import { CliProject, CliProjectFactory } from '@push-based/node-cli-testing/cli-project';

let projectSandbox: CliProject;

const cfg: ProjectConfig = {
  root: './',
  bin: 'cli.js',
  create: {
    'fileA.txt': 'Content of file A'
  },
  delete: ['fileA.txt', 'fileB.txt']
};

describe('The CLI in a folder structure', () => {
  beforeEach(async () => {
    projectSandbox = await CliProjectFactory.create(cfg);
    // files from cfg.create are created here
    await projectSandbox.setup();
  });
  afterEach(async () => {
    // files from cfg.delete are deleted here    
    await projectSandbox.teardown();
  });

  it('should copy a file', async () => {
    const { exitCode, stdout, stderr } = await projectSandbox.exec({source: 'fileA.txt', dest: 'fileB.txt'});
    expect(stdout).toContain('file copied');
    
    const fileAContent = fs.readFileSync('fileA.txt', 'utf8').toString();
    const fileBContent = fs.readFileSync('fileB.txt', 'utf8').toString();
    
    expect(fileAContent).toBe(fileBContent);
  });

});

The CliProject will create all defined files from cfg.create when CliProject#setup is called. The CliProject will delete all defined filesNames from cfg.delete when CliProject#teardown is called.

Working with a .rc.json file

Often CLIs can be configured over a .rc.json file containing some default configuration. Let's create a test for this scenario:

import { CliProject, CliProjectFactory } from '@push-based/node-cli-testing/cli-project';

type MyRcJson = {
  count: number
};

let projectSandbox: CliProject<MyRcJson>;

const cfg: ProjectConfig<MyRcJson> = {
  root: './',
  bin: 'cli.js',
  rcFile: {
    '.rc.json': {
        count: 42
    }
  }
};

describe('The CLI configured over the .rc file', () => {
  beforeEach(async () => {
    projectSandbox = await CliProjectFactory.create(cfg);
    await projectSandbox.setup();
  });
  afterEach(async () => { 
    await projectSandbox.teardown();
  });

  it('should have count of 42', async () => {
    const { stdout } = await projectSandbox.exec();
    expect(stdout).toContain('count is 42');
  });

});

The CliProject will create the .rc.json file when CliProject#setup is called. The CliProject will delete the .rc.json file when CliProject#setup is called.

You can also create multiple rc files at once:

import { CliProject, CliProjectFactory } from '@push-based/node-cli-testing/cli-project';

const cfg: ProjectConfig = {
  root: './',
  bin: 'cli.js',
  rcFile: {
    '.rc.json': {
        count: 42
    }
  }
};

Advanced Usage

Creating custom CLI wrapper

Let's extend the CliProject class to add custom logic to handle a command:

import { CliProject, CliProjectFactory, TestResult } from '@push-based/node-cli-testing/cli-project';


type MyRcJson = {
  count: number
};

export class MyCliProject extends CliProject<MyRcJson> {
  
  constructor() {
    super();
  }

  $myCommand(processParams?: Partial<{count: number}>, userInput?: string[]): Promise<TestResult> {
    const prcParams: ProcessParams = { _: 'my-command', ...processParams } as ProcessParams;
    return this.exec(prcParams, userInput);
  }

}

Next, for better DX let's create a custom factory:

export class MyCliProjectFactory {
  static async create(cfg: ProjectConfig<MyRcJson>): Promise<MyCliProject<MyRcJson>> {
    const prj = new MyCliProject();
    await prj._setup(cfg);
    return prj;
  }
}

Now we can use it in our tests like this:

let projectSandbox: MyCliProject;

const cfg: ProjectConfig<MyRcJson> = {
  root: './',
  bin: 'cli.js'
};

describe('The CLI configuration in default mode', () => {
  beforeEach(async () => {
    projectSandbox = await MyCliProjectFactory.create(cfg);
  });

  it('should work', async () => {
    const { stdout } = await projectSandbox.$myCommand({count: 42});
    expect(stdout).toContain('count is 42');
  });

});

The above test would be equivalent to: node cli.js my-command --count=42


made with ❤ by push-based.io