Angular CDK Overlay - Mocking overlay component in unit test

3.8k views Asked by At

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.

2

There are 2 answers

0
Pieterjan On BEST ANSWER

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

0
AliF50 On

I think you have to mock overlayService create method which will have to have a mock attach that has instance and backdropClick.

Something like this (follow lines with !!):

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

  let mockOverlayService: jasmine.SpyObj<OverlayService>;

  beforeEach(async () => {
    // mock overlay attach
    mockOverlayAttach = {
        attach: () => {
          return {
             instance: {
               backdropClick: of(true),
             }
          };
        }
    };
    mockOverlayService = jasmine.createSpyObj<OverlayService>('OverlayService', ['create']);
    mockOverlayService.create.and.returnValue(mockOverlayAttach);
    await TestBed.configureTestingModule({
      imports: [ CommonModule, OverlayModule ],
      declarations: [
        // Unit to test
        BsOffcanvasHostComponent,
      
        // Mock dependencies
        BsOffcanvasMockComponent,
        BsOffcanvasHeaderMockComponent,
        BsOffcanvasBodyMockComponent,
        BsOffcanvasContentMockDirective,

        // Testbench
        BsOffcanvasTestComponent,
      ],
       // provide mock for OverlayService
      providers: [
        { provide: OverlayService, useValue: mockOverlayService },
      ]
    })
    .compileComponents();
  });

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

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