首页
/ Angular 属性指令测试全攻略:从组件内集成测试到宿主组件隔离测试

Angular 属性指令测试全攻略:从组件内集成测试到宿主组件隔离测试

2026-09-06 19:21:35作者:曹令琨Iris

属性指令(Attribute Directive)通过宿主元素上的属性来修改元素、组件或其他指令的行为,是 Angular 中最常见的 DOM 增强手段之一。要验证这类指令真正生效,往往需要把指令渲染到真实组件模板中再断言 DOM 效果。本文以官方文档中典型的 Highlight(高亮背景色)指令为贯穿示例,系统讲解测试属性指令的两条主路径——在真实使用组件中做集成测试借助人工宿主测试组件(test host)做隔离测试,并深入 DebugElement/By.directive 等底层 API 的原理与使用技巧,让读者既能覆盖单条用例,也能低成本、低脆弱性地覆盖指令的全部用法。

属性指令的测试目标:行为绑定在 DOM 上

属性指令指南》中介绍过,属性指令的名字来源于它的应用方式——作为宿主元素上的一个属性存在。因此,属性指令的核心测试点不是它的类方法返回值,而是它施加在宿主元素上的最终 DOM 效果(样式、属性、事件监听等)。

先看本文的主角 Highlight 指令。它根据一个数据绑定的颜色值或默认颜色(浅灰 lightgray)设置元素背景色;同时按文档注释描述,它还会给元素设置一个自定义属性 customPropertytrue,目的只是演示"指令可以这样做":

import {Directive, inject, input} from '@angular/core';

/**
 * Set backgroundColor for the attached element to highlight color
 * and set the element's customProperty attribute to true
 */
@Directive({
  selector: '[highlight]',
  host: {
    '[style.backgroundColor]': 'bgColor() || defaultColor',
  },
})
export class Highlight {
  readonly defaultColor = 'rgb(211, 211, 211)'; // lightgray

  readonly bgColor = input('', {alias: 'highlight'});
}

这个实现有三个值得注意的要点,它们直接决定后续怎么写测试:

  • 选择器 [highlight]:指令以属性形式挂载,凡带有 highlight 属性的元素都会获得该指令实例;
  • 信号输入 bgColor = input('', {alias: 'highlight'})bgColor 是 Angular signal 输入,别名 highlight 使 <h2 highlight="skyblue"> 这样的静态属性写法也能把值注入输入;|| defaultColor 保证空值时回退到浅灰默认色;
  • 宿主绑定 '[style.backgroundColor]':指令实际改动的宿主样式在 @Directivehost 元数据里声明,测试时只需断言元素的 style.backgroundColor

在真实组件中使用:About 组件示例

指令在整个应用中反复被使用,最简洁的场景是 About 组件:

@Component({
  imports: [Twain, Highlight],
  template: `
    <h2 highlight="skyblue">About</h2>
    <h3>Quote of the day:</h3>
    <twain-quote />
  `,
})
export class About {}

About 自身的模板里混着嵌套组件(<twain-quote />)。测试这种"指令嵌在真实组件里"的场景时,还需要处理嵌套组件带来的 TestBed 配置负担,具体可参考《组件测试场景》中的 Nested component tests(含 CUSTOM_ELEMENTS_SCHEMANO_ERRORS_SCHEMA 与桩组件 stub 两种削减配置的手法)以及《组件测试基础》

测试 HighlightAbout 中的这次具体使用,只需要前面章节的技术即可:

let fixture: ComponentFixture<About>;

beforeEach(async () => {
  TestBed.configureTestingModule({
    providers: [TwainService, UserService],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
  });
  fixture = TestBed.createComponent(About);
  await fixture.whenStable();
});

it('should have skyblue <h2>', () => {
  const h2: HTMLElement = fixture.nativeElement.querySelector('h2');
  const bgColor = h2.style.backgroundColor;
  expect(bgColor).toBe('skyblue');
});

CUSTOM_ELEMENTS_SCHEMATestBed 容忍模板里未声明/不认识的元素标签;await fixture.whenStable() 等待组件初始化与信号变更稳定后再断言(whenStable 的完整语义参见《组件测试场景》)。

为什么不能只依赖"使用方组件"的测试

上面的测试只覆盖了 highlight="skyblue" 一种静态写法。但属性指令的能力范围通常更广:默认色回退、动态输入绑定、作用于非标题元素、多个元素同时挂载……要把所有用法的覆盖做完整,有两条朴素的路线,但都有明显缺陷:

  1. 手工找出所有使用了该指令的组件并逐一测试——既繁琐又脆弱(brittle),且覆盖面依然难以保证;
  2. 纯类测试(class-only tests)——属性指令这类逻辑高度依赖操作 DOM,隔离的单元测试不触碰 DOM,因而无法让人对指令的实际效果建立信心。

Highlight 这种"值 -> 样式"的指令,唯一可信的验证点就是渲染后宿主元素的真实样式。所以更优的解法是文档中给出的核心策略:

创建一个"人工测试组件"(artificial test component),把指令的所有应用方式都演示出来。

用人工宿主组件演示指令的全部用法

测试组件本身不用有业务含义,它只充当指令的宿主环境。下面的 Test 组件一口气演示了四种挂载形态:

@Component({
  imports: [Highlight],
  template: `
    <h2 highlight="yellow">Something Yellow</h2>
    <h2 highlight>The Default (Gray)</h2>
    <h2>No Highlight</h2>
    <input #box [highlight]="box.value" value="cyan" />
  `,
})
class Test {}

Highlight 指令测试运行效果截图

模板四行分别覆盖:静态值 yellow、无值时的默认色分支、完全没有指令的对照 <h2>、以及绑定到 <input> 输入框当前值的动态分支。关键设计就是 对照 + 动态:既要有"带指令"的元素,也必须有"不带指令"的元素用于验证指令没有副作用到无关元素。

提示:<input> 用例把 Highlight 绑定到输入框里的颜色值文本——初始值是单词 "cyan",因此输入框背景色一开始就应该是 cyan。这一设计让测试可以模拟用户输入后验证指令的响应式更新。

断言全集:逐条解读宿主元素测试

下面是针对该宿主组件的一套完整测试:

let fixture: ComponentFixture<Test>;
let des: DebugElement[]; // the three elements w/ the directive

beforeEach(async () => {
  fixture = TestBed.createComponent(Test);
  await fixture.whenStable();

  // all elements with an attached Highlight
  des = fixture.debugElement.queryAll(By.directive(Highlight));
});

// color tests
it('should have three highlighted elements', () => {
  expect(des.length).toBe(3);
});

it('should color 1st <h2> background "yellow"', () => {
  const bgColor = des[0].nativeElement.style.backgroundColor;
  expect(bgColor).toBe('yellow');
});

it('should color 2nd <h2> background w/ default color', () => {
  const dir = des[1].injector.get(Highlight);
  const bgColor = des[1].nativeElement.style.backgroundColor;
  expect(bgColor).toBe(dir.defaultColor);
});

it('should bind <input> background to value color', async () => {
  // easier to work with nativeElement
  const input = des[2].nativeElement as HTMLInputElement;
  expect(input.style.backgroundColor, 'initial backgroundColor').toBe('cyan');

  input.value = 'green';

  // Dispatch a DOM event so that Angular responds to the input value change.
  input.dispatchEvent(new Event('input'));
  await fixture.whenStable();

  expect(input.style.backgroundColor, 'changed backgroundColor').toBe('green');
});

it('bare <h2> should not have a backgroundColor', () => {
  // the h2 without the Highlight directive
  const bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])'));

  expect(bareH2.styles.backgroundColor).toBeUndefined();
});

各条断言的测试意图拆解如下:

用例 验证目标 关键断言
should have three highlighted elements 指令确实挂到了 3 个元素(两个 <h2> + 一个 <input>),不含裸 <h2> des.length === 3
should color 1st <h2> ... "yellow" 静态属性写法 highlight="yellow" 生效 des[0].nativeElement.style.backgroundColor === 'yellow'
should color 2nd <h2> ... default color 无值时回退到默认色 通过元素注入器取出指令实例,与其 defaultColor 字段比对
should bind <input> ... value color 动态输入绑定 + 事件驱动的响应式更新 初始 cyan,改值派发 input 事件后变 green
bare <h2> should not have a backgroundColor 指令不影响未挂载元素 :not([highlight]) 选中裸 <h2>,其 backgroundColorundefined

动态输入用例里的两个细节值得单独强调:直接修改 input.value 并不会触发 Angular 的变更检测,必须 input.dispatchEvent(new Event('input')) 派发 DOM 事件让框架感知变化,然后再 await fixture.whenStable() 等信号驱动的 DOM 更新落定。这与《组件测试场景》dispatchEvent() 改输入值的思路完全一致。

值得注意的几条测试技术要点

文档在测试之后专门提炼了若干技术要点,这些技巧同样适用于所有属性指令的测试:

By.directive 谓词:按指令类型而非元素类型查询

des = fixture.debugElement.queryAll(By.directive(Highlight));

当"挂载该指令的元素是什么标签"不可预知时,By.directive(Highlight) 是定位它们的理想手段——它直接按指令类遍历 DOM,返回所有附加了该指令实例的元素,天然免疫模板重构导致的标签变化。《组件测试场景》的 "By.directive and injected directives" 一节也演示了这一谓词的进阶用法(如从元素上直接取出指令实例)。

:not 伪类:精确锁定"没有该指令"的对照元素

const bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])'));
expect(bareH2.styles.backgroundColor).toBeUndefined();

CSS 的 :not 伪类By.css 中同样有效:h2:not([highlight]) 选出未挂指令的 <h2>;而 By.css('*:not([highlight])') 能找出任意未挂指令的元素。利用"反向选择"写负向断言,可以有力证明指令没有泄漏到无关元素上。

DebugElement.stylesnativeElement:两种取样式的方式

DebugElement 这一抽象层提供了 styles 等访问器,即使在没有真实浏览器的测试环境里也能读取元素样式。不过当直觉上 nativeElement 更清晰时(例如需要拿到 HTMLInputElement 才能改 .value),文档明确建议放手使用原生元素——两条路都通向同一份 DOM 事实。从DebugElement 的源码实现看,DebugElement 封装了 nativeElementstylespropertiesinjectorqueryAll 等访问器,更多调试 API 可参见《测试工具 API》

通过元素注入器获取指令实例

Angular 会把指令注册到应用它的那个元素的注入器上,所以不必像纯类测试那样手工 new,直接从元素注入器取实例即可断言其内部状态:

const dir = des[1].injector.get(Highlight);
expect(bgColor).toBe(dir.defaultColor);

HighlightdefaultColor 是公开只读字段,这种写法把"样式断言"升级成了"状态断言",是测试默认值回退逻辑的稳健手段。

DebugElement.properties:读取指令设置的宿主属性

前文提到该指令会顺带设置元素自定义属性 customProperty。宿主绑定产生的自定义属性同样落在 DebugElement 的属性快照中,可通过 DebugElement.properties 访问——它对应源码中对 nativeElement 上 DOM 属性的快照读取逻辑,是无浏览器环境下检查宿主属性的便捷通道。

隔离测试指令:用 componentRef.setInput 驱动输入

上一节的宿主组件把多种挂载方式"静态"写死在模板里。如果希望针对指令本身做更纯粹的隔离测试——比如由测试代码动态决定传给指令的颜色值——可以用一个极小的本地宿主组件 + ComponentRef.setInput() 实现。注意:指令无法直接通过 TestBed 构造,必须经由某个组件模板渲染后才能正确表现行为,这是属性指令隔离测试的底层约束:

@Component({
  imports: [Highlight],
  template: `<p [highlight]="color()">{{ color() }}</p>`,
})
class Test {
  readonly color = input('');
}

describe('Highlight', () => {
  let fixture: ComponentFixture<Test>;

  beforeEach(async () => {
    fixture = TestBed.createComponent(Test);
    await fixture.whenStable();
  });

  it('should use the specified color once an input is provided', async () => {
    fixture.componentRef.setInput('color', 'blue');
    await fixture.whenStable();

    const p = fixture.nativeElement.querySelector('p');
    expect(p.style.backgroundColor).toBe('blue');
  });
});

该模式的要点:

  1. 宿主组件最小化:模板只有一行 <p [highlight]="color()">color 是组件自身的一个 signal 输入;
  2. setInput 驱动fixture.componentRef.setInput('color', 'blue') 相当于从组件层面写入输入值,触发宿主绑定的 bgColor() 更新;
  3. await fixture.whenStable() 后断言:信号值变更需要稳定周期落地到 DOM,随后再通过 nativeElement.querySelector('p') 检查 p.style.backgroundColor === 'blue'

这种形态把"宿主组件"变成可编程的测试夹具,特别适合做"指令 + 输入"组合的逐值参数化测试;而前文的人工宿主组件则擅长演示"同一模板内多种用法并存"的集成场景。两种方法互为补充,共同覆盖了属性指令从"单点生效"到"全面行为"的测试需求。

结语:属性指令测试的推荐路径

综合全文,为属性指令编写测试可以遵循这条经验路径:

  1. 先写一个专属人工宿主测试组件,在同一模板里并列展示静态值、默认值、动态绑定与对照元素,一次性声明指令的全部行为面;
  2. By.directive 批量取元素、用 nativeElement/DebugElement.styles 断言背景色等样式效果,用 :not 伪类做负向对照断言;
  3. 对需要逐参数验证的场景,改用 componentRef.setInput() + 最小宿主组件的隔离测试;
  4. 必要时通过元素注入器直接取指令实例,断言其公开字段,把样式断言升级为状态断言。

若指令被嵌套在真实业务组件中使用,请先阅读《组件测试场景》掌握如何用 NO_ERRORS_SCHEMA 或桩组件削减测试配置。对于 Highlight 这类指令在其他上下文中的真实实现(如示例项目里的 highlight.directive.ts,以及 signal 教程第 8 步的 highlight-directive.ts),也可以对照阅读加深理解。测试运行方面,Karma/Jasmine 与 Vitest 的配置差异可参考《Karma 指南》《迁移到 Vitest》

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