Я создаю правильные юнит-тесты с Angular и жасмином? - PullRequest
0 голосов
/ 19 февраля 2020

Можете ли вы дать мне отзыв о моих недавних модульных тестах с angular и Жасмин? Я заинтересован в том, чтобы развить свои навыки в отношении написания лучших модульных тестов.

Должен ли мой тест быть в e2e? Могу ли я написать больше модульных тестов для этого компонента? Должен ли я сочетать некоторые из костюмов?

Любой отзыв будет принят!

Спасибо!

component.ts :

export class ReportDatasetCreationComponent implements OnInit, AfterViewChecked, OnDestroy {
  options: TableOptions = {
    sortable: true,
    filterable: true,
    navigateToEnd: true,
    callEvent: () => {
      this.data = [...this.data, { id: '', desc: '', type: 'str', values: '' }];
      this.inputsValid = true;
    },
    displayHeader: true,
    autoHeight: true,
    pagination: {
      length: 50,
      pageSize: 50,
      pageSizeOptions: [50, 100, 150, 200],
    }
  };
  loading = true;
  data: TableData = [];
  mainData;
  inputsValid;
  existingDatasets;
  columns = [];
  datasetDictionaries;
  // dictionaries;
  disableData;
  dataChanged = false;
  baseData;
  name = this.route.snapshot.params.reportName;
  duplicatedReport;
  show = false;
  showOriginRdsName = true;
  readOnly: boolean;
  tableDataObjChanged = false;
  indexOfElementToDelete;
  deleteHierarchyResponse;
  columnLinkedHierarchies = [];

  constructor(
    private route: ActivatedRoute,
    public dialog: MatDialog,
    public snackBar: MatSnackBar,
    private router: Router,
    private reportDatasetsService: ReportDatasetsService,
    public dictionaryService: DictionaryService,
    public datasetsService: DatasetsService,
    private authenticationService: AuthenticationService,
    public notificationsService: NotificationsService
  ) {
  }

  getColumns() {
    return [
      {
        key: 'id', label: 'Column', type: TABLE_COLUMNS_TYPES.INPUT, config: {
          validators: [this.isEmpty, this.isNotUnique],
          disabled: this.isDisabled
        }
      },
      {
        key: 'desc', label: 'Description', type: TABLE_COLUMNS_TYPES.INPUT, cellStyles: { 'width': '30%' }, config: {
          disabled: this.isDisabled
        }
      },
      {
        key: 'type', label: 'Type', type: TABLE_COLUMNS_TYPES.ROW_SELECT, options: this.datasetDictionaries, config: {
          validators: [this.isEmpty],
          disabled: this.isDisabled
        }
      },
      {
        key: 'values', label: 'Applicable Values', type: TABLE_COLUMNS_TYPES.CHIPS, config: {
          disabled: this.isDisabled,
          isHierarchy: (row) => {
            return row.hierarchy ? true : false;
          }
        }
      },
      {
        key: 'manage-app-values',
        label: '',
        type: TABLE_COLUMNS_TYPES.ACTION,
        icon: 'sort',
        onClick: (element) => {
          if (!this.readOnly) { this.openHierarchyModal(element); }
        },
        config: {
          disabled: this.isDisabled,
          isRender: (row) => {
            return !this.isDisabled(row);
          }
        },
      },
      {
        key: 'delete',
        label: '',
        type: TABLE_COLUMNS_TYPES.ACTION,
        icon: 'delete',
        onClick: (element) => {
          if (!this.readOnly) { this.deleteRow(element); }
        },
        config: {
          disabled: this.isDisabled,
          isRender: (row) => {
            return !this.isDisabled(row);
          }
        },
      }
    ];
  }

  isNotUnique = (row, column) => {
    if (this.isDisabled(row)) {
      return null;
    }
    const value = row[column.key];

    const duplicate = this.data.find((dataRow: TableRow) => {
      return row !== dataRow && dataRow[column.key] === value;
    });

    return duplicate ? 'this field should be unique' : null;
  }

  isEmpty = (row, column) => {
    if (this.isDisabled(row)) {
      return null;
    }
    const value = row[column.key];

    return Boolean(value) ? null : '*required';
  }

  isDisabled = (row) => {
    return this.disableData.includes(row);
  }

  ngOnInit() {

    this.authenticationService.readOnly.pipe(first()).subscribe(res => {
      this.readOnly = res;
    });

    this.datasetDictionaries = this.dictionaryService.getDictionary('ReportDataSetDefinition.col_dtypes');
    this.columns = this.getColumns();
    if (!this.reportDatasetsService.isNewRDS) {
      this.getReportDatasetDetails();
    }

    this.reportDatasetsService.$reportDatasetDetails.subscribe((reportDatasetDetails: ReportDataset) => {
      if (reportDatasetDetails) {
        if (reportDatasetDetails['data_in_db_dt'] && reportDatasetDetails['data_in_db_dt'] !== 'NaT') {
          this.reportDatasetsService.data_in_db = true;
        } else { this.reportDatasetsService.data_in_db = false; }
        this.data = Object.assign(reportDatasetDetails.columns);
        this.disableData = this.data.filter((item: any) => {
          return DISABLED_ROWS_IDS.includes(item.id);
        });
        this.loading = false;
      }
    });

    this.reportDatasetsService.loadingStatus
      .subscribe(loadingStatus => {
        this.loading = !isResolved(loadingStatus);
      });

    this.duplicatedReport = this.reportDatasetsService.reportDatasetDetails;
    if ((!this.duplicatedReport) || (!this.duplicatedReport.hasOwnProperty('newName'))) {
      this.show = false;
      this.showOriginRdsName = true;
      this.duplicatedReport = null;
    } else {
      this.show = true;
      this.showOriginRdsName = false;
    }
  }

  ngAfterViewChecked() {

  }

  ngOnDestroy() {

    this.show = false;
    this.showOriginRdsName = true;
    if (this.tableDataObjChanged) {
      this.onSave();
    }
  }

  getReportDatasetDetails() {
    this.reportDatasetsService.getReportDatasetDetails(this.name).subscribe();
  }

  downloadStructure() {
    this.reportDatasetsService.downloadRDSStructure(this.name).subscribe(data => saveAs(data, this.name + '.csv'));
  }

  uploadStructure() {
    const dialogRef = this.dialog.open(UploadDatasetStructureComponent, {
      width: '700px',
      data: {
        datasetInfo: {
          name: this.route.snapshot.params.reportName
        },
      }
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result) {
        const file = result['file'];
        const delimiter = result['delimiter'];
        const reader: FileReader = new FileReader();
        reader.readAsText(file);
        reader.onload = (e) => {
          const csv: string = reader.result as string;
          const lines = csv.split('\n');
          const array = [];
          for (let i = 1; i < lines.length; i++) {
            let arr = [];
            let line = lines[i].trim();
            let lastIndex = null;
            if (line !== '') {
              if (delimiter === ',') {
                if (line[line.length - 1] === ']') {
                  for (let j = line.length - 2; j > 0; j--) {
                    if (line[j] === '[') {
                      lastIndex = j;
                      line = line.slice(0, lastIndex) + '"' + line.slice(lastIndex) + '"';
                      break;
                    }
                  }
                }
                if (line[line.length - 1] === '"') {
                  for (let k = line.length - 2; k > 0; k--) {
                    if (line[k] === '"') {
                      lastIndex = k;
                      arr[3] = line.substring(lastIndex + 1, line.length - 1);
                      arr[3] = JSON.parse(arr[3].replace(/'/g, '"'));
                      line = line.substring(0, lastIndex - 1);
                      break;
                    }
                  }
                } else {
                  arr[3] = '';
                  line = line.substring(0, line.length - 1);
                }
                for (let l = line.length - 1; l > 0; l--) {
                  if (line[l] === ',') {
                    lastIndex = l;
                    arr[2] = line.substring(lastIndex + 1, line.length);
                    line = line.substring(0, lastIndex);
                    break;
                  }
                }

                lastIndex = line.indexOf(',');
                arr[0] = line.substring(0, lastIndex);
                arr[1] = line.substring(lastIndex + 1, line.length);
                if (arr[1].indexOf(',') > -1) {
                  arr[1] = arr[1].substring(1, arr[1].length - 1);
                }
              } else {
                arr = lines[i].split('|');
                arr[3] = arr[3].trim();
                if (arr[3] !== '') {
                  arr[3] = JSON.parse(arr[3].replace(/'/g, '"'));
                }
              }
              const obj = {
                id: arr[0],
                desc: arr[1],
                type: arr[2],
                values: arr[3]
              };
              array.push(obj);
            }
          }
          this.data = array;
          this.disableData = this.data.filter((item: any) => {
            return DISABLED_ROWS_IDS.includes(item.id);
          });
          this.setDataChanged();
        };
      }
    });

  }

  deleteRow(element) {
    this.dialog.open(DeleteConfirmationComponent).afterClosed().subscribe((isConfirmed: boolean) => {
      if (isConfirmed) {
        this.reportDatasetsService.saveReportDatasetDetails(this.data.filter((row) => row !== element)).subscribe(res => {
          if (!(res instanceof HttpErrorResponse)) {
            this.data = this.data.filter((row) => row !== element);
            this.resetBaseData();
            this.openSnackBar();
          }
        });
      }
    });
  }

  rowChange(e) {
    const isNotEmpty = disableButton(this.data, 'id');
    const dublicated = checkDuplicateInObject('id', this.data);
    if (isNotEmpty || dublicated) {
      this.inputsValid = true;
    } else {
      this.inputsValid = false;
    }
    this.setDataChanged();
  }

  openSnackBar() {
    const snackBarRef = this.snackBar.open('Report Dataset has been successfully saved', 'go back to RDS list', {
      duration: 2000
    });
    snackBarRef.onAction().subscribe(() => {
      this.router.navigate(['designer/report-datasets/']);
    });
  }

  goBack() {
    this.reportDatasetsService.isNewRDS = false;
    this.router.navigate(['designer/report-datasets/']);
  }

  onSave() {
    this.reportDatasetsService.saveReportDatasetDetails(this.data).subscribe(res => {
      if (!(res instanceof HttpErrorResponse)) {
        this.resetBaseData();
        this.openSnackBar();
      }
    });
  }

  openUploadModal() {
    this.dialog.open(UploadDatasetComponent, {
      width: '400px',
      data: {
        datasetInfo: {
          name: this.route.snapshot.params.reportName
        },
        isReportDataset: true
      }
    });
  }

  downloadFile() {
    this.reportDatasetsService.checkStatusCsvFile(this.name).subscribe((res) => {
      if (!(res instanceof HttpErrorResponse)) {
        this.reportDatasetsService.downloadRDScsvData(this.name).subscribe(data => saveAs(data, this.name + '.csv'));
      }
    });
  }


  setDataChanged() {
    this.dataChanged = !isEqual(this.data, this.baseData);
  }

  resetBaseData() {
    this.baseData = cloneDeep(this.data);
    this.setDataChanged();
  }

  iconContent(e) {
    if (e.fromElement === null) {
    } else if (e.fromElement.textContent === 'delete') {
      this.datasetsService.toolTipContent = 'Delete';
    } else if (e.fromElement.textContent === 'sort') {
      this.datasetsService.toolTipContent = 'Link/unlink hierarchy';
    }
  }


  openHierarchyModal(el) {

    const dialogRef = this.dialog.open(ManageApplicableValuesComponent, {
      width: '90%',
      data: {
        element: el,
        rdsName: this.route.snapshot.params.reportName,
        columnName: el.id,
        fromAction: 'rdsColumn'
      }
    });
    dialogRef.afterClosed().subscribe(result => {

      if (result === undefined) {
        // User clicked cancel or focus out from the modal

      } else if (result.action === 'save') {
        for (let i = 0; i < this.data.length; i++) {
          if (this.data[i].hierarchy === result.name) {
            const applicableValues = result.rowData.map(a => a.name);
            this.data[i].values = applicableValues;
          }
        }
        this.tableDataObjChanged = true;
        this.setDataChanged();

      } else if (result.action === 'unPairHierarchy') {
        this.data.find((o, i) => {
          if (o.id === el.id) {
            delete this.data[i].hierarchy;
            return true; // stop searching
          }
        });
        this.snackBar.open('Hierarchy un linked from column', null, { duration: 1000 });
        this.tableDataObjChanged = true;

      }

    });

  }

}

spe c .ts file:

fdescribe('ReportDatasetCreationComponent', () => {
  let component: ReportDatasetCreationComponent;
  let fixture: ComponentFixture<ReportDatasetCreationComponent>;
  let service;
  let mySpy;
  let httpMock;
  let de: DebugElement;

  let mockData = {
    columns: [
      {
        desc: 'Unique identifier of event. (Mandatory)',
        id: 'id',
        type: 'str'
      },
      {
        id: 'CURRENCY',
        type: 'str'
      },
      {
        id: 'NAME',
        type: 'str'
      },
      {
        hierarchy: 'coockie',
        id: 'COUNTRY',
        type: 'str',
        values: [
          'a',
          'b',
          'e',
          '123456789123456789123456789123456789123456789123456789123456789123456789'
        ]
      },
      {
        hierarchy: 'q',
        id: 'PRICE',
        type: 'nbr',
        values: [
          'q',
          '1',
          '2'
        ]
      }
    ],
    data_in_db_dt: '2020-01-06T23:21:46.375819',
    desc: '',
    filter_criteria: {},
    name: 'motoDealRds',
    updt_by: 'Shubby dubby',
    updt_on: '2020-01-06T23:21:46.376801'
  };

  let mockMusicShop = {
    columns: [
      {
        desc: 'Unique identifier of event. (Mandatory)',
        id: 'id',
        type: 'str'
      },
      {
        id: 'product',
        type: 'str'
      },
      {
        id: 'price',
        type: 'nbr'
      },
      {
        id: 'currency',
        type: 'str'
      },
      {
        id: 'manufacturer',
        type: 'str'
      }
    ],
    data_in_db_dt: '2020-02-12T19:03:16.755805',
    desc: 'RDS for unit testings',
    filter_criteria: {},
    name: 'music_shop',
    updt_by: 'Nikhil',
    updt_on: '2020-02-12T19:03:16.755805'
  }

  let mockColumnElement = {
    hierarchy: 'company_hierarchy_mock',
    id: 'PRICE',
    type: 'nbr',
    values: ['q', '1', '2']
  };

  let mockHierarchy = {
    name: 'mock hierarchy',
    rowData: [
      { name: 'a', path: ['a'], type: 'folder', id: 1 },
      { name: 'b', path: ['a', 'b'], type: 'folder', id: 2 },
      { name: 'c', path: ['a', 'c'], type: 'folder', id: 3 },
      { name: 'd', path: ['a', 'd'], type: 'folder', id: 4 }
    ]
  };

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [DesignerModule, RouterTestingModule, HttpClientTestingModule],
      providers: [
        AuthenticationService,
        DictionaryService,
        MockBackend,
        BaseRequestOptions,
        {
          provide: Http,
          useFactory: (mockBackend: MockBackend, defaultOptions: RequestOptions) => {
            return new Http(mockBackend, defaultOptions);
          },
          deps: [MockBackend, BaseRequestOptions]
        }
      ]
    })
      .compileComponents();
  }));

  beforeEach(() => {
    // ReportDatasetCreationComponent.prototype.ngOnInit = () => { };

    fixture = TestBed.createComponent(ReportDatasetCreationComponent);
    component = fixture.componentInstance;
    component.dictionaryService = new DictionaryServiceMock(TestBed.get(HttpClient));
    service = TestBed.get(ReportDatasetsService);
    httpMock = TestBed.get(HttpTestingController);
    de = fixture.debugElement;
    component.name = 'motoDealRds';
    mySpy = spyOn(service, 'getReportDatasetDetails').and.callThrough();
    fixture.detectChanges();
  });

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

  });

  it('should spyOn getReportDatasetDetails and return fake data', () => {

    service.$reportDatasetDetails.next(mockMusicShop);
    // this function should be called in ngOnInit once
    expect(service.getReportDatasetDetails).toHaveBeenCalledTimes(1);

    // Should enter the if condition after the response
    expect(component.reportDatasetDetailsResponse).toEqual(jasmine.objectContaining({
      data_in_db_dt: '2020-02-12T19:03:16.755805'
    }));

    // Since the response contain data, the data_in_db should be true:
    // expect(service.data_in_db).toBeTruthy();

  });


  it('Should return different results since the mockData will be change', () => {

    // Now, let's try to changed the data:
    service.$reportDatasetDetails.next(undefined);

    // this function should be called in ngOnInit once
    expect(service.getReportDatasetDetails).toHaveBeenCalledTimes(1);

    // The if condition should not pass and should not pass and go to the else scope:
    expect(service.data_in_db).toBeFalsy();

  });

  // mock a duplicate report and test the if conditions and the variables inside
  it('Should test the if conditions and the variables inside', () => {
    component.duplicatedReport = mockMusicShop;
    expect(component.show).toBeFalsy();
    expect(component.showOriginRdsName).toBeTruthy();
    expect(component.show).toBeFalsy();

  });

  it('Should call ngOnDestroy and test the state of the variables', () => {
    component.ngOnDestroy();
    expect(component.show).toBeFalsy();
    expect(component.showOriginRdsName).toBeTruthy();
    expect(component.tableDataObjChanged).toBeFalsy();

  });

  it('Should call ngOnDestroy and test the state of the variables, but this time, expect to call onSave once', () => {
    service.reportDatasetDetails = mockMusicShop;
    component.tableDataObjChanged = true;
    const onSaveSpy = spyOn(component, 'onSave').and.callThrough();
    component.ngOnDestroy();
    expect(onSaveSpy).toHaveBeenCalledTimes(1);

  });

  it('Should call onSave with mock data', () => {

    service.$reportDatasetDetails.next(mockMusicShop);
    service.reportDatasetDetails = mockMusicShop;
    const onSaveSpy = spyOn(component, 'onSave').and.callThrough();
    const saveReportDatasetDetailsSpy = spyOn(service, 'saveReportDatasetDetails').and.returnValue(of(null));
    const resetBaseDataSpy = spyOn(component, 'resetBaseData').and.callThrough();
    const openSnackBarSpy = spyOn(component, 'openSnackBar').and.callThrough();
    component.onSave();
    expect(onSaveSpy).toHaveBeenCalledTimes(1);
    expect(saveReportDatasetDetailsSpy).toHaveBeenCalledTimes(1);

    // Since the response is not en error object, it should call resetBaseData and openSnackBar

    expect(resetBaseDataSpy).toHaveBeenCalledTimes(1);
    expect(openSnackBarSpy).toHaveBeenCalledTimes(1);


  });

  it('Should call onSave with mock data, but this time the response will not pass the if condition', () => {

    service.$reportDatasetDetails.next(mockMusicShop);
    service.reportDatasetDetails = mockMusicShop;

    const onSaveSpy = spyOn(component, 'onSave').and.callThrough();
    const saveReportDatasetDetailsSpy = spyOn(service, 'saveReportDatasetDetails').and.callThrough();
    const resetBaseDataSpy = spyOn(component, 'resetBaseData').and.callThrough();
    const openSnackBarSpy = spyOn(component, 'openSnackBar').and.callThrough();

    component.onSave();

    expect(onSaveSpy).toHaveBeenCalledTimes(1);
    expect(saveReportDatasetDetailsSpy).toHaveBeenCalledTimes(1);

    // Since the response is undefined, it should not call resetBaseData and openSnackBar

    expect(resetBaseDataSpy).toHaveBeenCalledTimes(0);
    expect(openSnackBarSpy).toHaveBeenCalledTimes(0);


  });

});

1 Ответ

1 голос
/ 19 февраля 2020

Хорошо, я подумал добавить комментарий к этому вопросу, но поскольку это больше, чем параграф, я ставлю его в качестве ответа.

Для начала вы можете взглянуть на эту серию статей, которые я написал только для модульного тестирования . В конце этой статьи есть ссылки, прикрепленные к нему.

Подводя итог:

  1. Основное внимание при модульном тестировании это, чтобы убедиться, что мы сосредоточены на изоляции наших компонент и проверить его функции и поведение.

  2. Вы не можете ожидать, что охватите все случаи в модульном тесте. Чтобы проверить, как ваши компоненты реагируют при взаимодействии с внешними событиями и зависимыми компонентами, вы можете сфокусировать эти точки в тестовых случаях e2e.

  3. в прилагаемом коде (что слишком долго для меня обеспечить обратную связь по каждой области компонента), я могу сказать, что вы должны прежде всего сосредоточиться на том, правильно ли инициализируются переменные, функции вызываются, эти функции работают правильно, соответствующие изменения HTML отражаются соответствующим образом. например,

    • this.columns
    • this.readOnly
    • , вызывается ли getReportDatasetDetails() в зависимости от this.reportDatasetsService.isNewRDS
    • this.data
    • this.showOriginRdsName
    • this.duplicatedReport
    • , вызван ли rowChange() и внесены ли ожидаемые изменения
    • и т. Д. *

Практически невозможно протестировать приватную переменную и функции

Также обратите внимание на покрытие branch и line с его отличиями. Просто писать все больше и больше it блока также не лучшая практика. Также важно отметить отдельную ответственность за тестирование unit и e2e.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...