An open API service indexing awesome lists of open source software.

https://github.com/push-based/node-cli-testing

📦 A lib to test node processes and CLI's
https://github.com/push-based/node-cli-testing

Last synced: about 2 months ago
JSON representation

📦 A lib to test node processes and CLI's

Awesome Lists containing this project

README

        

# node-cli-testing

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

[![npm](https://img.shields.io/npm/v/%40push-based%2Fnode-cli-testing.svg)](https://www.npmjs.com/package/%40push-based%2Fnode-cli-testing)

## 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:

```text
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:

```ts
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:

```ts
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.

```ts
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:

```typescript
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.

```typescript
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

```typescript
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:

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

type MyRcJson = {
count: number
};

let projectSandbox: CliProject;

const cfg: ProjectConfig = {
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:

```typescript
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:

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

type MyRcJson = {
count: number
};

export class MyCliProject extends CliProject {

constructor() {
super();
}

$myCommand(processParams?: Partial<{count: number}>, userInput?: string[]): Promise {
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): Promise> {
const prj = new MyCliProject();
await prj._setup(cfg);
return prj;
}
}
```

Now we can use it in our tests like this:

```typescript
let projectSandbox: MyCliProject;

const cfg: ProjectConfig = {
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](https://www.push-based.io)