控制台测试

本指南涵盖在 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 方法,你可以在其中定义命令的行为。更新它以便向用户问候。

app/commands/greet.ts
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 方法接受命令类和一个参数数组(由于该命令不接受任何参数,此处为空数组)。

tests/unit/commands/greet.spec.ts
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 会将所有输出存储在内存中,而不是写入终端。这让你能够检查和断言命令所产生的确切消息。

Tip

Raw 模式会捕获来自 this.loggerthis.ui.table() 及其他 UI 方法的所有输出。测试完成后务必切回正常模式,以免影响其他测试。

使用 Japa 的 group.each.setup 钩子在每个测试前后自动切换模式。

tests/unit/commands/greet.spec.ts
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"')
  })
})
Warning

Raw 模式下的日志断言包含颜色函数名。this.logger.info('Hello') 在 raw 模式下会变成 [ blue(info) ] Hello。如果你的断言失败了,请检查你是否在期望字符串中包含了颜色格式。

测试表格输出

命令经常使用 this.ui.table() 显示表格数据。你可以像日志输出一样测试表格输出,只需先切换到 raw 模式。

考虑一个显示团队成员表格的命令。

app/commands/list_team.ts
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 来验证表格内容。传入一个二维数组,其中每个内层数组代表一行的单元格。

tests/unit/commands/list_team.spec.ts
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() 创建,该方法以提示标题作为参数。标题必须精确匹配,包括大小写。

Warning

提示标题是大小写敏感的。如果你的提示为 "What is your name?",而你拦截的是 "what is your name?",那么 trap 将不会匹配,你的测试会挂起等待输入。请始终从你的命令中复制确切的提示标题。

回复文本提示

使用 replyWith 为通过 this.prompt.ask() 创建的提示提供文本响应。

tests/unit/commands/greet.spec.ts
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()
  })
})
Tip

Trap 在触发时即被消费,并随后自动移除。如果某个测试在未触发被拦截提示的情况下就完成了,Japa 会抛出一个错误,提醒你所期望的提示从未出现。

从选择提示中选择选项

对于通过 this.prompt.choice()this.prompt.multiple() 创建的提示,请使用 chooseOptionchooseOptions,并传入从零开始的索引。

tests/unit/commands/setup.spec.ts
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() 创建的布尔提示,请使用 acceptreject

tests/unit/commands/cleanup.spec.ts
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')
  })
})

进阶:测试提示验证

提示可以包含 验证规则 ,用于拒绝无效的输入。你可以直接使用 assertPassesassertFails 测试这些验证器,而无需完整地执行命令。

assertFails 方法接受输入值和期望的错误消息。assertPasses 方法接受一个应当通过验证的值。

tests/unit/commands/create_user.spec.ts
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 退出,表示成功。

tests/unit/commands/greet.spec.ts
await command.exec()
command.assertSucceeded()
assertFailed

断言命令以非零的 exitCode 退出,表示失败。

tests/unit/commands/greet.spec.ts
await command.exec()
command.assertFailed()
assertExitCode

断言命令以特定的退出码退出。当你的命令使用不同的退出码来标识不同的错误条件时很有用。

tests/unit/commands/greet.spec.ts
await command.exec()
command.assertExitCode(2)
assertNotExitCode

断言命令未以特定代码退出。

tests/unit/commands/greet.spec.ts
await command.exec()
command.assertNotExitCode(1)
assertLog

断言命令写入了特定的日志消息。可选地将输出流指定为 stdoutstderr。需要 raw 模式。

tests/unit/commands/greet.spec.ts
await command.exec()

command.assertLog('[ blue(info) ] Task completed')
command.assertLog('[ red(error) ] Something went wrong', 'stderr')
assertLogMatches

断言命令写入了与正则表达式匹配的日志消息。当确切的消息会变化但遵循某种模式时很有用。需要 raw 模式。

tests/unit/commands/greet.spec.ts
await command.exec()

command.assertLogMatches(/Task completed in \d+ms/)
assertTableRows

断言命令打印了包含特定行的表格。传入一个二维数组,其中每个内层数组代表一行的列值。需要 raw 模式。

tests/unit/commands/list_users.spec.ts
await command.exec()

command.assertTableRows([
  ['1', 'Alice', 'alice@example.com'],
  ['2', 'Bob', 'bob@example.com'],
])

另请参阅