Nir
Nir

Reputation: 17

Am I creating proper unit tests with Angular and Jasmine?

Can you please give me feedback regards my recent unit tests with angular and Jasmine? I am interested to develop my skills regard to writing better unit tests.

Should some of my test be in the e2e? Can I write more unit tests for this component? Should I combine some of the suits?

Any feedback will accept!

Thank you!

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;

      }

    });

  }

}

spec.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);


  });

});

Upvotes: 0

Views: 1228

Answers (1)

Shashank Vivek
Shashank Vivek

Reputation: 17494

Ok, I thought to put a comment on this question but since it is more than a para, I am putting it as an answer.

To start with, you can take a look at this series of articles which I wrote just for unit testing . There are links attached to it at the bottom of this article.

To summarize:

  1. The main focus during unit testing it to make sure that we focus in isolating our component and test it functions and behavior.

  2. You can't expect to cover all cases in unit test. To test how your components reacts while interacting with external events and dependent components, you can focus those points in e2e test cases.

  3. In the provided code (which is way too long for me to provide feedback on each area of component), I can say that you should primarily focus on whether the variables are initialized properly ,functions are being called, those functions are working properly , respective HTML changes are reflected accordingly. such as

    • this.columns
    • this.readOnly
    • whether getReportDatasetDetails() is called depending on this.reportDatasetsService.isNewRDS
    • this.data
    • this.showOriginRdsName
    • this.duplicatedReport
    • whether rowChange() is called and make expected changes
    • and so on...

Its near impossible to test private variable and functions

Please also look into branch and line coverage with its differences. Just writing more and more it block is also not a best practice. To mark the separate responsibility of unit and e2e testing is also important.

Upvotes: 1

Related Questions