@push-based/node-cli-testing
v0.2.3
Published
### A e2e-testing library for node CLI and processes including sandbox generation on the fly
Downloads
87
Readme
node-cli-testing
A e2e-testing library for node CLI and processes including sandbox generation on the fly
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