From 0b69ca7a17ca23425bb28cc44238dba87931f4ec Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 17 May 2024 10:26:35 -0600 Subject: [PATCH] chore: update docs --- MIGRATION.md | 310 ++++++++++++++++++ README.md | 228 +------------ test/capture-output.test.ts | 69 ++++ test/exit.test.ts | 16 - test/index.test.ts | 50 --- test/{command.test.ts => run-command.test.ts} | 15 +- test/{hook.test.ts => run-hook.test.ts} | 2 +- 7 files changed, 407 insertions(+), 283 deletions(-) create mode 100644 MIGRATION.md create mode 100644 test/capture-output.test.ts delete mode 100644 test/exit.test.ts delete mode 100644 test/index.test.ts rename test/{command.test.ts => run-command.test.ts} (69%) rename test/{hook.test.ts => run-hook.test.ts} (92%) diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..708556a --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,310 @@ +# Migrating from v3 to v4 + +- [Migrating from v3 to v4](#migrating-from-v3-to-v4) + - [`.command()`](#command) + - [`.exit()`](#exit) + - [`.hook()`](#hook) + - [`.stdout() and .stderr()`](#stdout-and-stderr) + - [`.loadConfig()`](#loadconfig) + - [`.do()`](#do) + - [`.catch()`](#catch) + - [`.finally()`](#finally) + - [`.env()`](#env) + - [`.stub()`](#stub) + - [`.add()`](#add) + - [`.stdin()`](#stdin) + - [`.retries()`](#retries) + - [`.timeout()`](#timeout) + - [`.nock()`](#nock) + +## `.command()` + +`.command()` allowed you to run a command from your CLI. This can now be achieved with the `runCommand` function. + +**Before** + +```typescript +import {expect, test} from '@oclif/test' + +describe('my cli', () => { + test + .stdout() + .command(['hello:world']) + .it('runs hello world cmd', (ctx) => { + expect(ctx.stdout).to.contain('hello world!') + }) +}) +``` + +**After** + +```typescript +import {expect} from 'chai' +import {runCommand} from '@oclif/test' + +describe('my cli', () => { + it('should run the command', async () => { + const {stdout} = await runCommand<{name: string}>(['hello:world']) + expect(stdout).to.contain('hello world') + }) +}) +``` + +**Before (Single Command CLI)** + +```typescript +import {expect, test} from '@oclif/test' + +describe('my cli', () => { + test + .stdout() + .command(['.']) + .it('runs hello world cmd', (ctx) => { + expect(ctx.stdout).to.contain('hello world!') + }) +}) +``` + +**After (Single Command CLI)** + +```typescript +import {expect} from 'chai' +import {runCommand} from '@oclif/test' + +describe('my cli', () => { + it('should run the command', async () => { + const {stdout} = await runCommand<{name: string}>(['.']) + expect(stdout).to.contain('hello world') + }) +}) +``` + +## `.exit()` + +`.exit()` allowed you to test that command exited with a certain exit code. This can now be done by inspecting the `error` that's returned by `runCommand` + +**Before** + +```typescript +import {join} from 'node:path' +import {expect, test} from '@oclif/test' + +describe('exit', () => { + test + .loadConfig() + .stdout() + .command(['hello:world', '--code=101']) + .exit(101) + .do((output) => expect(output.stdout).to.equal('exiting with code 101\n')) + .it('should exit with code 101') +}) +``` + +**After** + +```typescript +import {expect} from 'chai' +import {runCommand} from '@oclif/test' + +describe('exit', () => { + it('should exit with code 101', async () => { + const {error} = await runCommand<{name: string}>(['hello:world', '--code=101']) + expect(error?.oclif?.exit).to.equal(101) + }) +}) +``` + +## `.hook()` + +`.hook()` allowed you to test a hook in your CLI. This can now be accomplished using the `runHook` function. + +**Before** + +```typescript +import {join} from 'node:path' + +import {expect, test} from '@oclif/test' + +const root = join(__dirname, 'fixtures/test-cli') + +describe('hooks', () => { + test + .loadConfig({root}) + .stdout() + .hook('foo', {argv: ['arg']}, {root}) + .do((output) => expect(output.stdout).to.equal('foo hook args: arg\n')) + .it('should run hook') +}) +``` + +**After** + +```typescript +import {join} from 'node:path' +import {expect} from 'chai' +import {runHook} from '@oclif/test' + +const root = join(__dirname, 'fixtures/test-cli') +describe('my cli', () => { + it('should run hook', async () => { + const {stdout} = await runHook('foo', {argv: ['arg']}, {root}) + expect(stdout).to.equal('foo hook args: arg\n') + }) +}) +``` + +## `.stdout() and .stderr()` + +Version 3 allowed you to access the output in stdout and stderr by attaching it to the `context`. This is now replaced by the `stdout` and `stderr` strings that are returned by `runCommand`, `runHook`, and `captureOutput` + +**Before** + +```javascript +describe('stdmock tests', () => { + fancy + .stdout() + .stderr() + .it('mocks stdout and stderr', (context) => { + console.log('foo') + console.error('bar') + expect(context.stdout).to.equal('foo\n') + expect(context.stderr).to.equal('bar\n') + }) +}) +``` + +**After** + +```typescript +import {expect} from 'chai' +import {captureOutput} from '@oclif/test' + +describe('stdmock tests', () => { + it('mocks stdout and stderr', async () => { + const {stdout, stderr} = await captureOutput(async () => { + console.log('foobar') + console.error('bar') + }) + + expect(stdout).to.equal('foo\n') + expect(stderr).to.equal('bar\n') + }) +}) +``` + +## `.loadConfig()` + +`.loadConfig()` allowed you to explicitly set the root of the CLI to be tested. This can now be achieved by passing the path into the `runCommand` function. + +**Before** + +```typescript +import {join} from 'node:path' +import {expect, test} from '@oclif/test' + +const root = join(__dirname, 'fixtures/test-cli') +describe('my cli', () => { + test + .loadConfig({root}) + .stdout() + .command(['foo:bar']) + .it('should run the command from the given directory', (ctx) => { + expect(ctx.stdout).to.equal('hello world!\n') + const {name} = ctx.returned as {name: string} + expect(name).to.equal('world') + }) +}) +``` + +**After** + +```typescript +import {join} from 'node:path' +import {expect} from 'chai' +import {runCommand} from '@oclif/test' + +const root = join(__dirname, 'fixtures/test-cli') + +describe('my cli', () => { + it('should run the command from the given directory', async () => { + const {result, stdout} = await runCommand<{name: string}>(['foo:bar'], {root}) + expect(result.name).to.equal('world') + }) +}) +``` + +## `.do()` + +`.do()` allowed you to execute some arbitrary code within the test pipeline. There's not a direct replacement in version 4, however, you are still able to execute arbitrary code within your chosen test framework. For example, mocha exposes the `beforeEach` and `before` hooks. + +## `.catch()` + +`.catch()` allowed you to catch errors in a declarative way and ensure that the error was actually thrown. We encourage you to use the utilities provided by your preferred testing framework to accomplish this. + +## `.finally()` + +`.finally()` allowed you to run a task at the end of a test, even if it failed. We encourage you to use the utilities provided by your preferred testing framework to accomplish this (for instance, mocha provided `afterEach` and `after` lifecycle hooks). + +## `.env()` + +`.env()` allowed you to set the environment variables before running the test. If you need this, you can easily implement it yourself. + +**Before** + +```javascript +describe('env tests', () => { + fancy.env({FOO: 'BAR'}).it('mocks FOO', () => { + expect(process.env.FOO).to.equal('BAR') + expect(process.env).to.not.deep.equal({FOO: 'BAR'}) + }) +}) +``` + +**After** + +```javascript +describe('env tests', () => { + let originalEnv + + beforeEach(() => { + originalEnv = {...process.env} + process.env = { + ...originalEnv + FOO: 'BAR' + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('mocks FOO', () => { + expect(process.env.FOO).to.equal('BAR') + expect(process.env).to.not.deep.equal({FOO: 'BAR'}) + }) +}) +``` + +## `.stub()` + +`.stub()` allowed you to stub any object and ensure that the stubs were cleared before the next test. We encourage you to use a dedicated library like [sinon](https://www.npmjs.com/package/sinon) for this. + +## `.add()` + +`.add()` allowed you to extend the `context` object that was used throughout fancy-tests. There is no direct replacement for this in version 4. + +## `.stdin()` + +`.stdin()` allowed you mock stdin. There is no direct replacement for this in version 4. You can use [mock-stdin](https://www.npmjs.com/package/mock-stdin) directly if you need this functionality. + +## `.retries()` + +`.retries()` allowed you to retry the test. There is no direct replacement for this but most testing frameworks have functionality for this builtin. + +## `.timeout()` + +`.timeout` allowed to set a timeout for a test. There is no direct replacement for this but most testing frameworks have functionality for this builtin. + +## `.nock()` + +`.nock` allowed you to use [nock](https://www.npmjs.com/package/nock) to mock HTTP requests. There is no direct replacement for since you can use [nock](https://www.npmjs.com/package/nock) directly if you need to. diff --git a/README.md b/README.md index aef95e4..d5f1e25 100644 --- a/README.md +++ b/README.md @@ -6,230 +6,28 @@ test helpers for oclif CLIs [![Downloads/week](https://img.shields.io/npm/dw/@oclif/test.svg)](https://npmjs.org/package/@oclif/test) [![License](https://img.shields.io/npm/l/@oclif/test.svg)](https://github.com/oclif/test/blob/main/package.json) -## Usage - -`@oclif/test` is an extension of [fancy-test](https://github.com/oclif/fancy-test). Please see the [fancy-test documentation](https://github.com/oclif/fancy-test#fancy-test) for all the features that are available. - -The following are the features that `@oclif/test` adds to `fancy-test`. - -### `.loadConfig()` - -`.loadConfig()` creates and returns a new [`Config`](https://github.com/oclif/core/blob/main/src/config/config.ts) instance. This instance will be available on the `ctx` variable that's provided in the callback. - -```typescript -import {join} from 'node:path' -import {expect, test} from '@oclif/test' - -const root = join(__dirname, 'fixtures/test-cli') -test - .loadConfig({root}) - .stdout() - .command(['foo:bar']) - .it('should run the command from the given directory', (ctx) => { - expect(ctx.stdout).to.equal('hello world!\n') - expect(ctx.config.root).to.equal(root) - const {name} = ctx.returned as {name: string} - expect(name).to.equal('world') - }) -``` - -If you would like to run the same test without using `@oclif/test`: - -```typescript -import {Config, ux} from '@oclif/core' -import {expect} from 'chai' -import {join} from 'node:path' -import {SinonSandbox, SinonStub, createSandbox} from 'sinon' - -const root = join(__dirname, 'fixtures/test-cli') -describe('non-fancy test', () => { - let sandbox: SinonSandbox - let config: Config - let stdoutStub: SinonStub - - beforeEach(async () => { - sandbox = createSandbox() - stdoutStub = sandbox.stub(ux.write, 'stdout') - config = await Config.load({root}) - }) - - afterEach(async () => { - sandbox.restore() - }) - - it('should run command from the given directory', async () => { - const {name} = await config.runCommand<{name: string}>('foo:bar') - expect(stdoutStub.calledWith('hello world!\n')).to.be.true - expect(config.root).to.equal(root) - expect(name).to.equal('world') - }) -}) -``` - -### `.command()` - -`.command()` let's you run a command from your CLI. - -```typescript -import {expect, test} from '@oclif/test' - -describe('hello world', () => { - test - .stdout() - .command(['hello:world']) - .it('runs hello world cmd', (ctx) => { - expect(ctx.stdout).to.contain('hello world!') - }) -}) -``` - -For a [single command cli](https://oclif.io/docs/single_command_cli) you would provide `'.'` as the command. For instance: - -```typescript -import {expect, test} from '@oclif/test' +## Migration -describe('hello world', () => { - test - .stdout() - .command(['.']) - .it('runs hello world cmd', (ctx) => { - expect(ctx.stdout).to.contain('hello world!') - }) -}) -``` +See the [V4 Migration Guide](./MIGRATION.md) if you are migrating from v3 or older. -If you would like to run the same test without using `@oclif/test`: - -```typescript -import {Config, ux} from '@oclif/core' -import {expect} from 'chai' -import {SinonSandbox, SinonStub, createSandbox} from 'sinon' - -describe('non-fancy test', () => { - let sandbox: SinonSandbox - let config: Config - let stdoutStub: SinonStub - - beforeEach(async () => { - sandbox = createSandbox() - stdoutStub = sandbox.stub(ux.write, 'stdout') - config = await Config.load({root: process.cwd()}) - }) - - afterEach(async () => { - sandbox.restore() - }) - - it('should run command', async () => { - // use '.' for a single command CLI - const {name} = await config.runCommand<{name: string}>('hello:world') - expect(stdoutStub.calledWith('hello world!\n')).to.be.true - expect(name).to.equal('world') - }) -}) -``` - -### `.exit()` - -`.exit()` let's you test that a command exited with a certain exit code. - -```typescript -import {join} from 'node:path' -import {expect, test} from '@oclif/test' - -describe('exit', () => { - test - .loadConfig() - .stdout() - .command(['hello:world', '--code=101']) - .exit(101) - .do((output) => expect(output.stdout).to.equal('exiting with code 101\n')) - .it('should exit with code 101') -}) -``` - -If you would like to run the same test without using `@oclif/test`: - -```typescript -import {Config, Errors, ux} from '@oclif/core' -import {expect} from 'chai' -import {SinonSandbox, createSandbox} from 'sinon' - -describe('non-fancy test', () => { - let sandbox: SinonSandbox - let config: Config - - beforeEach(async () => { - sandbox = createSandbox() - sandbox.stub(ux.write, 'stdout') - config = await Config.load({root: process.cwd()}) - }) - - afterEach(async () => { - sandbox.restore() - }) - - it('should run command from the given directory', async () => { - try { - await config.runCommand('.') - throw new Error('Expected CLIError to be thrown') - } catch (error) { - if (error instanceof Errors.CLIError) { - expect(error.oclif.exit).to.equal(101) - } else { - throw error - } - } - }) -}) -``` - -### `.hook()` - -`.hook()` let's you test a hook in your CLI. +## Usage -```typescript -import {join} from 'node:path' +`@oclif/test` provides a handful of utilities that make it easy to test your [oclif](https://oclif.io) CLI. -import {expect, test} from '@oclif/test' +### `captureOutput` -const root = join(__dirname, 'fixtures/test-cli') +`captureOutput` allows you to get the stdout, stderr, return value, and error of the callback you provide it. This makes it possible to assert that certain strings were printed to stdout and stderr or that the callback failed with the expected error or succeeded with the expected result. -describe('hooks', () => { - test - .loadConfig({root}) - .stdout() - .hook('foo', {argv: ['arg']}, {root}) - .do((output) => expect(output.stdout).to.equal('foo hook args: arg\n')) - .it('should run hook') -}) -``` +See the [tests](./test/capture-output.test.ts) for example usage. -If you would like to run the same test without using `@oclif/test`: +### `runCommand` -```typescript -import {Config, ux} from '@oclif/core' -import {expect} from 'chai' -import {SinonSandbox, SinonStub, createSandbox} from 'sinon' +`runCommand` allows you to get the stdout, stderr, return value, and error of a command in your CLI. -describe('non-fancy test', () => { - let sandbox: SinonSandbox - let config: Config - let stdoutStub: SinonStub +See the [tests](./test/run-command.test.ts) for example usage. - beforeEach(async () => { - sandbox = createSandbox() - stdoutStub = sandbox.stub(ux.write, 'stdout') - config = await Config.load({root: process.cwd()}) - }) +### `runHook` - afterEach(async () => { - sandbox.restore() - }) +`runHook` allows you to get the stdout, stderr, return value, and error of a hook in your CLI. - it('should run hook', async () => { - const {name} = await config.runHook('foo', {argv: ['arg']}) - expect(stdoutStub.calledWith('foo hook args: arg\n')).to.be.true - }) -}) -``` +See the [tests](./test/run-hook.test.ts) for example usage. diff --git a/test/capture-output.test.ts b/test/capture-output.test.ts new file mode 100644 index 0000000..b684f9d --- /dev/null +++ b/test/capture-output.test.ts @@ -0,0 +1,69 @@ +import {Command, Errors, Flags} from '@oclif/core' +import {expect} from 'chai' + +import {captureOutput} from '../src' + +class MyCommand extends Command { + static flags = { + channel: Flags.option({ + char: 'c', + multiple: true, + options: ['stdout', 'stderr'] as const, + required: true, + })(), + throw: Flags.integer(), + } + + async run() { + const {flags} = await this.parse(MyCommand) + + if (flags.throw) throw new Errors.CLIError('error', {exit: flags.throw}) + + if (flags.channel.includes('stdout')) { + this.log('hello world!') + } + + if (flags.channel.includes('stderr')) { + this.logToStderr('hello world!') + } + + return {success: true} + } +} + +describe('captureOutput', () => { + it('should capture stdout', async () => { + const {stdout} = await captureOutput(async () => MyCommand.run(['-c=stdout'])) + expect(stdout).to.equal('hello world!\n') + }) + + it('should capture stderr', async () => { + const {stderr} = await captureOutput(async () => MyCommand.run(['-c=stderr'])) + expect(stderr).to.equal('hello world!\n') + }) + + it('should capture both', async () => { + const {stderr, stdout} = await captureOutput(async () => MyCommand.run(['-c=stdout', '-c=stderr'])) + expect(stdout).to.equal('hello world!\n') + expect(stderr).to.equal('hello world!\n') + }) + + it('should capture both from console', async () => { + const {stderr, stdout} = await captureOutput(async () => { + console.log('hello world!') + console.error('hello world!') + }) + expect(stdout).to.equal('hello world!\n') + expect(stderr).to.equal('hello world!\n') + }) + + it('should capture result', async () => { + const {result} = await captureOutput(async () => MyCommand.run(['-c=stdout'])) + expect(result).to.deep.equal({success: true}) + }) + + it('should capture error', async () => { + const {error} = await captureOutput(async () => MyCommand.run(['-c=stdout', '--throw=101'])) + expect(error?.oclif?.exit).to.equal(101) + }) +}) diff --git a/test/exit.test.ts b/test/exit.test.ts deleted file mode 100644 index 3762d2c..0000000 --- a/test/exit.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {expect} from 'chai' -import {join} from 'node:path' - -import {runCommand} from '../src' - -// eslint-disable-next-line unicorn/prefer-module -const root = join(__dirname, 'fixtures/multi') - -describe('exit', () => { - it('should handle expected exit codes', async () => { - const {error, stdout} = await runCommand(['exit', '--code=101'], {root}) - expect(stdout).to.equal('exiting with code 101\n') - expect(error?.message).to.equal('EEXIT: 101') - expect(error?.oclif?.exit).to.equal(101) - }) -}) diff --git a/test/index.test.ts b/test/index.test.ts deleted file mode 100644 index 19fbd1e..0000000 --- a/test/index.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {Command, Flags} from '@oclif/core' -import {expect} from 'chai' - -import {captureOutput} from '../src' - -class MyCommand extends Command { - static flags = { - channel: Flags.option({ - char: 'c', - multiple: true, - options: ['stdout', 'stderr'] as const, - required: true, - })(), - } - - async run() { - const {flags} = await this.parse(MyCommand) - if (flags.channel.includes('stdout')) { - this.log('hello world!') - } - - if (flags.channel.includes('stderr')) { - this.logToStderr('hello world!') - } - } -} - -describe('captureOutput', () => { - it('should capture stdout', async () => { - const {stdout} = await captureOutput(async () => { - await MyCommand.run(['-c=stdout']) - }) - expect(stdout).to.equal('hello world!\n') - }) - - it('should capture stderr', async () => { - const {stderr} = await captureOutput(async () => { - await MyCommand.run(['-c=stderr']) - }) - expect(stderr).to.equal('hello world!\n') - }) - - it('should capture both', async () => { - const {stderr, stdout} = await captureOutput(async () => { - await MyCommand.run(['-c=stdout', '-c=stderr']) - }) - expect(stdout).to.equal('hello world!\n') - expect(stderr).to.equal('hello world!\n') - }) -}) diff --git a/test/command.test.ts b/test/run-command.test.ts similarity index 69% rename from test/command.test.ts rename to test/run-command.test.ts index 9221071..6d09fb0 100644 --- a/test/command.test.ts +++ b/test/run-command.test.ts @@ -3,7 +3,7 @@ import {join} from 'node:path' import {runCommand} from '../src' -describe('command', () => { +describe('runCommand', () => { // eslint-disable-next-line unicorn/prefer-module const root = join(__dirname, 'fixtures/multi') @@ -24,6 +24,19 @@ describe('command', () => { expect(stdout).to.equal('hello foo!\n') expect(result?.name).to.equal('foo') }) + + it('should handle single string', async () => { + const {result, stdout} = await runCommand<{name: string}>('foo:bar --name=foo', {root}) + expect(stdout).to.equal('hello foo!\n') + expect(result?.name).to.equal('foo') + }) + + it('should handle expected exit codes', async () => { + const {error, stdout} = await runCommand(['exit', '--code=101'], {root}) + expect(stdout).to.equal('exiting with code 101\n') + expect(error?.message).to.equal('EEXIT: 101') + expect(error?.oclif?.exit).to.equal(101) + }) }) describe('single command cli', () => { diff --git a/test/hook.test.ts b/test/run-hook.test.ts similarity index 92% rename from test/hook.test.ts rename to test/run-hook.test.ts index 2f62ddb..79fb8a1 100644 --- a/test/hook.test.ts +++ b/test/run-hook.test.ts @@ -6,7 +6,7 @@ import {runHook} from '../src' // eslint-disable-next-line unicorn/prefer-module const root = join(__dirname, 'fixtures/multi') -describe('hooks', () => { +describe('runHook', () => { it('should run a hook', async () => { const {stdout} = await runHook('foo', {argv: ['arg']}, {root}) expect(stdout).to.equal('foo hook args: arg\n')