首页
/ Angular ng-modules-importability 集成测试:如何守护框架所有 @NgModule 的可导入性

Angular ng-modules-importability 集成测试:如何守护框架所有 @NgModule 的可导入性

2026-09-07 17:16:54作者:羿妍玫Ivan

本文深入讲解 Angular 仓库中 integration/ng-modules-importability 集成测试的工作原理与实现细节:它如何自动扫描框架各包的全部入口点、生成模拟用户代码的 TypeScript 测试文件,并借助 @angular/compiler-cliperformCompilation 在 CI 中验证每一个导出的 @NgModule 符号都能被用户代码成功导入。读完本文,你将掌握该安全网测试的完整执行链路、配置方式,以及如何为新增的 @angular 包扩展这一测试覆盖。

为什么需要这个测试:@NgModule 再导出与相对导入陷阱

README 开门见山地说明了该测试的定位:

This test is a safety check, ensuring that all @NgModule's exported by Angular framework packages can be imported in user code without causing any build errors.

这是一个"安全检查"测试,目标是确保 Angular 框架各包中导出的所有 @NgModule 都能在用户代码中被导入且不引发任何构建错误。README 还点出了它针对的真实问题场景:

  • 一个 @NgModule 有时会重新导出(re-export)另一个模块。这本身是合法的;
  • 但在一些情况下——尤其是使用了相对导入(relative imports)时——消费方项目中的编译器无法为这些被再导出的符号找到可用的导入路径,从而在用户构建时报错。

修复方式在 README 中给出了明确结论:被再导出的符号必须从包的入口点(entry-point)重新导出。README 将其归因为 angular/components#30667 中描述的问题类别。换句话说,该测试守护的是框架包的公共 API 契约——凡是 .d.ts 入口点中声明导出的 *Module,用户都必须能直接 import

作为对比,仓库中 cli-hello-world-* 系列集成测试模仿的是完整的 CLI 应用构建流程(见 integration/README.md),而本测试属于更轻量的"类型级"检查:它不产出产物,只验证编译诊断(diagnostics)是否为零。

测试目录结构与各文件职责

测试目录 integration/ng-modules-importability 结构非常精简:

文件 职责
README.md 说明测试目的与再导出问题背景
index.mts 测试主程序:分片、生成测试文件、调用编译器、收集诊断
find-all-modules.mts 扫描包 package.jsonexports.d.ts,提取全部导出的 Module 符号
index.bzl 定义 Bazel 规则 module_test,封装配置生成与 js_test
BUILD.bazel 声明 test_lib(编译工具库)与具体测试目标,列出被测包
tsconfig.json 测试工具自身(.mts 文件)的 TS 配置

下面沿执行顺序逐一展开。

第一步:扫描包入口点并提取所有导出的 Module 符号

find-all-modules.mts 导出的核心函数是 findAllEntryPointsAndExportedModules(packagePath),它的输入是包目录路径,输出是 {name, packagePath, moduleExports}。其工作流程为:

  1. 解析 package.json:读取包的 package.json,重点是其 exports 字段(Node 子路径导出映射)。
  2. 过滤真实入口点
    • 跳过没有 types 条件的子路径(无法定位类型声明文件);
    • 跳过 types 值中包含 * 的通配条件(如 common/locales),注释明确说明"通配条件不是入口点"。
  3. 解析 .d.ts 声明文件:对每个入口点,用 ts.createSourceFileESNext 目标构建 AST,然后调用 scanExportsForModules 遍历。
  4. 提取 Module 符号名scanExportsForModules 只关注形如 export { A, B, C } from '...' 的具名导出声明(ExportDeclaration + NamedExports),并过滤出:
    • 符号名以 Module 结尾;
    • 首字母为大写(e.name.text[0].toLowerCase() !== e.name.text[0])。
  5. 组装结果:每个符号映射为 {importPath, symbolName},其中 importPathpath.posix.join(packageJson.name, subpath) 得到——即用户视角的完整导入路径,例如 @angular/router@angular/forms 的子路径。

这里的关键设计是:.d.ts 为准而非源码。这保证了测试视角与真实用户一致——用户拿到的就是编译后的类型声明文件,测试验证的也正是这份声明里"承诺"导出的符号。

第二步:Bazel 规则 module_test 与测试配置

index.bzl 中的 module_test 宏接收两类关键参数:

module_test(
    name = "test",
    npm_packages = {
        "//packages/core:npm_package": "packages/core/npm_package",
        # ...其余包
    },
    shard_count = 4,
    skipped_entry_points = [
        # Core does not expose any modules and just needs to be made available.
        "@angular/core",
    ],
)
  • npm_packages:一个从 Bazel 目标到包 runfiles 路径的映射。宏会把路径部分作为 packages 写入配置文件,把目标部分加入测试的 data 依赖,使沙箱内可访问这些包的构建产物。
  • skipped_entry_points:需要跳过验证的入口点列表。BUILD.bazel 中只跳过了 @angular/core,注释解释了原因:@angular/core 本身不暴露任何 @NgModule,但它必须"可用"(因为生成的测试文件要 import {NgModule, Component} from '@angular/core')。

module_test 宏内部做了两件事:

  1. 用 bazel_skylib 的 write_file 生成 <name>_config.json,内容为 {"packages": [...], "skipEntryPoints": [...]},并通过 fixed_args 把配置路径传给测试入口;
  2. 创建 js_test 目标(加载自 tools/defaults.bzl),entry_point 指向 //integration/ng-modules-importability:index.mjsdata 依赖配置、test_lib 和被测包产物。

当前测试覆盖的包列表(共 10 个)包括 animationscommoncoreelementsformslocalizeplatform-browserrouterservice-workerupgrade。若要为新包补充覆盖,只需在 npm_packages 字典中加入对应的 //packages/<name>:npm_package 条目。

第三步:测试主程序如何模拟"用户代码"

index.mtsmain() 是测试的真正执行体,可分为五个阶段。

3.1 声明支持 Bazel 分片并切分工作量

程序启动后先 touch TEST_SHARD_STATUS_FILE,以此向 Bazel 宣告该测试支持分片(sharding)。随后按 Bazel 测试百科定义的初始条件环境变量(TEST_SHARD_INDEX / TEST_TOTAL_SHARDS)把全部待验证符号切成连续分片:

const testChunkSize = Math.ceil(allExports.length / testMaxShards);
const testChunkStart = testChunkSize * testShardIndex;
const shardExports = allExports.slice(testChunkStart, testChunkStart + testChunkSize);

配合 BUILD.bazel 中的 shard_count = 4,每个 shard 只负责约 1/4 的模块,从而把总编译时间摊平到 4 个并行进程。

3.2 在临时目录中生成每个符号一个测试文件

对分片内的每个 {importPath, symbolName},程序生成如下测试文件(文件名取符号名小写,如 commonformmodule.ts):

import {NgModule, Component} from '@angular/core';
import {CommonFormModule} from '@angular/common';

@NgModule({
  exports: [CommonFormModule]
})
export class TestModule {}

@Component({imports: [TestModule], template: ''})
export class TestComponent {}

注意这段代码刻意构造了双重导入路径:

  • import {CommonFormModule} from '@angular/common' —— 验证符号可以从用户会写的导入路径解析到;
  • @NgModule({exports: [CommonFormModule]}) + @Component({imports: [TestModule]}) —— 让 Angular 编译器真正把该 Module 纳入编译图,而不只是一个裸的类型导入。这正是 README 所针对的场景:再导出的模块只有在真正被"使用"时,才能暴露出导入解析问题。

3.3 用符号链接伪造用户项目的 node_modules

测试在 os.tmpdir() 下创建临时目录,并做两处 symlink:

  • tmp/node_modules → 仓库 integration/node_modules,用于解析三方依赖(如 typescript);
  • tmp/test/node_modules/@angular/<name> → 每个被测包的 runfiles 路径(如 packages/common/npm_package),用于解析 @angular/common 等第一方包。

代码注释特别说明:因为测试运行在 Bazel 沙箱内的 runfiles 目录中,第一方包的相对路径解析才能成立。这个"用户目录 + 本地构建产物"的组合,正是模拟真实用户"从 npm 安装最新版 Angular 并写代码"的环境。

3.4 调用 performCompilation 做全量编译验证

每个测试文件单独调用 packages/compiler-cliperformCompilation(定义于 perform_compile.ts),关键选项包括:

options: {
  rootDir: tmpDir,
  skipLibCheck: true,
  noEmit: true,
  module: ts.ModuleKind.ESNext,
  moduleResolution: ts.ModuleResolutionKind.Bundler,
  preserveSymlinks: true,
  _enableHmr: true,
}

各选项的作用:

  • noEmit + skipLibCheck:只关心诊断,不产出文件,也不检查库文件——聚焦于"用户代码能否编译"这一契约;
  • module: ESNext + moduleResolution: Bundler:贴近现代构建工具链(Bundler 式解析,与 tsconfig.json 中测试工具自身使用的解析模式一致);
  • preserveSymlinks: true:不解析 symlink 到真实路径,保证 @angular/* 始终按 runfiles 中的相对位置解析;
  • _enableHmr: true:这是最微妙的一项。源码注释解释:HMR 模式会禁用 Angular 编译器对"未使用 directive/component"的树摇(tree-shaking)。对本测试至关重要——只有关闭该优化,编译器才会强制解析每一个被列进 @NgModule({exports}) 的符号,从而自动验证所有符号"可达且可导入"。若开了树摇,未被模板实际使用的模块可能被优化掉,测试就会失去守护力。

3.5 汇总诊断并决定成败

所有测试文件的诊断被收集后,用 ts.formatDiagnosticsWithColorAndContext 打印到 stderr;只要 diagnostics.length > 0,就将 process.exitCode 置为 1,Bazel 据此标记测试失败。临时目录最后会被清理。

也就是说,任何一次框架改动——无论是 package.jsonexports 结构调整、.d.ts 入口点的导出变化,还是把某个再导出符号的声明路径改错——只要导致任何一个 @NgModule 从任何入口点导入失败,这个测试都会以带上下文的 TS/Angular 诊断精确定位到出问题的包与符号。

如何运行与扩展该测试

本地运行

该测试是 Bazel js_test 目标,仓库根目录下可按 integration/README.md 推荐的方式运行,并建议用 --local_test_jobs 限制并发以避免资源打满:

pnpm bazel test //integration/ng-modules-importability:test --test_output=streamed --cache_test_results=no

由于 shard_count = 4,Bazel 会拆出 4 个 shard 并行执行,每个 shard 处理约四分之一的模块。

扩展覆盖清单

  • 新增被测包:在 BUILD.bazelnpm_packages 中加入 //packages/<pkg>:npm_package 映射即可,无需改任何脚本——find-all-modules.mts 会自动发现新包 package.json 中所有带 types 条件的入口点;
  • 跳过某入口点:把完整导入路径加入 skipped_entry_points 列表(如 @angular/core 的做法);
  • 调整并行度:修改 shard_count

小结

ng-modules-importability 是 Angular 框架包公共 API 的一道类型级安全网:以 .d.ts 入口点声明为契约来源(find-all-modules.mts),用"用户视角"的模拟代码与 symlink 化的 node_modules 还原真实消费环境,再借 performCompilation_enableHmr: true 关闭树摇,强制每个导出的 @NgModule 都必须可解析、可导入。它把 README 中指出的"再导出符号需从入口点导出"这一约定,从人工经验固化成了 CI 中可分片、可增量扩展的自动化检查,也是理解 Angular 如何保障自身发布包"用户可导入性"契约的一个完整样本。

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