Search code examples
angularjasmine

Unit testing ViewChild and MockedDirective prevents it from being initialized


When running a unit test to see if a ViewChild is DEFINED, it fails to initialized. This only occurs if I have the *appUserProfile directive on the component.

Otherwise the unit test will pass and the ViewChild will be DEFINED. Please Help.

<div id="user" *appUserProfile="[ Profile.GAPGENAC ]" style="text-align: right">
  <div class="page-title-div">
    <mat-label class="page-title">View Roster</mat-label>
  </div>
  <div style="width: 100%; margin: 0 auto">
    <div class="view-roster-controls">
      <div id="filter-text-box-container">
        <img
          id="view-roster-search-icon"
          src="../../assets/icons/search-outline.svg"
          class="search-icon"
          alt="search"
        />
        <input
          id="filter-text-box"
          aria-label="Filter"
          (input)="onFilterTextBoxChanged()"
          type="text"
          placeholder="Search the Roster"
        />
      </div>
      <div id="right-controls-container">
        <button
          id="view-roster-export"
          (click)="onBtnExport()"
          value="Export to CSV"
        >
          <img
            id="view-roster-export-icon"
            src="../../assets/icons/download-outline.svg"
            class="export-icon"
            alt="search"
          />
          <p id="export-button-text">Export to CSV</p>
        </button>
        <div class="roster-controls-vertical-break"></div>
        <div id="display-columns-container">
          <div style="display: flex; height: 100%">
            <img
              id="view-roster-columns-icon"
              src="../../assets/icons/columns.svg"
              class="columns-icon"
              alt="column"
            />
            <p id="displayed-columns-text">Display Columns</p>
          </div>
          <mat-form-field id="invisible-display-columns" appearance="fill">
            <mat-label for="displayColumn" id="mat-lbl-display-col"
              >Displayed Columns</mat-label
            >
            <mat-select
              multiple
              [formControl]="selectFormControl"
              role="combobox"
              id="mat-sel-display-col"
              aria-label="select"
              aria-expanded="false">
              <mat-option
                role="option"
                for="displayColumn"
                *ngFor="let column of columnDefs"
                [value]="column.field"
                [attr.aria-label]="column.headerName"
                aria-disabled="true"
                (onSelectionChange)="selectionChange($event)"
                >{{ column.headerName }}</mat-option>
            </mat-select>
            <div id="panel"></div>
          </mat-form-field>
        </div>
      </div>
    </div>
    <ag-grid-angular
      id="member-roster"
      #memberRosterGrid
      style="width: 100%"
      class="ag-theme-alpine"
      [columnDefs]="columnDefs"
      [rowData]="rowData$ | async"
      [defaultColDef]="defaultColDef"
      [pagination]="true"
      [paginationPageSize]="15"
      [unSortIcon]="true"
      (gridReady)="onGridReady()"
      (cellValueChanged)="onCellValueChanged()"
      (displayedColumnsChanged)="onDisplayedColumnsChanged($event)"
      (columnVisible)="oncolumnVisible()"
      (sortChanged)="onSortChanged()"
      (filterChanged)="onFilterChanged()"
      (paginationChanged)="onPaginationChanged()"
      (bodyScroll)="onBodyScroll()"
    >
    </ag-grid-angular>
  </div>
</div>
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { Input, Directive, OnInit } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AgGridModule } from 'ag-grid-angular';
import { Observable } from 'rxjs';
import { UserProfileDirective } from 'src/app/directives/user-profile-directive';
import { Profile } from 'src/app/models/profile';
import { MaterialModule } from '../../material/material.module';
import { MemberRoster } from '../../models/memberroster.model';
import { AuthenticationService } from '../../services/authentication.service';
import { MemberService } from '../../services/member.service';
import { MemberrosterComponent } from './memberroster.component';

@Directive({ selector: '[appUserProfile]' })
export class MockUserProfileDirective implements OnInit {
    @Input() appUserProfile: Profile[];

    ngOnInit() {
        console.log('mocked Directive: ', this.appUserProfile);
    }
}

fdescribe('MemberRosterComponent', () => {
    let component: MemberrosterComponent;
    let fixture: ComponentFixture<MemberrosterComponent>;
    let authServiceMock: AuthenticationService;
    let authServiceSpyObj = jasmine.createSpyObj('AuthenticationService', ['isAuthenticated']);

    let memberServiceSpyObj = jasmine.createSpyObj('MemberService', ['getMembers']);
    memberServiceSpyObj.getMembers.and.returnValue(new Observable<MemberRoster[]>());

    beforeEach(async () => {
        await TestBed.configureTestingModule({
            declarations: [
                MemberrosterComponent,
                MockUserProfileDirective
            ],
            imports: [
                HttpClientTestingModule,
                AgGridModule,
                MaterialModule,
                BrowserAnimationsModule,
                ReactiveFormsModule
            ],
            providers: [
                { provide: AuthenticationService, useValue: authServiceSpyObj },
                { provide: MemberService, useValue: memberServiceSpyObj }
            ]
        })
            .compileComponents();

        fixture = TestBed.createComponent(MemberrosterComponent);

        component = fixture.componentInstance;
    });

    it('should create', () => {
        fixture.detectChanges();

        expect(component).toBeTruthy();
        expect(component.rowData$).toBeDefined();
    });

    fit('grid API is available after `detectChanges`', fakeAsync(() => {
        // Setup template bindings and run ngOInit. This causes the <ag-grid-angular> component to be created.
        // As part of the creation the grid apis will be attached to the gridOptions property.
        fixture.detectChanges();
        flush();
        console.log('component.grid: ', component.grid);
        expect(component.grid.api).toBeDefined();
    }));
});
import { Component, ViewChild, OnInit, ViewEncapsulation } from "@angular/core";
import { Router } from "@angular/router";
import { ILdapUser } from "../../models/ldap-user";
import { AuthenticationService } from "../../services/authentication.service";
import { MemberService } from "../../services/member.service";
import { MemberRoster } from "../../models/memberroster.model";
import { ColDef, ICellRendererParams, DisplayedColumnsChangedEvent } from "ag-grid-community";
import { AgGridAngular } from "ag-grid-angular";
import { FormControl } from "@angular/forms";
import { MatOptionSelectionChange } from "@angular/material/core";
import { Profile } from '../../models/profile';
import { Observable } from "rxjs";

@Component({
  selector: "app-memberroster",
  templateUrl: "./memberroster.component.html",
  styleUrls: ["./memberroster.component.css"],
  encapsulation: ViewEncapsulation.None,
})
export class MemberrosterComponent implements OnInit {
  @ViewChild("memberRosterGrid") grid!: AgGridAngular;
  user: ILdapUser;
  rowData$: Observable<MemberRoster[]>;
  userName: string;
  groupNumber: string;
  Profile = Profile;

  constructor(
    public authService: AuthenticationService,
    public memberService: MemberService,
    private router: Router
  ) { }

  ngOnInit() {
    console.log('ngOnInit called');
    const ariaHiddenControls = document.querySelectorAll(
      '[aria-hidden="true"]'
    );
    ariaHiddenControls.forEach((b) => b.setAttribute("tabindex", "-1"));

    this.rowData$ = this.memberService.getMembers();

    const pageControls = document.getElementById("ag-25-row-count");
    if (pageControls) {
      pageControls.insertAdjacentText("afterend", " records");
    }
  }

  columnDefs: ColDef[] = [
    {
      field: "health_id",
      headerName: "Member ID",
      cellClass: "cellAlignment"
    },
    {
      field: "last_name",
      sort: "asc",
      sortIndex: 0,
      headerName: "Last Name",
      cellClass: "cellAlignment",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      },
      width: 160
    },
    {
      field: "first_name",
      headerName: "First Name",
      filter: "agTextColumnFilter",
      cellClass: "cellAlignment",
      filterParams: {
        buttons: ["clear", "apply"],
      },
      width: 160
    },
    {
      field: "birth_date",
      headerName: "Date of Birth",
      comparator: this.dateComparator,
      filter: "agDateColumnFilter",
      cellClass: "cellAlignment",
      filterParams: {
        buttons: ["clear", "apply"],
        comparator: (filterLocalDateAtMidnight, cellValue) => {
          const cellDate = new Date(cellValue);

          if (filterLocalDateAtMidnight.getTime() === cellDate.getTime()) {
            return 0;
          }

          if (cellDate < filterLocalDateAtMidnight) {
            return -1;
          }

          if (cellDate > filterLocalDateAtMidnight) {
            return 1;
          }
        },
      }
    },
    {
      field: "contract_type",
      headerName: "Contract Type",
      cellClass: "cellAlignment",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      }
    },
    {
      field: "enrollment_status",
      headerName: "Enrollment Status",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      },
      cellRenderer: this.enrollmentStatusRenderer
    },
    {
      field: "section_number",
      headerName: "Section #",
      cellClass: "cellAlignment",
      filter: "agNumberColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      }
    },
    {
      field: "section_name",
      headerName: "Section Name",
      cellClass: "cellAlignment",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      }
    },
    {
      field: "section_description",
      headerName: "Section Description",
      cellClass: "cellAlignment",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      }
    },
    {
      field: "cancel_date",
      headerName: "Cancel Date",
      comparator: this.dateComparator,
      filter: "agDateColumnFilter",
      cellClass: "cellAlignment",
      filterParams: {
        buttons: ["clear", "apply"],
        comparator: (filterLocalDateAtMidnight, cellValue) => {
          const cellDate = new Date(cellValue);

          if (filterLocalDateAtMidnight.getTime() === cellDate.getTime()) {
            return 0;
          }

          if (cellDate < filterLocalDateAtMidnight) {
            return -1;
          }

          if (cellDate > filterLocalDateAtMidnight) {
            return 1;
          }
        },
      }
    },
    {
      field: "department_number",
      headerName: "Department",
      cellClass: "cellAlignment",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      }
    },
    {
      field: "employee_id",
      headerName: "Employee ID",
      cellClass: "cellAlignment",
      filter: "agTextColumnFilter",
      filterParams: {
        buttons: ["clear", "apply"],
      }
    }
  ];

  public defaultColDef: ColDef = {
    resizable: true,
    sortable: true,
    minWidth: 100,
    width: 200,
    icons: {
      menu: "<img src='../../assets/icons/filter.svg' class='view-roster-filter-icon' alt='filter'>",
      move: "<img id='view-roster-move-icon' src='../../assets/icons/move-outline.svg' class='move-icon' alt='move'>",
      sortAscending: "<div class='sort-icon-container'>" +
        "<img src='../../assets/icons/caret-up-outline-green.svg' class='sort-icon-top' alt='ascending sort'>" +
        "<img src='../../assets/icons/caret-down-outline.svg' class='sort-icon-bottom' alt='ascending sort'>" +
        "</div>",
      sortDescending: "<div class='sort-icon-container'>" +
        "<img src='../../assets/icons/caret-up-outline.svg' class='sort-icon-top' alt='ascending sort'>" +
        "<img src='../../assets/icons/caret-down-outline-green.svg' class='sort-icon-bottom' alt='ascending sort'>" +
        "</div>",
      sortUnSort: "<div class='sort-icon-container'>" +
        "<img src='../../assets/icons/caret-up-outline.svg' class='sort-icon-top' alt='ascending sort'>" +
        "<img src='../../assets/icons/caret-down-outline.svg' class='sort-icon-bottom' alt='ascending sort'>" +
        "</div>"
    }
  };

  selectFormControl: FormControl = new FormControl(
    this.columnDefs.filter((col) => !col.hide).map((col) => col.field)
  );

  selectionChange(event: MatOptionSelectionChange) {
    if (event.isUserInput) {
      this.grid.columnApi.applyColumnState({
        state: [
          {
            colId: event.source.value,
            hide: !event.source.selected,
          },
        ],
      });
    }
  }

  ariaControlsReset() {
    let controls = document.querySelectorAll(
      '[aria-description="Press ENTER to sort. Press CTRL ENTER to open column menu."]'
    );
    controls.forEach((b) => b.removeAttribute("aria-description"));
    controls = document.querySelectorAll(
      '[aria-description="Press ENTER to sort."]'
    );
    controls.forEach((b) => b.removeAttribute("aria-description"));
    document.querySelector('[id="mat-sel-display-col"]').setAttribute("aria-controls", "panel");
  }

  onGridReady() {
    console.log('on grid ready');
    this.ariaControlsReset();
  }
  onCellValueChanged() {
    this.ariaControlsReset();
  }
  onDisplayedColumnsChanged(event: DisplayedColumnsChangedEvent) {
    this.ariaControlsReset();
    for (let col of event.columnApi.getColumns()) {
      if (!(event.columnApi.getAllDisplayedColumns().includes(col))) {
        this.columnDefs.filter(colDef => colDef.field === col.getColDef().field).forEach(colDef => colDef.hide = true);
      }
      else {
        this.columnDefs.filter(colDef => colDef.field === col.getColDef().field).forEach(colDef => colDef.hide = false);
      }
    }
    this.selectFormControl = new FormControl(
      this.columnDefs.filter((col) => !col.hide).map((col) => col.field)
    );
  }
  oncolumnVisible() {
    this.ariaControlsReset();
  }
  onSortChanged() {
    this.ariaControlsReset();
  }
  onFilterChanged() {
    this.ariaControlsReset();
  }
  onPaginationChanged() {
    this.ariaControlsReset();
  }
  onBodyScroll() {
    this.ariaControlsReset();
  }

  dateComparator(date1, date2) {
    const date1Number = date1 && new Date(date1).getTime();
    const date2Number = date2 && new Date(date2).getTime();

    if (date1Number === null && date2Number === null) {
      return 0;
    }

    if (date1Number === null) {
      return -1;
    } else if (date2Number === null) {
      return 1;
    }

    return date1Number - date2Number;
  }

  onFilterTextBoxChanged() {
    this.grid.api.setQuickFilter(
      (document.getElementById("filter-text-box") as HTMLInputElement).value
    );
  }

  onBtnExport() {
    this.grid.api.exportDataAsCsv();
  }

  enrollmentStatusRenderer(params: ICellRendererParams) {
    let cssClass = "";
    if (params.value === "Active")
      cssClass = "member-roster-enrollment-status-active";
    else if (params.value === "Cancelled")
      cssClass = "member-roster-enrollment-status-cancelled";
    else if (params.value === "Future")
      cssClass = "member-roster-enrollment-status-future";

    return "<div class=" + cssClass + ">" + params.value + "</div>";
  }
}

Solution

  • The issue was that I was creating a mock for a Directive and not a Structural Directive.

    A structural directive is annotated with an asterisk and generated the ng-template in the DOM. This means your mock needs to call createEmbeddedView in order for the DOM to render correctly.

    Therefore I need to change my mock to this

    @Directive({ selector: '[appUserProfile]' })
    export class MockUserProfileDirective {
      constructor(
        private templateRef: TemplateRef<any>,
        private viewContainer: ViewContainerRef
      ) {  }
    
      @Input() set appUserProfile(profiles: Profile[]) {
        this.viewContainer.createEmbeddedView(this.templateRef);
      }
    }