首页
/ shadcn/ui tests 包实战指南:用本地 Registry 构建 CLI 集成测试的完整方案

shadcn/ui tests 包实战指南:用本地 Registry 构建 CLI 集成测试的完整方案

2026-09-04 15:07:28作者:侯霆垣

shadcn/ui 仓库中的 packages/tests 是一组面向 shadcn CLI 的集成测试:它把真实 CLI 可执行文件跑在本地 registry 上,针对 Next.js、Vite、Remix 等项目模板做端到端验证,确保 initaddapply 等命令生成的文件与配置符合预期。读完本篇,你将理解这套测试从 fixture 拷贝、子进程调用 CLI、到断言 components.json 与 CSS 输出的完整链路,并能按同样的模式为 CLI 新增集成测试。

一、集成测试包的定位

根据 packages/tests/README.md 的说明,该包的目标是:

This package contains integration tests that verify the shadcn CLI works correctly with a local registry. The tests run actual CLI commands against test fixtures to ensure files are created and updated properly.

也就是说,它测的不是某个纯函数,而是**“CLI 二进制 + 本地 registry 服务 + 目标项目脚手架”三方协作后的磁盘结果**。从 packages/tests/package.json 可以看到其依赖结构印证了这一定位:

  • "shadcn": "workspace:*":直接以 workspace 依赖引用本仓库的 CLI 包,保证测试跑的是当前源码构建产物;
  • execa:以子进程方式调用 CLI;
  • fs-extra:fixture 拷贝、文件断言;
  • rimraf:清理临时测试目录;
  • vitest + vite-tsconfig-paths:测试运行器与 TS 路径解析。

测试用例按 CLI 命令划分为七个文件,位于 packages/tests/src/tests/add.test.tsapply.test.tsinit.test.tsregistries.test.tssearch.test.tstypeset.test.tsview.test.ts,与 CLI 的核心子命令一一对应。

二、运行测试:命令与前置条件

README 给出的官方运行方式是(在 workspace 根目录执行):

pnpm tests:test

而从仓库根目录 package.json 的实际编排来看,完整的本地验证流程由 test 脚本串联:

"test": "pnpm --filter=v4 registry:build && start-server-and-test v4:dev http://localhost:4000 test:dev",
"test:dev": "turbo run test --force"

可以拆解出三个关键前提:

  1. 先构建 registry 产物pnpm --filter=v4 registry:build,把 apps/v4 的 registry 数据构建出来;
  2. 启动 v4 开发服务器start-server-and-test v4:dev http://localhost:4000 ... 会等待 http://localhost:4000 就绪后再执行测试,这也就是测试默认 registry 地址 http://localhost:4000/r 的来源(见下文 getRegistryUrl);
  3. turbo 调度turbo run test --force 触发各包 test 任务;turbo.jsontest 任务声明了 "dependsOn": ["^build"]"cache": false,即测试始终依赖上游构建产物且不命中缓存。

另一个隐含前提是 CLI 本体已构建:setup.ts 会显式等待 packages/shadcn/dist/index.js 存在(细节见第五节)。

三、核心测试工具:fixtures 与子进程调用

所有测试共用 packages/tests/src/utils/helpers.ts 中的三个基础设施。

3.1 从 fixture 创建隔离测试目录

createFixtureTestDirectory 的逻辑是:把 packages/tests/fixtures/ 下的指定 fixture 整体复制到一个带唯一后缀的临时目录,避免用例之间互相污染:

const FIXTURES_DIR = path.join(__dirname, "../../fixtures")
const SHADCN_CLI_PATH = path.join(__dirname, "../../../shadcn/dist/index.js")
const TEMPLATES_DIR = path.join(__dirname, "../../../../templates")

export async function createFixtureTestDirectory(fixtureName: string) {
  const fixturePath = path.join(FIXTURES_DIR, fixtureName)
  const uniqueId = `${process.pid}-${randomUUID().substring(0, 8)}`
  let testDir = path.join(TEMP_DIR, `test-${uniqueId}-${fixtureName}`)

  await fs.ensureDir(testDir)
  await fs.copy(fixturePath, testDir)

  return testDir
}

当前可用的 fixture 覆盖了主流框架形态(见 packages/tests/fixtures/):

Fixture 覆盖场景
next-app 标准 Next.js 项目(app/ 目录结构)
next-app-imports / next-app-init 已带 src/ 导入风格或已 init 过的 Next.js 项目
vite-app / vite-monorepo-imports Vite 单包与 monorepo 形态(含 pnpm-workspace.yaml
remix-app 未被模板覆盖的框架,用于验证降级路径
no-framework 空目录边界场景
registry/*.json 自定义 registry 条目样本,如 example-item.jsonexample-style.jsonexample-target-aliases.json

3.2 以子进程运行真实 CLI

runCommand 是全部 CLI 调用的底层通道,它用 execanode packages/shadcn/dist/index.js ... 的方式启动 CLI,并对环境做了刻意约束:

const childProcess = execa("node", [SHADCN_CLI_PATH, ...args], {
  cwd,
  env: {
    ...process.env,
    FORCE_COLOR: "0",   // 关闭 ANSI 颜色,便于对 stdout 做字符串断言
    CI: "true",          // 模拟 CI 行为
    ...options?.env,
  },
  input: options?.input,        // 支持注入交互输入
  reject: false,                // 失败不抛异常
  timeout: options?.timeout ?? 60000,  // 默认 60s 超时
})

配合 reject: false,调用方拿到统一的 { stdout, stderr, exitCode } 结构,因此测试既能断言成功路径,也能断言失败路径(如非法模板名返回 exitCode === 1 且 stdout 含 "Invalid template")。

外层封装 npxShadcn 则负责注入两个关键环境变量:

export async function npxShadcn(cwd, args, { debug = false, input, timeout } = {}) {
  const result = await runCommand(cwd, args, {
    env: {
      REGISTRY_URL: getRegistryUrl(),
      SHADCN_TEMPLATE_DIR: TEMPLATES_DIR,
    },
    input,
    timeout,
  })
  if (debug) console.log(result)
  return result
}

其中 getRegistryUrl() 默认返回 http://localhost:4000/r,可用 REGISTRY_URL 环境变量覆盖;SHADCN_TEMPLATE_DIR 指向仓库根 templates/ 目录——这正是 CLI 创建新项目(init --name xxx)时所用脚手架的来源,与根目录 templates/next-apptemplates/vite-app 等模板目录一一对应。

此外 helpers 还导出 cssHasProperties(cssContent, checks):对 CSS 文本按选择器提取代码块,逐一校验 property: value; 是否存在,是断言主题变量落地情况的利器(init 测试中大量使用,见第六节)。

四、测试夹具全景:fixtures 与 registry 样本

如第三节所述,packages/tests/fixtures/ 是测试的“被测项目库”。每个 fixture 都是一个精简但可被 CLI 识别的真实项目骨架,例如:

  • packages/tests/fixtures/next-app/app/globals.cssapp/layout.tsxnext.config.tspackage.jsonpostcss.config.mjstsconfig.json,模拟一个干净的 Next.js + Tailwind 项目,是 init 测试的主力夹具;
  • packages/tests/fixtures/vite-monorepo-imports/ 则带有 pnpm-workspace.yamlapps/webpackages/ui 两级结构,用于验证 monorepo 下 components.json 与 workspace 别名(@workspace/ui/...)的处理;
  • packages/tests/fixtures/registry/ 中的 10 个 JSON 样本对应自定义 registry 的各类条目:example-at-property.jsonexample-component.jsonexample-env-vars.jsonexample-item-to-root.jsonexample-style.jsonexample-target-alias-child.json 等,供 registries.test.ts 验证远端 registry 项的解析与落地。

这种“fixture 即场景”的划分,让每个 describe 块可以明确声明自己测的是哪种框架形态。

五、vitest 配置与全局预热:解决慢冷启动问题

packages/tests/vitest.config.ts 的配置值得逐条理解:

test: {
  testTimeout: 120000,
  hookTimeout: 120000,
  globals: true,
  environment: "node",
  globalSetup: ["./src/utils/setup.ts"],
  maxConcurrency: 4,
  isolate: false,
}
  • testTimeout/hookTimeout 均放宽到 120s:单个用例要完整跑一遍 CLI 子进程;
  • maxConcurrency: 4 + isolate: false:控制并发并复用同一进程,因为每个用例都在独立临时目录工作,不需要沙箱隔离。

真正体现工程功力的是全局设置 packages/tests/src/utils/setup.ts。它在所有测试开始前做三件事,全部是为了对抗 Next.js dev server 的冷编译延迟:

  1. 等待 CLI 二进制就绪:v4 的 dev 脚本会在后台构建 shadcn 包,CI 上可能出现“服务器已起但 packages/shadcn/dist/index.js 尚未构建完”的竞态,因此 waitForCondition 会轮询该路径(最长 60s);
  2. 预热 /init 动态路由:CLI 第一个请求打到 /init 路由,冷 dev server 首次访问需现场编译(注释中实测约 1.8s),叠加 init 本身的工作量会击穿 CLI 的 30s 超时,所以 setup 主动请求一次完整的 init URL;
  3. 预触发一次 404:CLI 会拉取可能不存在的 registry 路径(如字体文件),首次 404 会触发 Next.js 编译 /_not-found/page(约 4–5s),setup 通过请求一个不存在的 font-geist.json 提前消化掉这次编译。

清理阶段则用 rimraf 删除 TEMP_DIR(即 packages/tests/temp/),保证测试可重复运行。

六、内存 Registry:在本地模拟远端 registry 的全部端点

要测试“从自定义 registry 拉取资源”,不必依赖外网。packages/tests/src/utils/registry.tscreateRegistryServer 用 Node 原生 http 起一个默认监听 4444 端口、路径前缀 /r 的迷你 registry 服务,端点行为如下:

  • /r/registries.json:返回空数组 [](刻意留空,让测试验证 components.json 中的手动 registry 配置生效);
  • /r/icons/index:返回 lucide/radix 图标映射表;
  • /r/colors/neutral:返回 inlineColorscssVarscssVarsV4(oklch 色值)及 inlineColorsTemplate/cssVarsTemplate,模拟真实的颜色端点;
  • /r/styles/index/r/styles/{style}/index:分别返回风格列表(new-york、default)与风格索引项;
  • /r/index:返回 alert-dialogbutton 两个 registry:ui 条目(内联文件内容);
  • /r/registry 及任意 /r/{item} 路径:按传入的 items 返回条目,并对鉴权路径做严格校验:
    • /bearer/:校验 Authorization 头,token 必须为 EXAMPLE_BEARER_TOKEN,否则 401;
    • /api-key/:校验 x-api-key 头(EXAMPLE_API_KEY);
    • /client-secret/:校验 x-client-secret + x-client-id 头;
    • /params/:校验 URL 查询参数 token(EXAMPLE_REGISTRY_TOKEN)。

这组 401 用例让 registries.test.ts 可以在本机穷举 CLI 对四种鉴权方式(bearer / api-key / client-secret / query 参数)的处理逻辑。配套的 configureRegistries(fixturePath, payload) 工具则负责向 fixture 的 components.json 写入/更新 registries 字段,模拟用户在项目中登记私有 registry 的操作。

七、用例示例:init 测试如何断言端到端结果

packages/tests/src/tests/init.test.ts 是最能体现测试风格的文件,它覆盖 next-app、vite-app、自定义 style、不支持的框架、模板标志、--name 建项目、monorepo、RTL、--force 等场景。挑几个代表性用例说明断言层次:

默认配置落盘:对 next-app fixture 执行 init --defaults 后,逐层验证——

const componentsJson = await fs.readJson(componentsJsonPath)
expect(componentsJson).toMatchObject({
  style: "base-nova",
  rsc: true,
  tsx: true,
  tailwind: { config: "", css: "app/globals.css", baseColor: "neutral", cssVariables: true },
  aliases: { components: "@/components", utils: "@/lib/utils", ui: "@/components/ui", lib: "@/lib", hooks: "@/hooks" },
})
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(true)

再对 app/globals.css 内容做字符串断言(@layer base:root.darktw-animate-css--background--foreground)。

CSS 变量精检:对自定义 style registry(在 4445 端口起 createRegistryServer,提供 stylestyle-extendedstyle-extend-none 三个条目),测试用 cssHasProperties 精确校验 @theme inline:root.dark 三个选择器块内的每一个变量值,例如验证 style 继承链中 --foo-var 是否按“子覆盖父”合并为 2rem——这是对 registry style extends 语义的白盒级验证。

别名改写:vite-app fixture 使用 #custom/... 导入别名,测试断言 init --defaults alert-dialog 后,src/components/ui/alert-dialog.tsx 内的 import 语句已被改写为 import { Button } from "#custom/components/ui/button",验证 CLI 能尊重项目已有别名约定。

失败与边界路径

  • -t invalidexitCode 为 1,stdout 含 Invalid template
  • 已弃用的 --src-dir → 以未知选项拒绝(exit 1);
  • --name 新建项目时特意把目录建在 os.tmpdir() 下,代码注释说明原因:“This prevents pnpm from detecting the monorepo workspace root”
  • 已存在 components.json 时,init 失败会回滚备份:测试验证报错后原配置原样恢复且不留 .bak--force 重新 init 时用户自定义的 registries 字段被保留。

monorepo 场景(CI 上通过 itIfNotCi 跳过):init --name ... -t next --monorepo --preset nova --base radix 后,断言 packages/ui/components.jsonstyleradix-novaiconLibrarylucide,且 apps/web 的别名被解析为 @workspace/ui/lib/utils 等 workspace 路径,CSS 主题变量写入 packages/ui/src/styles/globals.css

八、按 README 模式编写新的集成测试

README 给出的最小用例骨架如下(注意其中 import 路径相对于测试文件所在位置):

import {
  createFixtureTestDirectory,
  fileExists,
  npxShadcn,
} from "../utils/helpers"

describe("my test suite", () => {
  it("should do something", async () => {
    // Create a test directory from a fixture
    const testDir = await createFixtureTestDirectory("next-app")

    // Run CLI command
    await npxShadcn(testDir, ["init", "--base-color=neutral"])

    // Make assertions
    expect(await fileExists(path.join(testDir, "components.json"))).toBe(true)
  })
})

结合当前源码需要补充三点实操要点:

  1. 断言文件存在时,现有测试普遍直接借助 fs-extraexpect(await fs.pathExists(...)).toBe(true)(见 init.test.ts)。README 示例中的 fileExists 辅助在当前 helpers.ts 中并未导出,写新用例时建议以 fs.pathExists 为准,或自行在 utils 中补齐;
  2. 命令参数风格:现网用例多用 ["init", "--defaults"]["init", "--defaults", "button"] 这类组合,需要交互输入时用 npxShadcn(dir, args, { input: "..." }) 注入;
  3. 自定义 registry:先用 createRegistryServer(items, { port }) 起服务并在 beforeAllstart()afterAllstop(),再让 CLI 指向 http://localhost:{port}/r/xxx.json;需要把该 registry 登记进项目配置时用 configureRegistries(fixturePath, { name: { url: ... } })

新测试文件放在 packages/tests/src/tests/ 下(命名为 xxx.test.ts),vitest 会自动拾取;如果单个用例涉及建项目这类重操作,可参考现有做法将超时提到 120s–300s(通过 npxShadcntimeout 选项,其默认值为 60s)。

九、小结:这套集成测试的设计要点

packages/tests 的实现可以归纳出 shadcn CLI 集成测试的五个设计决策,均可在源码中直接对照:

  1. 测真二进制:通过 workspace:* 依赖 + execa 执行 packages/shadcn/dist/index.js,而非 mock CLI 内部逻辑(helpers.ts);
  2. 环境即数据REGISTRY_URLSHADCN_TEMPLATE_DIR 通过环境变量注入,使同一套测试既能对本地 v4 服务(http://localhost:4000/r)运行,也能指向任意兼容 registry;
  3. 隔离与幂等:每个用例从 fixture 复制到 pid + UUID 命名的独立目录,全局 teardown 用 rimraf 清场(setup.ts);
  4. 对抗冷启动:全局 setup 显式等待 CLI 产物、预热 /init 路由与 404 路由,把 Next.js dev 环境的编译抖动挡在用例之外(setup.ts);
  5. 失败也是一等断言对象reject: false + 统一的 { stdout, stderr, exitCode } 返回结构,让 401 鉴权失败、非法模板、弃用参数等负向路径都能被稳定验证(registry.tsinit.test.ts)。

掌握以上链路后,你既能读懂每个断言背后 CLI 的契约(配置字段、别名改写、CSS 变量合并、鉴权处理),也能按同样模式为新的 CLI 命令补充可重复、可隔离的集成测试。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384