You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular 18独立组件迁移:无法覆盖CdkCopyToClipboard指令

Angular 18独立组件迁移:单元测试中指令Mock失效问题

我正在将Angular 18应用迁移至独立组件架构,在单元测试中碰到了依赖指令Mock覆盖失效的问题。当前技术栈包含Angular 18、Material、NGMocks、Spectator(替代TestBed)以及Karma/Jasmine。

以CdkCopyToClipboard指令为例,我自定义了MockCdkCopyToClipboard指令用于捕获输入值,避免执行真实的复制逻辑,但迁移后原指令依然生效,Mock完全没起作用。被测组件本身直接导入了CdkCopyToClipboard、MatIcon、MatIconButton三个依赖。

我尝试改用TestBed实现,问题依旧。推测Spectator应该支持这类场景,但不清楚指令覆盖的正确配置方式。


被测组件代码

import { CdkCopyToClipboard } from '@angular/cdk/clipboard';
import { Component, Input, OnChanges } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';

/**
 * Show clipboard to copy text to clipboard
 */
@Component({
  selector: 'app-copy-to-clipboard',
  template: `
  @if (showButton) {
    <button
      class="clipboard-button mat-icon-button-small"
      mat-icon-button
      type="button"
      [cdkCopyToClipboard]="textToCopy"
    >
      <mat-icon>content_copy</mat-icon>
    </button>
    }
    `,
  standalone: true,
  imports: [CdkCopyToClipboard, MatIcon, MatIconButton],
})
export class CopyToClipboardComponent implements OnChanges {
  @Input() textToCopy!: string;

  showButton = false;

  ngOnChanges(): void {
    this.showButton = this.show();
    console.log('showButton', this.showButton);
  }

  show() {
    return (
      !!this.textToCopy &&
      this.textToCopy !== '-' &&
      this.textToCopy?.toLowerCase() !== 'null'
    );
  }
}

测试代码

import { Directive, Input } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { Spectator, createComponentFactory } from '@ngneat/spectator';
import { MockComponent } from 'ng-mocks';
import { CopyToClipboardComponent } from './copy-to-clipboard.component';

// simple mock directive to capture the input. We're not going to test the cdk logic of the copy
let clipboardResult = '';
@Directive({ selector: '[cdkCopyToClipboard]', standalone: true })
class MockCdkCopyToClipboard {
  // text to copy to clipboard
  @Input() set cdkCopyToClipboard(value: string) {
    console.log('text copied', value);
    clipboardResult = value;
  }
}

describe('CopyToClipboardComponent', () => {
  let spectator: Spectator<CopyToClipboardComponent>;
  const createComponent = createComponentFactory({
    component: CopyToClipboardComponent,
    declarations: [
      MockComponent(MatIcon),
      MockComponent(MatIconButton),
      MockCdkCopyToClipboard,
    ],
  });

  beforeEach(() => {
    spectator = createComponent();
    clipboardResult = '';
  });

  it('should create', () => {
    expect(spectator.component).toBeTruthy();
  });

  it('should show the clipboard button when there is text to copy', () => {
    spectator.setInput('textToCopy', 'test');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeTruthy();
    expect(clipboardResult).toEqual('test');
  });

  it('should not show the clipboard button when there is no text to copy', () => {
    spectator.setInput('textToCopy', '');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeFalsy();
    expect(clipboardResult).toEqual('');
  });

  it('should not show the clipboard button when the text to copy is "null"', () => {
    spectator.setInput('textToCopy', null as unknown as string);
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeFalsy();
    expect(clipboardResult).toEqual('');
  });

  it('should update the clipboard button when the input changes', () => {
    spectator.setInput('textToCopy', 'initial');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeTruthy();
    expect(clipboardResult).toEqual('initial');

    spectator.setInput('textToCopy', 'updated');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeTruthy();
    expect(clipboardResult).toEqual('updated');
  });

  it('should update the clipboard button when the input changes to an empty string', () => {
    spectator.setInput('textToCopy', 'initial');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeTruthy();
    expect(clipboardResult).toEqual('initial');

    spectator.setInput('textToCopy', '');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeFalsy();
  });

  it('should update the clipboard button when the input changes to "null"', () => {
    spectator.setInput('textToCopy', 'initial');
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeTruthy();
    expect(clipboardResult).toEqual('initial');

    spectator.setInput('textToCopy', null as unknown as string);
    spectator.detectChanges();
    expect(spectator.query('.clipboard-button')).toBeFalsy();
  });
});

问题根源与解决方法

问题核心

独立组件的imports数组优先级高于测试中的declarations,直接在测试里声明Mock指令不会覆盖组件自带的依赖,必须显式替换组件内部的导入项。

方法1:用Spectator的overrideComponent替换imports

修改createComponentFactory配置,通过overrideComponent直接替换组件的imports:

const createComponent = createComponentFactory({
  component: CopyToClipboardComponent,
  overrideComponent: {
    component: CopyToClipboardComponent,
    override: {
      imports: [MockCdkCopyToClipboard, MockComponent(MatIcon), MockComponent(MatIconButton)],
    }
  }
});

方法2:用NGMocks的MockDirective自动替换

利用NGMocks简化Mock创建,无需手动定义Mock类:

import { MockDirective } from 'ng-mocks';
import { CdkCopyToClipboard } from '@angular/cdk/clipboard';

// 移除自定义MockCdkCopyToClipboard类,直接使用MockDirective
const createComponent = createComponentFactory({
  component: CopyToClipboardComponent,
  overrideComponent: {
    component: CopyToClipboardComponent,
    override: {
      imports: [MockDirective(CdkCopyToClipboard), MockComponent(MatIcon), MockComponent(MatIconButton)],
    }
  }
});

// 在测试用例中捕获输入值
it('should bind correct value to clipboard directive', () => {
  spectator.setInput('textToCopy', 'test');
  spectator.detectChanges();
  const mockDirective = spectator.query(MockDirective(CdkCopyToClipboard));
  expect(mockDirective['cdkCopyToClipboard']).toEqual('test');
});

内容的提问来源于stack exchange,提问作者Martinspire

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 21:05:12