Search code examples
angularangular-formsangular-formbuilder

Angular FormBuilder is undefined in unit tests


I have the following component:

.ts:

import {Component, Inject} from '@angular/core';
import {FormBuilder, Validators} from '@angular/forms';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {IValueAndCurrency} from '@model/IValueAndCurrency';

@Component({
  selector: 'app-top-up-amount-change-dialog',
  templateUrl: './top-up-amount-change-dialog.component.html',
  styleUrls: ['./top-up-amount-change-dialog.component.scss'],
})
export class TopUpAmountChangeDialogComponent {
  topupAmountFormGroup = this.fb.group({
    topupAmount: [this.data.topupAmount, {
      validators: [
        Validators.required,
        Validators.min(0),
      ]
    }],
  })

  constructor(
    private dialogRef: MatDialogRef<TopUpAmountChangeDialogComponent>,
    private fb: FormBuilder,
    @Inject(MAT_DIALOG_DATA)
    public data: {
      topupAmount: IValueAndCurrency;
      possibleAmounts: IValueAndCurrency[];
    },
  ) {
  }

}

Unit test:

import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {TopUpAmountChangeDialogComponent} from './top-up-amount-change-dialog.component';
import {FormBuilder, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {HttpClientModule} from '@angular/common/http';
import {MATERIAL_MODULES} from 'src/app/strong textapp.consts';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {APP_CONFIG} from "../../../../../../../appConfig.injectortoken";
import {TestAppConfigJson} from "../../../../../../app.component.spec";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {IValueAndCurrency} from "../../../../../../common/models/IValueAndCurrency";
import {Currency} from "../../../../../../common/models/enums/currency.enum";

fdescribe('TopUpAmountChangeDialogComponent', () => {
  let component: TopUpAmountChangeDialogComponent;
  let fixture: ComponentFixture<TopUpAmountChangeDialogComponent>;
  const matRefSpy = jasmine.createSpyObj('matRefSpy', ['close']);
  const dialogData: { topupAmount: IValueAndCurrency, possibleAmounts: IValueAndCurrency[] } = {
    topupAmount: {
      value: 10,
      currencyCode: Currency.EUR
    },
    possibleAmounts: [
      {
        value: 0,
        currencyCode: Currency.EUR
      },
      {
        value: 0,
        currencyCode: Currency.USD
      }
    ]
  };

  beforeEach(waitForAsync(() => {
    TestBed.configureTestingModule({
      imports: [
        FormsModule,
        BrowserAnimationsModule,
        MATERIAL_MODULES,
        ReactiveFormsModule,
        HttpClientModule,
      ],
      declarations: [TopUpAmountChangeDialogComponent],
      providers: [
        FormBuilder,
        {provide: APP_CONFIG, useValue: TestAppConfigJson},
        {provide: MatDialogRef, useValue: matRefSpy},
        {provide: MAT_DIALOG_DATA, useValue: dialogData},
      ],
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(TopUpAmountChangeDialogComponent);
    component = fixture.componentInstance;
    component.data = dialogData;
    fixture.detectChanges();
  });

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

If I try to run it, I get the following error:

hrome 110.0.0.0 (Linux x86_64) TopUpAmountChangeDialogComponent should create FAILED
        TypeError: Cannot read properties of undefined (reading 'group')
            at new TopUpAmountChangeDialogComponent (src/app/modules/.../top-up-amount-change-dialog/top-up-amount-change-dialog.component.ts:12:34)

If I move the formControl initialization to the constructor, it works properly. But I don't want to do that: that case I would have to declare my form as an untyped FormGroup<any> and therefore I would loose the type safety this provides (more about this here, "Avoid This Common Typed Forms Pitfall".

Edit: A correction: I could keep the type information with moving the initialization to the constructor or to ngOnInit, however, for that I would need to explicitly describe the interface which I feel like is a code duplication: the formBuilder could get the type implicitly. So you either do the solution I posted below, or follow this recommendation and add the type information manually.


Solution

  • The solution is to not to use the injected FormBuilder instance, but in fact, create a new one:

    export class TopUpAmountChangeDialogComponent {
      topupAmountFormGroup = new FormBuilder().group({
        topupAmount: [this.data.topupAmount, {
          validators: [
            Validators.required,
            Validators.min(0),
          ]
        }],
      })
    ...
    
    

    It is only implicitly described in the documentation and I thought that it should be more clear that in this case we should do this.