Search code examples
angularunit-testingangular-jestangular-cdk-overlay

Angular CDK Overlay - Mocking overlay component in unit test


I've created an offcanvas component for angular, but I can't get my unit tests to work.

Here's the failing unit test (offcanvas-host.component):

describe('BsOffcanvasHostComponent', () => {
  let component: BsOffcanvasTestComponent;
  let fixture: ComponentFixture<BsOffcanvasTestComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ CommonModule, OverlayModule ],
      declarations: [
        // Unit to test
        BsOffcanvasHostComponent,
      
        // Mock dependencies
        BsOffcanvasMockComponent,
        BsOffcanvasHeaderMockComponent,
        BsOffcanvasBodyMockComponent,
        BsOffcanvasContentMockDirective,

        // Testbench
        BsOffcanvasTestComponent,
      ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(BsOffcanvasTestComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

type OffcanvasPosition = 'top' | 'bottom' | 'start' | 'end';

@Component({
  selector: 'bs-offcanvas-test',
  template: `
    <bs-offcanvas [(show)]="isOffcanvasVisible" [position]="position" [hasBackdrop]="true" (backdropClick)="isOffcanvasVisible = false">
        <div *bsOffcanvasContent>
            <bs-offcanvas-header>
                <h5>Offcanvas</h5>
            </bs-offcanvas-header>
            <bs-offcanvas-body>
                <span>Content</span>
            </bs-offcanvas-body>
        </div>
    </bs-offcanvas>`
})
class BsOffcanvasTestComponent {
  isOffcanvasVisible = false;
  position: OffcanvasPosition = 'start';
}

@Directive({ selector: '[bsOffcanvasContent]' })
class BsOffcanvasContentMockDirective {
  constructor(offcanvasHost: BsOffcanvasHostComponent, template: TemplateRef<any>) {
    offcanvasHost.content = template;
  }
}

@Component({
  selector: 'bs-offcanvas-holder',
  template: `
    <div>
      <ng-container *ngTemplateOutlet="contentTemplate"></ng-container>
    </div>`,
  providers: [
    { provide: BsOffcanvasComponent, useExisting: BsOffcanvasMockComponent }
  ]
})
class BsOffcanvasMockComponent {
  constructor(@Inject(OFFCANVAS_CONTENT) contentTemplate: TemplateRef<any>) {
    this.contentTemplate = contentTemplate;
  }

  contentTemplate: TemplateRef<any>;
}

@Component({
  selector: 'bs-offcanvas-header',
  template: `
    <div class="offcanvas-header">
      <ng-content></ng-content>
    </div>`
})
class BsOffcanvasHeaderMockComponent {}

@Component({
  selector: 'bs-offcanvas-body',
  template: `
    <div class="offcanvas-body">
      <ng-content></ng-content>
    </div>`
})
class BsOffcanvasBodyMockComponent {}

which tests the following component:

ngAfterViewInit() {
    const injector = Injector.create({
      providers: [
        { provide: OFFCANVAS_CONTENT, useValue: this.content },
      ],
      parent: this.rootInjector,
    });
    const portal = new ComponentPortal(BsOffcanvasComponent, null, injector);
    const overlayRef = this.overlayService.create({
      scrollStrategy: this.overlayService.scrollStrategies.block(),
      positionStrategy: this.overlayService.position().global()
        .top('0').left('0').bottom('0').right('0'),
      hasBackdrop: false
    });

    this.component = overlayRef.attach<BsOffcanvasComponent>(portal); // <-- The test fails here

    this.component.instance.backdropClick
      .pipe(takeUntil(this.destroyed$))
      .subscribe((ev) => {
        this.backdropClick.emit(ev);
      });

    this.viewInited$.next(true);
}

The error message is

Error: NG0302: The pipe 'async' could not be found in the 'BsOffcanvasComponent' component!. Find more at https://angular.io/errors/NG0302

Here's a minimal reproduction of the issue

How can tell the angular TestingModule to use the mock component instead of the initial component type when calling

overlayRef.attach<BsOffcanvasComponent>(portal)

command in my unit test?

EDIT

Sadly I'm still not getting it to work. Usually for unit testing in angular there are mainly 3 cases:

Components referenced through the use of the tagname in the template of the UTT (unit-to-test)

This you solve by creating a MockComponent with the same tagname and inputs/outputs.

Ancestoral components injected in the UTT

This you solve by creating a MockComponent with a provider on the decorator

@Component({
  selector: 'bs-offcanvas-holder',
  template: ``,
  providers: [
    { provide: BsOffcanvasComponent, useExisting: BsOffcanvasMockComponent }
  ]
})
class BsOffcanvasMockComponent {}

Component types called directly from the UTT

this.component = overlayRef.attach<BsOffcanvasComponent>(portal);

Here I should be able to use the BsOffcanvasMockComponent instead, without having the unit-test dragging the other file in the TestBed. So how can I solve this? Off course I can mock the CDK Overlay service, but this still leaves me with the above line of code in my UTT, where the BsOffcanvasComponent is litterally being dragged into the Testbed.


Solution

  • I was able to solve the problem by providing a factory in my runtime module (OffcanvasModule) and in my TestingModule. This eliminates the import of the BsOffcanvasComponent in the testingmodule.

    components.module.ts

    providers: [{
      provide: 'PORTAL_FACTORY',
      useValue: (injector: Injector) => {
        return new ComponentPortal(BsOffcanvasComponent, null, injector);
      }
    }]
    

    offcanvas-host.component.spec.ts

    providers: [
      {
        provide: 'PORTAL_FACTORY',
        useValue: (injector: Injector) => {
          return new ComponentPortal(BsOffcanvasComponent, null, injector);
        }
      }
    ]
    

    This solves the following error

    The pipe 'async' could not be found in the 'BsOffcanvasComponent' component