Skip to main content

CLI Testing

See how to create tests for CLI applications in Athenna.

Introduction

Athenna provides a very fluent API for running CLI commands of your application and examining the output. For example, take a look at the e2e test defined below:

import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
@Test()
public async 'test successful output'({ command }: Context) {
const output = await command.run('app')

output.assertSucceeded()
}
}

The command.run() method will run a child process using the Path.bootstrap('artisan.ts') file to execute the app command and get the stdout, stderr and exitCode outputs, while the assertSucceeded() method asserts that the returned output should have a successful exit code (0). In addition to this simple assertion, Athenna also contains a variety of assertions for inspecting the output.

You might have noticed that the ExampleTest is extending the BaseConsoleTest class. We gonna see later on this documentation what is the purpose of this class, and how to configure it for your needs.

Registering command plugin

The command property in your test context will only be available if you register the command plugin within the Runner class. By default, your Athenna application already comes with the command plugin registered. But we are going to cover how to register it manually if needed.

Just call the Runner.addPlugin() static method to set up the request plugin imported from @athenna/artisan/testing/plugins:

Path.bootstrap('test.ts')
import { Runner } from '@athenna/test'
import { request } from '@athenna/http/testing/plugins'
import { command } from '@athenna/artisan/testing/plugins'

await Runner.setTsEnv()
.addAssertPlugin()
.addPlugin(request())
.addPlugin(command()) 👈
.addPath('tests/e2e/**/*.ts')
.addPath('tests/unit/**/*.ts')
.setCliArgs(process.argv.slice(2))
.setGlobalTimeout(5000)
.run()

Running commands

To run a command to your application, you may invoke the command.run() method within your test.

This method will return a TestOutput instance, which provides a variety of helpful assertions that allow you to inspect your application's CLI output:

import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
@Test()
public async testBasicCommand({ command }: Context) {
const output = await command.run('greet')

output.assertExitCode(0)
}
}

Changing Artisan file path

Changing the default Artisan file path

As mentioned previously, the command.run() method invokes a child process using the Path.bootstrap('artisan.ts') file. But for some reason, you may want to change which file should be used to test your commands. To do so, you can call the TestCommand.setArtisanPath() static method before running your tests:

Path.bootstrap('test.ts')
import { Runner } from '@athenna/test'
import { request } from '@athenna/http/testing/plugins'
import { command, TestCommand } from '@athenna/artisan/testing/plugins'

TestCommand.setArtisanPath(Path.fixtures('artisan.ts')) 👈

await Runner.setTsEnv()
.addAssertPlugin()
.addPlugin(request())
.addPlugin(command())
.addPath('tests/e2e/**/*.ts')
.addPath('tests/unit/**/*.ts')
.setCliArgs(process.argv.slice(2))
.setGlobalTimeout(5000)
.run()

Changing Artisan file path per command

When running your tests, you may want to create a different behavior for a specific command, like mocking the prompts to not block your test execution or adding some different value for an Env or Config.

Since the command.run() method invokes a child process, you can't do this kind of customization in your tests:

import { Config } from '@athenna/config'
import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
@Test()
public async testConfigCommand({ command }: Context) {
Config.set('app.name', 'MyAppName')

const output = await command.run('greet')

output.assertLogged('Hello from MyAppName!')
}
}

To solve this problem, you can use a different artisan file for each command.run() call. Let's first create a new artisan file and save it in our fixtures path:

Path.fixtures('consoles/artisan-set-app-name.ts')
import { Ignite } from '@athenna/core'
import { Config } from '@athenna/config'

const ignite = await new Ignite().load(import.meta.url, { bootLogs: false })

Config.set('app.name', 'MyAppName')

await ignite.console(process.argv, { displayName: 'Artisan' })

Now, we can use this new artisan file to run our command:

import { Path } from '@athenna/common'
import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
@Test()
public async testConfigCommand({ command }: Context) {
const output = await command.run('greet', {
path: Path.fixtures('consoles/artisan-set-app-name.ts') 👈
})

output.assertLogged('Hello from MyAppName!')
}
}

Changing Artisan file path for the test group

If you need to use the same Artisan file for all the tests inside of your class, but you don't want to change it globally for all the rest of your classes using TestCommand.setArtisanPath() method, you can set the artisanPath property in your test class that BaseConsoleTest will automatically change it when bootstraping your app:

import { Path } from '@athenna/common'
import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
public artisanPath = Path.fixtures('consoles/artisan-set-app-name.ts') 👈

@Test()
public async testConfigCommand({ command }: Context) {
const output = await command.run('greet')

output.assertLogged('Hello from MyAppName!')
}
}

Debugging outputs

After executing a test command to your application, the output returned will contain the output property inside with all the output data:

import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest BaseConsoleTest {
@Test()
public async testBasicCommand({ command }: Context) {
const output = await command.run('basic')

console.log(output.output.stdout)
console.log(output.output.stderr)
console.log(output.output.exitCode)

output.assertExitCode(0)
}
}

Output assertions

Athenna's TestOutput class provides a variety of custom assertion methods that you may utilize when testing your application. These assertions may be accessed on the output that is returned by the command.run() test method:

assertExitCode()

Assert the exit code of the output:

output.assertExitCode(0)
output.assertIsNotExitCode(1)
tip

The 0 exit code means a successful exit of the command, anything different from 0 means an error.

assertSucceeded()

Assert the command exits with 0 exit code:

output.assertSucceeded()

assertFailed()

Assert the command exits with anything different from 0 exit code:

output.assertFailed()

assertLogged()

Assert the command has logged the expected message:

output.assertLogged('Hello World!')
output.assertNotLogged('Hello World!')

This method validates that the log message will be printed in stdout or stderr. To force the stream type where this log should appear, you can set it as second argument:

output.assertLogged('Hello World!', 'stdout') // or stderr
output.assertNotLogged('Hello World!', 'stdout') // or stderr

assertLogMatches()

Assert the command has logged a message that matches the RegExp provided:

output.assertLogMatches(/Hello World/)
output.assertLogNotMatches(/Hello World/)

This method validates that the regex will match in stdout or stderr. To force the stream type where this log should match, you can set it as second argument:

output.assertLogMatches(/Hello World/, 'stdout') // or stderr
output.assertLogNotMatches(/Hello World/, 'stdout') // or stderr

The BaseConsoleTest class

The BaseConsoleTest class is responsible to bootstrap your Athenna application before running all tests and also to kill the application after running all tests, meaning that is not possible to use the command property without extending this class or at least setting up your own Athenna application using Ignite class.

If for some reason you need to change the options set when calling the Ignite.load() or Ignite.artisan() methods, you can set the igniteOptions and artisanOptions properties in your test class:

import { Path } from '@athenna/common'
import { type IgniteOptions } from '@athenna/core'
import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
public igniteOptions: IgniteOptions = {
bootLogs: true,
shutdownLogs: true,
envPath: Path.fixtures('envs/.env'),
athennaRcPath: Path.fixtures('rcs/.athennarc.json'),
environments: ['http', 'test']
}

@Test()
public async 'test successful output'({ command }: Context) {
const output = await command.run('app')

output.assertSucceeded()
}
}
warning

Remember that changes done to the options of Ignite class will only be relevant when running commands outside of the child process. Meaning that if you call command.run(), the options used to ignite Artisan will be from your Artisan file, and not from the ones you set in your test class.

To solve this, check how to change the Artisan file path of your test group.

Accessing Ignite instance

You are able to access the Ignite instance by using the ignite property:

import { Test, type Context } from '@athenna/test'
import { BaseConsoleTest } from '@athenna/core/testing/BaseConsoleTest'

export default class ExampleTest extends BaseConsoleTest {
@Test()
public async 'test successful output'({ command }: Context) {
this.ignite 👈
}
}