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.bin('artisan.ts')
./bin/artisan.ts
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
:
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.bin('artisan.ts')
./bin/artisan.ts
TestCommand.setArtisanPath()
static method before running
your tests:
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:
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)
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()
}
}
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 👈
}
}