Angular 单元测试模拟选择器不工作

Angular Unit Test Mock Selector not working

我正在尝试在我的单元测试中模拟一个选择器,如下所示:

describe('Device List Component', () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  let deviceServiceMock: any;
  let mockStore: MockStore<any>;
  let devices;


  beforeEach(async(() => {
    deviceServiceMock = jasmine.createSpyObj('DevicesService', ['fetchDevices']);
    deviceServiceMock.fetchDevices.and.returnValue(of(deviceState()));


    TestBed.configureTestingModule({
      declarations: [
        ListComponent,
        MockComponent(DataGridComponent),
      ],
      imports: [
        RouterTestingModule,
        MockModule(SharedModule),
        ToastrModule.forRoot({
          preventDuplicates: true,
          closeButton: true,
          progressBar: true,
        }),
        TranslateModule.forRoot({
          loader: { provide: TranslateLoader, useClass: JsonTranslationLoader },
        }),
      ],
      providers: [
        { provide: DevicesService, useValue: deviceServiceMock },
        { provide: ColumnApi, useClass: MockColumnApi },
        provideMockStore(),
      ],
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    mockStore = TestBed.get(MockStore);
    component.columnApi = TestBed.get(ColumnApi);
    devices = mockStore.overrideSelector('devices', deviceState());
    fixture.detectChanges();
  });
});

这是组件文件

export class ListComponent implements OnInit, OnDestroy {
  columnDefs: DeviceColumns[];
  defaultColumnDefs: any;
  gridApi: any;
  columnApi: any;
  overlayNoRowsTemplate: string;
  rowData: DeviceData[] = [];
  pagination: DevicePagination;
  globalSearch: string;
  hasFloatingFilter: boolean;
  frameworkComponents: any;
  dropdownSettings: any = {};
  dropDownList: Columns[] = [];
  selectedItems: Columns[] = [];
  shuffledColumns: any = [];
  selectedRows: DeviceData[] = [];
  rowSelection: string;
  bsModalRef: BsModalRef;
  isColumnsSorting: boolean;
  hasRowAnimation = true;
  multiSortKey = 'ctrl';
  changeDetectorRef: ChangeDetectorRef;
  deviceDeleteSubscription: Subscription;

  currentPage = new Subject<number>();
  search = new Subject<string>();
  subscription: Subscription;

  constructor(
    private router: Router,
    private devicesService: DevicesService,
    public store: Store<any>,
    private toast: ToastrService,
    private ngZone: NgZone,
    private translateService: TranslateService,
    private globalTranslate: GlobalLanguageService,
    private modalService: BsModalService,
    changeDetectorRef: ChangeDetectorRef
  ) {
    this.translateService.stream(['DEVICES.LIST', 'MULTISELECT']).subscribe((translations) => {
      const listTranslations = translations['DEVICES.LIST'];
      const multiSelectTranslations = translations['MULTISELECT'];
      this.overlayNoRowsTemplate = `<span class="ag-overlay-loading-center">${listTranslations.NODEVICE}</span>`;
      this.dropdownSettings.selectAllText = multiSelectTranslations.SELECTALL;
      this.dropdownSettings.unSelectAllText = multiSelectTranslations.DESELECTALL;
    });
    this.changeDetectorRef = changeDetectorRef;
    this.translateService.onLangChange.subscribe(() => {
      this.gridApi && this.gridApi.refreshHeader();
    });
  }

  ngOnInit() {
    this.loadStore();
    this.initializeColumns();
    this.pageSearch();
    this.dropdownSettings = {
      singleSelection: false,
      idField: 'field',
      textField: 'key',
      selectAllText: 'Select All',
      unSelectAllText: 'UnSelect All',
      itemsShowLimit: 3,
      allowSearchFilter: true,
      enableCheckAll: true,
    };
    this.store.dispatch(new DevicesActions.ClearCurretDevice());
  }
  loadStore() {
    this.store.pipe(select('devices')).subscribe((val) => {
      const deviceList = val.devices.map((d) => {
        return {
          ...d,
          is_online: d.is_online ? 'Active' : 'Inactive',
        };
      });
      this.rowData = deviceList;
      this.pagination = val.pagination;
      this.globalSearch = val.globalSearch;
      this.hasFloatingFilter = val.hasFloatingFilter;
      this.dropDownList = val.shuffledColumns;
      this.selectedItems = val.shuffledColumns.filter((column: Columns) => !column.hide);
      this.selectedRows = val.selectedRows;
    });
  }
  initializeColumns() {
    this.columnDefs = [
      {
        headerName: 'S.No',
        translateKey: 'DEVICES.LIST.SNO',
        width: 100,
        resizable: false,
        sortable: false,
        suppressSizeToFit: true,
        valueGetter: (args) => this.getId(args),
        checkboxSelection: (params) => {
          console.log('params.columnApi.getRowGroupColumns()', params.columnApi.getRowGroupColumns());
          return params.columnApi.getRowGroupColumns().length === 0;
        },
        headerCheckboxSelection: (params) => {
          return params.columnApi.getRowGroupColumns().length === 0;
        },
      },
      ...gridColumns,
    ];
    this.columnDefs = map(this.columnDefs, (columnDef) => {
      return extend({}, columnDef, { headerValueGetter: this.localizeHeader.bind(this) });
    });
    this.shuffledColumns.push(this.columnDefs[0]);
    this.dropDownList.forEach((column, colIndex) => {
      this.columnDefs.forEach((data) => {
        if (data.field === column.field) {
          data.hide = column.hide;
          data.sort = column.sort;
          data.width = column.width;
          data.minWidth = column.minWidth;
          this.shuffledColumns.splice(colIndex + 1, 0, data);
        }
      });
    });
    this.columnDefs = this.shuffledColumns;
    this.rowSelection = 'multiple';
    this.defaultColumnDefs = {
      suppressMenu: true,
      suppressMovable: true,
      sortable: true,
      resizable: true,
    };
    this.frameworkComponents = { FloatingFilterComponent: FloatingFilterComponent };
  }

  localizeHeader(params: any) {
    return this.globalTranslate.getTranslation(params.colDef.translateKey);
  }

  getId(args: any): any {
    return (
      this.pagination.per_page * this.pagination.prev_page + parseInt(args.node.rowIndex, 10) + 1
    );
  }
  pageSearch() {
    this.subscription = this.search.subscribe((value) => {
      this.store.dispatch(new DevicesActions.GlobalSearch(value));
      if (value.length === 0) {
        this.clearSelectedRows();
        this.loadData();
      }
    });
  }
  OnGridReady(params) {
    this.gridApi = params.api;
    this.columnApi = params.columnApi;
    this.loadData();
  }
  loadData() {
    this.devicesService.fetchDevices(this.gridApi);
  }
  gotoAddDevice() {
    this.router.navigate(['/devices/new']);
  }
  searchDevices() {
    this.store.dispatch(new DevicesActions.UpdateCurrentPage(1));
    this.clearSelectedRows();
    this.loadData();
  }
  clearSelectedRows() {
    this.store.dispatch(new DevicesActions.ClearSelectedRows());
  }
  onItemSelect(item: DropDownColumns) {
    this.store.dispatch(new DevicesActions.ColumnSelect(item.field));
    this.columnApi.setColumnVisible(item.field, true);
  }
  onSelectAll(items: any) {
    this.store.dispatch(new DevicesActions.ColumnsSelectAll());
    items.map((item) => this.columnApi.setColumnVisible(item.field, true));
  }
  onItemUnSelect(item: DropDownColumns) {
    this.store.dispatch(new DevicesActions.ColumnDeSelect(item.field));
    this.columnApi.setColumnVisible(item.field, false);
  }
  onDeSelectAll() {
    this.store.dispatch(new DevicesActions.ColumnsDeSelectAll());
    this.dropDownList.map((item) => this.columnApi.setColumnVisible(item.field, false));
  }
  SortedColumns(params: SortedColumns[]) {
    const columnsId = [];
    params.map((param: SortedColumns) => {
      columnsId.push(param.id);
    });
    const shuffledColumns = columnsId.map((columnId) =>
      this.dropDownList.find((data) => data.field === columnId)
    );
    this.store.dispatch(new DevicesActions.ShuffledColumns(shuffledColumns));
    this.columnApi.moveColumns(columnsId, 1);
  }
  hasDevices() {
    return this.rowData.length > 0 ? true : false;
  }
  updatePage() {
    if (this.pagination.current_page.toString() === '') {
      this.pagination.current_page = 1;
    }
    this.store.dispatch(new DevicesActions.UpdateCurrentPage(this.pagination.current_page));
    this.clearSelectedRows();
    this.loadData();
  }
  previousPage() {
    this.store.dispatch(new DevicesActions.UpdateCurrentPage(this.pagination.prev_page));
    this.clearSelectedRows();
    this.loadData();
  }

  firstPage() {
    this.store.dispatch(new DevicesActions.UpdateCurrentPage(1));
    this.clearSelectedRows();
    this.loadData();
  }

  lastPage() {
    this.store.dispatch(new DevicesActions.UpdateCurrentPage(this.pagination.total_pages));
    this.clearSelectedRows();
    this.loadData();
  }

  nextPage() {
    if (!this.pagination.is_last_page) {
      this.store.dispatch(new DevicesActions.UpdateCurrentPage(this.pagination.next_page));
      this.clearSelectedRows();
      this.loadData();
    }
  }
  toggleFloatingFilter() {
    this.hasFloatingFilter = !this.hasFloatingFilter;
    this.store.dispatch(new DevicesActions.UpdateFloatingFilter(this.hasFloatingFilter));
    this.clearSelectedRows();
    this.gridApi.setRowData(this.rowData);
    if (!this.hasFloatingFilter) {
      this.gridApi.setFilterModel(null);
      this.store.dispatch(new DevicesActions.ClearColumnSearch());
      this.loadData();
    }
    setTimeout(() => {
      this.gridApi.refreshHeader();
    }, 0);
    window.location.reload();
  }
  isSortingEnabled() {
    this.isColumnsSorting = this.dropDownList.some((column) => column.sort !== '');
    return this.isColumnsSorting;
  }
  setSortingBackgroundColor() {
    return this.isColumnsSorting ? COLOR_PRIMARY : COLOR_SECONDARY;
  }

  setSortingIconColor() {
    return this.isColumnsSorting ? ICON_ENABLED : ICON_DISABLED;
  }
  clearSort() {
    this.gridApi.setSortModel(null);
    this.store.dispatch(new DevicesActions.ClearColumnsSort());
    this.loadData();
  }
  resizeColumns() {
    const allColumnIds = [];
    this.columnApi.getColumnState().forEach((column) => {
      allColumnIds.push(column.colId);
    });
    this.columnApi.autoSizeColumns(allColumnIds, false);
  }
  onRowDataChanged() {
    if (this.gridApi) {
      this.gridApi.forEachNode((node: any) => {
        const selectNode = this.selectedRows.some((row) => row.id === node.data.id);
        if (selectNode) {
          node.setSelected(true);
        }
      });
    }
  }
  onSelectionChanged() {
    this.selectedRows = this.gridApi.getSelectedRows();
    console.log('selected rows', this.selectedRows);
    this.store.dispatch(new DevicesActions.UpdateSelectedRows(this.selectedRows));
    this.changeDetectorRef.detectChanges();
  }
  onSortChanged(params) {
    this.store.dispatch(new DevicesActions.UpdateColumnsSort(params));
    this.clearSelectedRows();
    this.loadData();
  }
  onColumnResized() {
    const updatedColumns: ColumnWidth[] = [];
    this.columnApi.getColumnState().forEach((column) => {
      updatedColumns.push({ field: column.colId, width: column.width });
    });
    this.store.dispatch(new DevicesActions.UpdateColumnWidth(updatedColumns));
  }
  gotoDetailView(params: any) {
    const id = params.id;
    this.ngZone.run(() => this.router.navigate(['/devices', id]));
  }
  isDeleteEnabled() {
    return this.selectedRows.length === 0 ? true : false;
  }
  setDeleteBackgroundColor() {
    return this.selectedRows.length !== 0 ? COLOR_PRIMARY : COLOR_SECONDARY;
  }

  setDeleteIconColor() {
    return this.selectedRows.length !== 0 ? ICON_ENABLED : ICON_DISABLED;
  }

  openModal() {
    const initialState = {
      title: 'Delete Device',
      message: 'Do you really want to delete the device? This process cannot be undone',
    };
    if (this.selectedRows.length > 1) {
      (initialState.title = 'Delete Devices'),
        (initialState.message = `Do you really want to delete ${this.selectedRows.length} devices? This process cannot be undone`);
    }
    this.bsModalRef = this.modalService.show(ModalDeleteComponent, { initialState });
    this.bsModalRef.content.delete.subscribe((canDelete: boolean) => {
      if (canDelete) {
        this.deleteDevices();
      }
      this.bsModalRef.hide();
    });
  }
  ngOnDestroy() {
    this.deviceDeleteSubscription?.unsubscribe();
  }
  deleteDevices() {
    const selectedIds = this.selectedRows.map((row) => row.id).toString();
    const params = {
      ids: selectedIds,
    };
    this.deviceDeleteSubscription = this.devicesService.deleteDevices(params).subscribe(
      (data) => {
        const ids = selectedIds.split(',').map(Number);
        this.clearSelectedRows();
        this.store.dispatch(new DevicesActions.DeleteDevices(ids));
        if (this.rowData.length === 0) {
          this.store.dispatch(new DevicesActions.UpdateCurrentPage(1));
          this.loadData();
        }
        this.toast.success('Deleted successfully');
        setTimeout(() => {
          window.location.reload();
        }, 500);
      },
      (error) => {
        this.toast.error(error.message);
      }
    );
  }

  editConfiguration() {
    this.store.dispatch(new DevicesActions.SetEditDevice(this.selectedRows[0]));
    this.router.navigate(['/devices', 'edit', this.selectedRows[0].id]);
  }

  isEditEnabled() {
    return this.selectedRows.length !== 1 ? true : false;
  }

  setEditBackgroundColor() {
    return this.selectedRows.length === 1 ? COLOR_PRIMARY : COLOR_SECONDARY;
  }

  setEditIconColor() {
    return this.selectedRows.length === 1 ? ICON_ENABLED : ICON_DISABLED;
  }
}

但是当我 运行 规范时,我得到的错误是

TypeError: Cannot read property 'length' of undefined

我认为问题在于使用 provideMockStore 模拟选择器。我可以看到你已经使用了很多this.selectedRows.length(例如setEditBackgroundColor()),这些是基于ngRx Selector设置的。

providers: [
        { provide: DevicesService, useValue: deviceServiceMock },
        { provide: ColumnApi, useClass: MockColumnApi },
         provideMockStore({
          selectors: [
            {
              selector: selectLoginPagePending,
              value: true
            }
          ]
        })
      ],

对于像这样的选择器:

export const selectLoginPagePending = createSelector(
  selectLoginPageState,
  (state: State) => state.pending;
);

按照 select('devices') 的预期输出尝试这个,我认为它应该有效。


附带说明一下,尽量不要像在 setEditBackgroundColor() 和其他方法中那样从 HTML 进行函数调用,这会影响性能并且会在每个 ChangeDetection 周期中调用(尝试将 console.log 放入此类方法中)。他们会被叫几次。最好使用一些 map 设置对象 属性 然后在 HTML

上渲染它

我已将商店设置为如下所示的初始值,而不是选择器

 const mockInitialAppState = {
    devices: deviceState(),
  };

  beforeEach(() => {
    deviceServiceMock = jasmine.createSpyObj('DevicesService', ['fetchDevices', 'deleteDevices']);
    deviceServiceMock.fetchDevices.and.returnValue(of(deviceState()));
    deviceServiceMock.deleteDevices.and.returnValue(of({}));

    TestBed.configureTestingModule({
      declarations: [ListComponent, MockComponent(DataGridComponent)],
      imports: [
        RouterTestingModule,
        MockModule(SharedModule),
        ToastrModule.forRoot({
          preventDuplicates: true,
          closeButton: true,
          progressBar: true,
        }),
        TranslateModule.forRoot({
          loader: { provide: TranslateLoader, useClass: JsonTranslationLoader },
        }),
      ],
      providers: [
        { provide: DevicesService, useValue: deviceServiceMock },
        { provide: ColumnApi, useClass: MockColumnApi },
        { provide: GridApi, useClass: MockGridApi },
        provideMockStore({
          initialState: { ...mockInitialAppState },
        }),
      ],
    }).compileComponents();
  });

这使得测试 pass.Bypassing 成为初始状态我不需要初始化 selectedRows