控制台测试
本指南涵盖在 AdonisJS 应用中测试 Ace 命令。你将学习如何:
- 为自定义的 Ace 命令编写测试
- 使用 raw 模式捕获并断言 logger 输出
- 测试命令输出中的表格渲染
- 以编程方式拦截并响应 CLI 提示
- 在测试中验证提示输入
- 使用针对命令结果的内置断言方法
概述
控制台测试让你可以验证自定义 Ace 命令是否正确运行,而无需手动交互。由于命令通常会产生终端输出并向用户索取输入,测试它们需要特殊的技术来捕获输出并模拟用户响应。
AdonisJS 通过 ace 服务提供了一套专用测试 API,让你可以创建命令实例、在隔离环境中执行它们,并对其行为做出断言。该 API 包含用于捕获日志输出、拦截提示以及验证退出码的工具。
当你的命令执行关键操作(如数据库迁移、文件生成或部署任务)时,测试命令的价值尤为突出。生产环境中一个失败的命令可能引发严重后果,因此自动化测试有助于在问题到达用户之前就将其捕获。
基础示例
让我们从头到尾演练如何测试一个简单的命令。首先,使用 make:command 生成器创建一个新命令。
node ace make:command greet
# DONE: create app/commands/greet.ts
生成的命令包含一个 run 方法,你可以在其中定义命令的行为。更新它以便向用户问候。
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
export default class Greet extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
static options: CommandOptions = {}
async run() {
this.logger.info('Hello world from "Greet"')
}
}
接下来,为命令创建一个测试文件。如果你尚未定义单元测试套件,请参阅 测试简介 获取设置说明。
node ace make:test commands/greet --suite=unit
# DONE: create tests/unit/commands/greet.spec.ts
测试使用 ace 服务来创建命令实例、执行它,并验证它成功完成。ace.create 方法接受命令类和一个参数数组(由于该命令不接受任何参数,此处为空数组)。
import { test } from '@japa/runner'
import Greet from '#commands/greet'
import ace from '@adonisjs/core/services/ace'
test.group('Commands greet', () => {
test('should greet and exit with code 0', async () => {
/**
* 创建命令的一个实例。第二个参数
* 是要传递给命令的 CLI 参数数组。
*/
const command = await ace.create(Greet, [])
/**
* 执行命令。这会运行 `run` 方法。
*/
await command.exec()
/**
* 断言命令成功退出(退出码 0)。
*/
command.assertSucceeded()
})
})
使用以下命令运行测试。
node ace test --files=commands/greet
测试 logger 输出
Greet 命令使用 this.logger.info() 向终端写入一条日志消息。默认情况下,该输出会直接发送到 stdout,这使得在测试中捕获它并做出断言变得困难。
为了解决这个问题,你可以将 ace UI 库切换到 raw 模式。在 raw 模式下,ace 会将所有输出存储在内存中,而不是写入终端。这让你能够检查和断言命令所产生的确切消息。
Raw 模式会捕获来自 this.logger、this.ui.table() 及其他 UI 方法的所有输出。测试完成后务必切回正常模式,以免影响其他测试。
使用 Japa 的 group.each.setup 钩子在每个测试前后自动切换模式。
import { test } from '@japa/runner'
import Greet from '#commands/greet'
import ace from '@adonisjs/core/services/ace'
test.group('Commands greet', (group) => {
/**
* 在每个测试之前切换到 raw 模式。返回的函数
* 会在每个测试之后运行以恢复正常模式。
*/
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should log greeting message', async () => {
const command = await ace.create(Greet, [])
await command.exec()
command.assertSucceeded()
/**
* 断言确切的日志消息。在 raw 模式下,颜色被
* 表示为像 `blue()` 这样的函数名。
*/
command.assertLog('[ blue(info) ] Hello world from "Greet"')
})
})
Raw 模式下的日志断言包含颜色函数名。this.logger.info('Hello') 在 raw 模式下会变成 [ blue(info) ] Hello。如果你的断言失败了,请检查你是否在期望字符串中包含了颜色格式。
测试表格输出
命令经常使用 this.ui.table() 显示表格数据。你可以像日志输出一样测试表格输出,只需先切换到 raw 模式。
考虑一个显示团队成员表格的命令。
import { BaseCommand } from '@adonisjs/core/ace'
export default class ListTeam extends BaseCommand {
static commandName = 'list:team'
static description = 'List all team members'
async run() {
const table = this.ui.table()
table.head(['Name', 'Email'])
table.row(['Harminder Virk', 'virk@adonisjs.com'])
table.row(['Romain Lanz', 'romain@adonisjs.com'])
table.row(['Julien-R44', 'julien@adonisjs.com'])
table.render()
}
}
使用 assertTableRows 来验证表格内容。传入一个二维数组,其中每个内层数组代表一行的单元格。
import { test } from '@japa/runner'
import ListTeam from '#commands/list_team'
import ace from '@adonisjs/core/services/ace'
test.group('Commands list:team', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should display team members table', async () => {
const command = await ace.create(ListTeam, [])
await command.exec()
/**
* 断言表格行与期望数据匹配。每个内层数组
* 代表一行及其列值。
*/
command.assertTableRows([
['Harminder Virk', 'virk@adonisjs.com'],
['Romain Lanz', 'romain@adonisjs.com'],
['Julien-R44', 'julien@adonisjs.com'],
])
})
})
拦截提示
提示(Prompts) 会暂停命令的执行并等待用户输入,这会阻塞自动化测试。为了解决这个问题,你必须在执行命令之前**拦截(trap)**提示。一个 trap 会拦截特定的提示并提供程序化的响应。
Trap 使用 command.prompt.trap() 创建,该方法以提示标题作为参数。标题必须精确匹配,包括大小写。
提示标题是大小写敏感的。如果你的提示为 "What is your name?",而你拦截的是 "what is your name?",那么 trap 将不会匹配,你的测试会挂起等待输入。请始终从你的命令中复制确切的提示标题。
回复文本提示
使用 replyWith 为通过 this.prompt.ask() 创建的提示提供文本响应。
import { test } from '@japa/runner'
import Greet from '#commands/greet'
import ace from '@adonisjs/core/services/ace'
test.group('Commands greet', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should greet user by name', async () => {
const command = await ace.create(Greet, [])
/**
* 拦截提示并提供响应。这必须
* 在调用 exec() 之前设置。
*/
command.prompt.trap('What is your name?').replyWith('Virk')
await command.exec()
command.assertSucceeded()
})
})
Trap 在触发时即被消费,并随后自动移除。如果某个测试在未触发被拦截提示的情况下就完成了,Japa 会抛出一个错误,提醒你所期望的提示从未出现。
从选择提示中选择选项
对于通过 this.prompt.choice() 或 this.prompt.multiple() 创建的提示,请使用 chooseOption 或 chooseOptions,并传入从零开始的索引。
import { test } from '@japa/runner'
import Setup from '#commands/setup'
import ace from '@adonisjs/core/services/ace'
test.group('Commands setup', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should configure with npm', async () => {
const command = await ace.create(Setup, [])
/**
* 从选择提示中选择第一个选项(索引 0)。
*/
command.prompt.trap('Select package manager').chooseOption(0)
await command.exec()
command.assertSucceeded()
})
test('should select multiple databases', async () => {
const command = await ace.create(Setup, [])
/**
* 通过传入索引数组选择多个选项。
*/
command.prompt.trap('Select databases to configure').chooseOptions([0, 2])
await command.exec()
command.assertSucceeded()
})
})
接受或拒绝确认提示
对于通过 this.prompt.confirm() 或 this.prompt.toggle() 创建的布尔提示,请使用 accept 或 reject。
import { test } from '@japa/runner'
import Cleanup from '#commands/cleanup'
import ace from '@adonisjs/core/services/ace'
test.group('Commands cleanup', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should delete files when confirmed', async () => {
const command = await ace.create(Cleanup, [])
command.prompt.trap('Want to delete all temporary files?').accept()
await command.exec()
command.assertSucceeded()
})
test('should abort when rejected', async () => {
const command = await ace.create(Cleanup, [])
command.prompt.trap('Want to delete all temporary files?').reject()
await command.exec()
command.assertLog('[ blue(info) ] Cleanup cancelled')
})
})
进阶:测试提示验证
提示可以包含
验证规则
,用于拒绝无效的输入。你可以直接使用 assertPasses 和 assertFails 测试这些验证器,而无需完整地执行命令。
assertFails 方法接受输入值和期望的错误消息。assertPasses 方法接受一个应当通过验证的值。
import { test } from '@japa/runner'
import CreateUser from '#commands/create_user'
import ace from '@adonisjs/core/services/ace'
test.group('Commands create:user', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should validate email format', async () => {
const command = await ace.create(CreateUser, [])
/**
* 在不执行完整命令的情况下测试验证。
* 链式调用多个断言以测试不同的输入。
*/
command.prompt
.trap('Enter your email')
.assertFails('', 'Email is required')
.assertFails('invalid', 'Please enter a valid email')
.assertPasses('user@example.com')
.replyWith('admin@adonisjs.com')
await command.exec()
command.assertSucceeded()
})
})
你可以将验证断言与 replyWith 链式调用,以同时测试验证器并提供最终的响应。
可用的断言
命令实例提供了多个用于验证命令行为的断言方法。
assertSucceeded
断言命令以 exitCode=0 退出,表示成功。
await command.exec()
command.assertSucceeded()
assertFailed
断言命令以非零的 exitCode 退出,表示失败。
await command.exec()
command.assertFailed()
assertExitCode
断言命令以特定的退出码退出。当你的命令使用不同的退出码来标识不同的错误条件时很有用。
await command.exec()
command.assertExitCode(2)
assertNotExitCode
断言命令未以特定代码退出。
await command.exec()
command.assertNotExitCode(1)
assertLog
断言命令写入了特定的日志消息。可选地将输出流指定为 stdout 或 stderr。需要 raw 模式。
await command.exec()
command.assertLog('[ blue(info) ] Task completed')
command.assertLog('[ red(error) ] Something went wrong', 'stderr')
assertLogMatches
断言命令写入了与正则表达式匹配的日志消息。当确切的消息会变化但遵循某种模式时很有用。需要 raw 模式。
await command.exec()
command.assertLogMatches(/Task completed in \d+ms/)
assertTableRows
断言命令打印了包含特定行的表格。传入一个二维数组,其中每个内层数组代表一行的列值。需要 raw 模式。
await command.exec()
command.assertTableRows([
['1', 'Alice', 'alice@example.com'],
['2', 'Bob', 'bob@example.com'],
])
另请参阅
- Ace prompts 了解创建交互式提示的详情
- Creating commands 了解构建自定义 Ace 命令
- Testing introduction 了解配置测试套件与运行器