You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Commander.js的Action中测试Inquirer交互提示?

如何用Vitest和@inquirer/testing测试Commander.js的交互CLI命令

问题背景

我用Commander.js开发了一个带Inquirer交互提示的CLI工具,希望通过单元测试验证这些交互流程(比如自动填充提示的测试值)。以下是add命令的Action代码:

const program = new Command();
program
        .command('add')
        .alias('a')
        .description('Add new command')
        .option('--description, -d <d>', 'Project command description')
        .arguments('[name] [path]')
        .action(async (_name, _path, { d }) => {
            if (customCommands?.find((p) => p?.name === _name)) {
                showMessageLog(
                    {
                        error: `Command name ${_name} already exists. Please choose a different name.`,
                    },
                    { exit: true, exitNumber: 1 },
                );
            }

            const name =
                _name ??
                (await input({
                    message: 'Enter the command name',
                    validate: (value) => {
                        if (value.includes(' ')) {
                            return 'Command name cannot contain spaces';
                        }
                        if (program.commands.find((cmd) => cmd.name() === value)) {
                            return `Command name ${value} already exists. Please choose a different name.`;
                        }
                        return true;
                    },
                }));

            const path =
                _path ??
                (await input({
                    message: 'Enter the path',
                    default: customCommands?.find((p) => p?.name === name)?.path ?? '/',
                    required: true,
                }));

            const desc =
                d ??
                (await input({
                    message: 'Enter the command description. Leave empty for no description:',
                    default: '',
                }));

            const newConfig: CustomCommand = {
                name,
                path,
                description: desc === '' ? undefined : desc,
            };
            const { error, success } = await saveConfig('commands', [newConfig], 'name');
            showMessageLog({ error, success }, { exit: true, exitNumber: 1 });
        });

我尝试用@inquirer/testing结合Vitest测试,但代码无法正常运行:

describe('add command', () => {
            test('should call action with correct arguments', async () => {
                const p = await program.parseAsync(['node', 'weasel', 'add']);

                const { answer, events, getScreen } = await render(p, {
                    message: 'Enter the command name',
                });

                events.type('test');
            });
        });

解决方案

核心思路

@inquirer/testing的render方法是用来测试单个Inquirer提示的,不能直接传入Commander的解析结果。正确的做法是通过模拟依赖(尤其是Inquirer的input函数)和控制测试流程来验证交互逻辑,同时结合Vitest的mock能力隔离外部依赖。

步骤1:安装必要依赖

确保已安装测试所需包:

npm install -D @inquirer/testing vitest

步骤2:编写测试代码

以下是完整的测试示例,覆盖了无参数交互、传入参数、命令名重复三种场景:

import { describe, test, expect, vi, beforeEach } from 'vitest';
import { Command } from 'commander';
import input from '@inquirer/input';
// 替换为你的实际模块路径
import { showMessageLog, saveConfig } from './cli-utils';

// 模拟全局依赖
vi.mock('./cli-utils', () => ({
  showMessageLog: vi.fn(),
  saveConfig: vi.fn().mockResolvedValue({ success: 'Command added successfully' }),
}));

// 模拟customCommands状态,用于测试重复命令场景
let customCommands = [];

describe('add command', () => {
  let program: Command;

  // 每次测试前重置Commander实例,避免状态污染
  beforeEach(() => {
    program = new Command();
    customCommands = [];

    // 重新注册add命令(或导入你的全局program实例)
    program
      .command('add')
      .alias('a')
      .description('Add new command')
      .option('--description, -d <d>', 'Project command description')
      .arguments('[name] [path]')
      .action(async (_name, _path, { d }) => {
        if (customCommands?.find((p) => p?.name === _name)) {
          showMessageLog(
            { error: `Command name ${_name} already exists. Please choose a different name.` },
            { exit: true, exitNumber: 1 }
          );
        }

        const name = _name ?? await input({
          message: 'Enter the command name',
          validate: (value) => {
            if (value.includes(' ')) return 'Command name cannot contain spaces';
            if (program.commands.find((cmd) => cmd.name() === value)) {
              return `Command name ${value} already exists. Please choose a different name.`;
            }
            return true;
          },
        });

        const path = _path ?? await input({
          message: 'Enter the path',
          default: customCommands?.find((p) => p?.name === name)?.path ?? '/',
          required: true,
        });

        const desc = d ?? await input({
          message: 'Enter the command description. Leave empty for no description:',
          default: '',
        });

        const newConfig = { name, path, description: desc === '' ? undefined : desc };
        const { error, success } = await saveConfig('commands', [newConfig], 'name');
        showMessageLog({ error, success }, { exit: true, exitNumber: 1 });
      });
  });

  test('通过交互提示完成命令添加', async () => {
    // Mock Inquirer的input函数,依次返回预设值
    vi.mocked(input)
      .mockResolvedValueOnce('test-cmd') // 命令名称
      .mockResolvedValueOnce('/test/path') // 路径
      .mockResolvedValueOnce('Test command description'); // 描述

    // 执行add命令(无参数触发交互)
    await program.parseAsync(['node', 'weasel', 'add']);

    // 验证saveConfig是否传入正确参数
    expect(saveConfig).toHaveBeenCalledWith(
      'commands',
      [{ name: 'test-cmd', path: '/test/path', description: 'Test command description' }],
      'name'
    );

    // 验证成功提示被调用
    expect(showMessageLog).toHaveBeenCalledWith(
      { success: 'Command added successfully' },
      { exit: true, exitNumber: 1 }
    );
  });

  test('使用传入的参数直接完成添加(不触发交互)', async () => {
    // 执行带参数的add命令
    await program.parseAsync([
      'node', 'weasel', 'add', 'direct-cmd', '/direct/path', '-d', 'Direct command desc'
    ]);

    // 验证saveConfig参数
    expect(saveConfig).toHaveBeenCalledWith(
      'commands',
      [{ name: 'direct-cmd', path: '/direct/path', description: 'Direct command desc' }],
      'name'
    );
  });

  test('命令名重复时输出错误提示', async () => {
    // 设置已存在的命令
    customCommands = [{ name: 'existing-cmd', path: '/' }];

    // 执行add命令传入重复名称
    await program.parseAsync(['node', 'weasel', 'add', 'existing-cmd']);

    // 验证错误提示被调用
    expect(showMessageLog).toHaveBeenCalledWith(
      { error: 'Command name existing-cmd already exists. Please choose a different name.' },
      { exit: true, exitNumber: 1 }
    );

    // 验证saveConfig未被调用(因为提前退出)
    expect(saveConfig).not.toHaveBeenCalled();
  });
});

关键要点

  1. 依赖隔离:用vi.mock模拟saveConfig、showMessageLog和@inquirer/input,避免测试时修改真实文件或输出内容。
  2. 状态重置:通过beforeEach在每次测试前重置Commander实例和customCommands,防止测试间的状态污染。
  3. 两种测试方式:
    • 直接mockinput返回预设值,快速验证业务逻辑的正确性。
    • 若需验证提示内容/校验规则,可结合@inquirer/testing的render方法单独测试单个提示:
      import { render } from '@inquirer/testing';
      
      test('命令名称提示的校验规则', async () => {
        const { events, getScreen } = await render(input, {
          message: 'Enter the command name',
          validate: (value) => {
            if (value.includes(' ')) return 'Command name cannot contain spaces';
            return true;
          },
        });
      
        // 输入带空格的名称,验证错误提示
        events.type('test cmd');
        expect(await getScreen()).toContain('Command name cannot contain spaces');
      
        // 输入合法名称,验证通过
        events.clear();
        events.type('test-cmd');
        events.keypress('enter');
        expect(await getScreen().answer).toBe('test-cmd');
      });
      
  4. 行为验证:通过expect断言关键函数的调用情况(参数、次数),确保命令逻辑符合预期。

内容的提问来源于stack exchange,提问作者Jonathan Sigg

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 04:05:56