'How to test form controls avoiding bracket notation to preserve type checking and maintainability

The Problem

My unit tests on a form break when I refactor an object's member because I can only access the formcontrols on a fromgroup by using bracket notation. This string based selection of the member under test will not get refactored with the code by any IDE.

Here's an example...

The object:

class Task {
  constructor(
    public id: number = 0,
    public description: string = '',    // <--- member under test
    public completed: boolean = false,
    public priority: Priority = Priority.Normal
  ) {}

  clone(): Task {
    return new Task(this.id, this.description, this.completed, this.priority);
  }
}

The component:

export class AppComponent implements OnInit {
  form!: FormGroup;
  task: Task;
  priorities = [Priority.Low, Priority.Normal, Priority.High];

  constructor(private fb: FormBuilder) {
    this.task = this.mockTask();
  }

  ngOnInit() {
    this.task = this.mockTask();
    this.form = this.fb.group({
      description: [
        this.task.description,
        Validators.compose([Validators.required, Validators.maxLength(50)]),
      ],
      priority: [this.task.priority],
    });
  }

  // ...
}

The test:

describe('AppComponent', () => {
  let comp: AppComponent
  let fixture: ComponentFixture<AppComponent>;
  let task: Task;
  let taskService: TaskService;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [ReactiveFormsModule],
      declarations: [AppComponent],
      providers: [{provide: TaskService, useClass: MockTaskService}]
    }).compileComponents()
  }))

  beforeEach((done) => {
    fixture = TestBed.createComponent(AppComponent);
    comp = fixture.componentInstance;

    taskService = fixture.debugElement.injector.get(TaskService);
    taskService.getAllTasks().subscribe(tasks => {
      task = tasks[0];
      comp.task = task;
      comp.ngOnInit();
      fixture.detectChanges();
      done()
    })
  })

  it('should not update value if form is invalid', () => {
    let spy = spyOn(taskService, 'updateTask')

    comp.form.controls['description'].setValue(null) // <------

    comp.onSubmit()

    expect(comp.form.valid).toBeFalsy();
    expect(spy).not.toHaveBeenCalled();
  })
})

Note the bracket notation next to the arrow. I would like to be able to use this somehow:

comp.form.controls.description.setValue(null)

What I Have Tried

I have tried creating some interface, e.g:

interface ITask {
  description: string;
}

interface ITaskFormGroup extends FormGroup {
  value: ITask;
  controls: {
    description: AbstractControl;
  };
}

The problem with this is the controls are of type AbstractControl and the member of Task in question is of type string. So 'controls' can't implement ITask as a common interface.

Also, this:

"compilerOptions": {
    "noPropertyAccessFromIndexSignature": false,
    // ...
}

... allows for a dot-notation, but that would still not refactor the test when I change the member 'description' in Task or ITask.

I feel like this problem should have some solution, but I could use some help getting there. I would like to know what effort it would take so I can see if it is worth it in the long run.

Full Example

Although I don't think you can use a Test runner on Stackblitz, I provided a more complete example. To solve the syntax problem running the test should not be required.



Solution 1:[1]

The proper way of getting a form control from a FormGroup is to use its get function:

const descControl = comp.form.get('description')
descControl.setValue(null)

this way if the form control is missing, at least your tests will raise an error about it.

The angular team has implemented strong typed forms. That can also help you with improving testing if you have the patience to wait for angular 14.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 The Fabio