如何在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(); }); });
关键要点
- 依赖隔离:用
vi.mock模拟saveConfig、showMessageLog和@inquirer/input,避免测试时修改真实文件或输出内容。 - 状态重置:通过
beforeEach在每次测试前重置Commander实例和customCommands,防止测试间的状态污染。 - 两种测试方式:
- 直接mock
input返回预设值,快速验证业务逻辑的正确性。 - 若需验证提示内容/校验规则,可结合
@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'); });
- 直接mock
- 行为验证:通过
expect断言关键函数的调用情况(参数、次数),确保命令逻辑符合预期。
内容的提问来源于stack exchange,提问作者Jonathan Sigg
相关产品推荐
相关产品推荐

