EN

测试

根据命令、CLI 进程和终端交互的测试目标,选择对应的 Func 测试工具。

更新于

测试是命令行项目中非常重要的环节,自动化测试设施能够帮助用户模拟各类交互环境、参数解析、进程行为,确保分发后的命令行工具始终具备预期中的软件质量。

func/testing 为我们预制了多种测试接入能力与模板,无需使用额外接口或编写环境垫片,直接引用真实的 app.module 即可完成测试。

选择测试类型

  • 命令功能测试:使用 func/testing 在当前测试进程中调用完整命令,针对命令分发、参数解析、Provider、输出和错误处理。它不要求先构建项目,也是日常覆盖命令行为的默认选择。
  • CLI 进程测试:使用 func/testing/cli 启动已构建的 package bin,针对真实进程边界、启动环境、工作目录、退出码和信号。通常用于验证构建后的产物或子进程。
  • 终端交互测试:通过 func/testing/pty 对接项目选择的 PTY 工具,针对提示符、TTY 检测、终端尺寸、resize 和其他交互行为。普通 stdout、stderr pipe 能覆盖的场景不需要 PTY。

func/testing/fixtures 是可组合的项目 fixture 工具,不限定测试类型。命令功能测试或 CLI 进程测试需要真实文件读写、代码生成或独立工作目录时,都可以用它创建临时项目。Provider override 同样是一种隔离依赖的手段,而不是单独的测试类型。

纯函数、单个 Service、校验器和格式化逻辑等与 func 框架无关的纯逻辑仍然可以直接用 Vitest 编写单元测试,func 不为这些不经过命令入口的测试增加额外封装。

添加第一个命令功能测试

tests/commands 中新建测试文件,使用根模块创建测试应用,传入用户在可执行文件名之后输入的参数,然后断言退出码和输出:

tests/commands/greet-name.test.ts
import { createTestingApp } from 'func/testing'
import { expect, test } from 'vitest'
import { AppModule } from '../../src/app.module'

const app = createTestingApp(AppModule)

test('greet should support a name', async () => {
  const result = await app.invoke(['greet', '--name', 'Ada'])

  expect(result.exitCode).toBe(0)
  expect(result.stdout).toContain('Hello, Ada!')
  expect(result.stderr).toBe('')
})

运行模板的 test script:

终端
npm test

这个测试不会构建项目或启动子进程,因此执行速度快,适合覆盖绝大多数命令行为。每次 app.invoke() 都会创建独立的调用上下文,可以继续添加不同的参数组合、错误输入和输出断言。

编写命令功能测试

尽管 invoke 会自动隔离创建新的容器,但 createTestingApp 仍旧只会编译一次 Module,这意味着编译开销在测试阶段不会出现多次,这能够确保用户能够以高性能的方式同时运行大量测试。

tests/deploy.test.ts
import { createTestingApp } from 'func/testing'
import { AppModule } from '../src/app.module'

const app = createTestingApp(AppModule)
const result = await app.invoke(['deploy', '--env', 'test'], {
  cwd: '/workspace',
  env: { ...process.env, NODE_ENV: 'test' },
  stdin: 'yes',
})

expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('deployed')
expect(result.stderr).toBe('')
expect(result.error).toBeUndefined()

invoke 的第一个参数是省略可执行文件名后的 argv。第二个参数控制本次调用:

选项用途
cwd设置本次调用的 Context.cwd,不会调用 process.chdir
env设置完整的 Context.env,不会自动继承 process.env
stdin作为本次调用的标准输入。
signal向命令传递 AbortSignal

进程环境安全

默认执行测试时,不会自动捕获全局进程等信息,Invoker 和 CLI runner 的默认环境都是空对象:

  • env:空对象 {},不继承 process.env
  • argv:空数组 [],不读取 process.argv
  • cwd:读取调用时的 process.cwd(),但不会执行 process.chdir()
  • stdin:空字符串;
  • stdout / stderr:分别捕获到当前测试结果;
  • signal:为当前调用创建独立的未中止信号;
  • process.exitCode:不读取或修改,退出码只返回在 result.exitCode

如果在测试时需要依赖PATHHOME、CI 变量或凭据时,应在 env 中传入最小环境,

tests/deploy.test.ts
const result = await app.invoke(['deploy'], {
  env: {
    ...process.env,
    NODE_ENV: 'test',
  },
})

传入的环境会在调用开始时被规范化并冻结,因此之后修改 process.env 不会影响正在执行的测试。全局 console.logconsole.error 也不会被捕获,只有通过 Context.io@Stdout()@Stderr() 写入的内容才会出现在测试结果中。

invoke 始终返回 { exitCode, result, stdout, stderr, error }。已被 onError 处理的异常不会出现在 error 中,调用也不会修改测试进程的 process.exitCode

测试模拟

覆盖依赖

如果一个依赖服务与副作用、业务关键信息、服务端鉴权等强相关,容易阻塞测试,我们可以使用独立的服务组件进行模拟。

使用 overrides 把真实依赖替换为 fake、stub 或测试值:

tests/deploy.test.ts
import { createTestingApp } from 'func/testing'
import { AppModule } from '../src/app.module'
import { FakeRegistryService, RegistryService } from './registry.fixture'

const app = createTestingApp(AppModule, {
  appName: 'ship',
  overrides: [
    {
      provide: RegistryService,
      useClass: FakeRegistryService,
    },
  ],
})

appNamecreateApp 使用相同的应用身份;测试 Config、Log 等依赖应用目录的能力时需要设置。useClassuseFactory 随每次调用创建。

useValue 则始终使用调用者传入的同一个对象,可变测试对象若需要逐次隔离,应改用 useFactory

当同一个 token 在多个 Module 中注册时,通过 { module, provider } 指定目标:

tests/deploy.test.ts
const app = createTestingApp(AppModule, {
  overrides: [
    {
      module: DeployModule,
      provider: {
        provide: RegistryService,
        useClass: FakeRegistryService,
      },
    },
  ],
})

使用项目 fixture

func/testing/fixtures 创建独立临时目录,按相对路径写入初始文件,并在回调结束后清理:

tests/project.test.ts
import { withProjectFixture } from 'func/testing/fixtures'

await withProjectFixture(
  {
    files: {
      'package.json': JSON.stringify({ name: 'demo' }),
      'src/config.json': JSON.stringify({ region: 'test' }),
    },
  },
  async project => {
    expect(await project.read('src/config.json')).toContain('test')
    await project.write('generated/result.txt', 'ok')
  },
)

fixture 提供 pathresolvereadwriteexists 和幂等的 cleanup。所有路径都限制在 fixture 根目录内。

  • withProjectFixture:适合在单个测试或回调中使用。回调结束后会自动删除临时目录。适合大部分项目的测试环境。
  • createProjectFixture:适合需要在 beforeEach 中创建,并在多个测试钩子或测试阶段之间共享 fixture 的场景。由于它不会自动确定何时用完,必须在 afterEach 中调用 cleanup()。

CLI 进程测试

func/testing/clipackage.json#bin 定位已构建入口,使用当前 Node 可执行文件启动子进程,并捕获 stdout、stderr、退出码和信号:

tests/cli.test.ts
import { resolve } from 'node:path'
import { createCliRunner } from 'func/testing/cli'

const packageRoot = resolve(import.meta.dirname, '..')
const cli = createCliRunner({ packageRoot })
const result = await cli.run(['deploy'], {
  cwd: packageRoot,
  env: { ...process.env, NODE_ENV: 'test' },
  stdin: 'yes',
})

expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('deployed')

Runner 不会自动构建项目,因此要顺利运行进程测试还需要你预先编译项目。

建议在独立的 CLI 测试脚本中先构建,再覆盖 package bin、进程退出、启动环境和真实文件系统集成。多 bin package 通过 binName 指定入口,也可以直接传 bin

终端交互测试

PTY(Pseudo Terminal,伪终端)测试用于验证程序在“真实终端环境”中的交互行为,而不只是检查 stdout 输出文本。主要用于高级命令行项目验证交互提示、键盘操作、界面排版、光标移动等行为。

大部分命令行项目不需要额外的 PTY 测试,只有涉及提示符、TTY 判断、动态界面或键盘交互时,PTY 测试才有必要。func 不自动下载和启动 PTY (这通常是较大的额外软件),只提供对应接口。用户可根据项目需求与安装策略自行使用 Python ptynode-pty 或 ConPTY 实现测试。

func/testing/pty 仅导出 PtyAdapterPtyProcessPtySpawnOptionsPtyExitEventPtyDisposable 数据接口。进程生命周期、输出聚合、等待提示符和清理由项目自己的测试工具负责。

API 速查

入口用途主要 API
func/testing命令功能测试createTestingAppTestingApp.invoke、Provider 覆盖。
func/testing/fixtures项目 fixturecreateProjectFixturewithProjectFixture
func/testing/cliCLI 进程测试createCliRunnerCliRunner.run
func/testing/pty终端交互测试仅提供 backend-neutral 的 PTY 适配数据接口。