1

I began migrating my angular 18 application to standalone components and I've run into issues overriding various imports. I'm using Angular 18, Material, NGMocks, Spectator (as testbed replacement) and Karma/Jasmine for running unit tests.

I have an example with overriding CdkCopyToClipboard directive and I simply cannot figure out why it isn't working or what setup the componentfactory needs to make it work.

So the test has a mock directive that instead of copying to clipboard, puts the value in a string. Not the best way, but the functionality isn't really the point of the question, its one of the examples that I failed to migrate. The component itself has a button that we show if there's anything to copy if there's text on the component input.

When moving to standalone components, there are now 3 items imported, 2 are material related, 1 is CDK related and it seems that the cdk one is always being used normally instead of taking the mocked directive.

I tried reverting to TestBed but also ran into issues there and overall I think Spectator should still work but the overriding part is difficult to figure out.

The example is on stackblitz: https://stackblitz.com/edit/stackblitz-starters-um7c3g?file=src%2Fcomponents%2Fcopy-to-clipboard.component.spectator.spec.ts

The component I'm testing:

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'
    );
  }
}

The tests:

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();
  });
});

On the stackblitz I've also added a testbed alternative that also doesn't seem to work...

1 Answer 1

0

I'm not familiar with Spectator for unit tests, so this answer will be using TestBed.

You should be adding standalone components/directives to the imports array of the testing module instead of the declarations array. You also don't need to worry about mocking/overriding imports for these tests as you've outlined them.

Instead, you need to update your testing strategy for this component because it's relying on ngOnChanges to update the showButton property.

The ngOnChanges lifecycle hook of a component is not triggered by programmatically setting/updating its inputs, even when calling fixture.detectChanges(). The ngOnChanges lifecycle hook is only triggered when values are passed in via the view.

You can use a simple test host component, which passes an input to your CopyToClipboardComponent to get things working. Programmatically setting the value in the TestHostComponent and calling fixture.detectChanges() will trigger ngOnChanges in the child component (the thing you really want to test), and everything will work as expected from there.

It wasn't clear to me how you were planning to use clipboardResult for your test assertions, but you can directly check hostComponent.copyComponent.textToCopy which I think is sufficient.

// spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CopyToClipboardComponent } from './copy-to-clipboard.component';
import { Component, ViewChild } from '@angular/core';

/** Test Host Component */
@Component({
  selector: `test-host-component`,
  standalone: true,
  imports: [CopyToClipboardComponent],
  template: `<app-copy-to-clipboard [textToCopy]="value" />`,
})
export class TestHostComponent {
  @ViewChild(CopyToClipboardComponent)
  public copyComponent!: CopyToClipboardComponent;
  /* this is the variable which is passed as input to the CopyToClipboardComponent */
  public value = ''; 
}

describe('CopyToClipboardComponent', () => {
  let hostComponent: TestHostComponent;
  let fixture: ComponentFixture<TestHostComponent>;
  let btn: HTMLElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CopyToClipboardComponent, TestHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(TestHostComponent);
    hostComponent = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(hostComponent.copyComponent).toBeTruthy();
  });

  it('should show the clipboard button when there is text to copy', () => {
    hostComponent.value = 'test';
    fixture.detectChanges();
    btn = fixture.nativeElement.querySelector('.clipboard-button');
    expect(btn).toBeTruthy();
    expect(hostComponent.copyComponent.textToCopy).toEqual('test');
  });
)};

forked your stackblitz with this updated strategy.

Edit 9/7

As far as shallow testing, since you want to override the imports of your component, (replacing the cdk directive it uses with your mocked directive) you'll want to use overrideComponent instead of overrideDirective. That will fix the linting issue you saw, and the console log from your mocked directive will work as expected. I updated the above stackblitz to show this.

// spect.ts
//...
beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [CopyToClipboardComponent, TestHostComponent],
  })
    .overrideComponent(CopyToClipboardComponent, {
      remove: { imports: [CdkCopyToClipboard] },
      add: { imports: [MockCdkCopyToClipboard] },
    })
    .compileComponents();

  fixture = TestBed.createComponent(TestHostComponent);
  hostComponent = fixture.componentInstance;
  fixture.detectChanges();
});
//...
6
  • Using a host component wouldn't override the dependencies of my copytoclipboard component. The main issue is that I cannot override those. This is just one example. Commented Sep 6 at 22:27
  • If I change my tests to use imports instead of declarations, it doesn't change much of the outcome (but a good tip, so thanks for that). Because my issue is that some tests require external dependencies that I don't want to include in my unit test since its not part of that unit. For example, a test that does a lot of promises to API calls that require lots of effort to mock. Commented Sep 6 at 22:32
  • I just want a test that looks whether I have the right input, regardless of what that external component does with that data. When I change my current examples to imports, it still doesn't log the console of my mocked cdkcopytoclipboard directive. So its less about this specific component and more about mocking imports Commented Sep 6 at 22:33
  • I added a paragraph at the end and updated the stackblitz to show how you can replace the imports of your component for shallow testing. Commented Sep 7 at 19:32
  • I see that the error is gone now, but why does it still not console log anything inside the mocked one? I've updated my testbed variant as well. Commented Sep 9 at 10:49

Not the answer you're looking for? Browse other questions tagged or ask your own question.