Testing Forms Using the CVA

How to test Angular forms with components implementing Control Value Accessor Interface.

September 10, 2019

You’ve mastered the Control Value Accessor, now you need to learn how to unit test components implementing it. For good unit tests we care about one main thing - that the parent component where the formControl is instantiated and the child component implementing the CVA are communicating as expected. Secondarily, we also want to ensure that validation functionality happens as expected so we can manage behavior based on the validity state and display relevant information to the user.

Let’s consider my galaxy rating app from this blog post.

There are two components implementing the Control Value Accessor interface with different concerns. First, let’s consider the typeahead and the business requirements around it.

TypeAhead CVA Example

This typeahead displays a galaxy name for the user to select, but transmits an id as the form value. I’ve used the typeahead provided by the NGX Bootstrap library because I’m a busy girl and don’t have time to reinvent the wheel.

typeahead demo

The typeahead should:

  1. If the FormControl associated with the typeahead has a value, that value should be “selected” in the ui initially.

  2. If the FormControl associated with the typeahead gets a new value from calling a method on the FromControl the ui should be updated to “select” the new value

  3. If a new value is selected in the typeahead ui, the associated FormControl should receive the new value from the change handler

  4. If the typeahead is interacted with but no value is selected/changed, the FormControl should get a touch event

  5. The component should have appropriate ng-validation classes - ng-dirty, ng-touched, etc

  6. If a value is cleared from the typeahead ui, the associated FormControl should receive the null value

  7. The component should have appropriate ng-validation classes - ng-dirty, ng-touched, etc

  8. The component acts as a regular formControl with validity state and corresponding ng-valid/ng-invalid CSS classes.

Listing these “requirements” out gives us key areas to begin writing unit tests around. Easyish enough. The tough part is setting up the parent component and the CVA component to test their communications.

We can do this by creating a “host” component to initialize our FormControl in and in the template render our “child” component where we’ve implemented the CVA interface.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, ViewChild } from '@angular/core';

import { TypeaheadComponent } from './typeahead.component';
import { TypeaheadModule } from 'ngx-bootstrap/typeahead';
import { FormsModule, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  template: '<gr-typeahead [data]="testData" [formControl]="galaxy"></gr-typeahead>'
})
class TestHostComponent {
  @ViewChild(TypeaheadComponent)
  public typeaheadComponent: TypeaheadComponent;

  public testData = [{id: 1, name: 'foo'},{id: 2, name: 'bar'}];
  public galaxy: FormControl = new FormControl({value: null, disabled: false});
}

describe('TypeaheadComponent', () => {
  let hostFixture: ComponentFixture<TestHostComponent>;
  let testHostComponent: TestHostComponent;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ TypeaheadComponent, TestHostComponent ],
      imports: [
        TypeaheadModule.forRoot(),
        FormsModule,
        ReactiveFormsModule
      ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    hostFixture = TestBed.createComponent(TestHostComponent);
    testHostComponent = hostFixture.componentInstance;
    hostFixture.detectChanges();
  });

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

With access to the testHostComponent and to the Typeahead component we can write the tests described above.

As a reminder, the writeValue method of a component implementing the CVA is called in two different scenarios: when the formControl is initialized with a value, or when a setValue/patchValue method is called to change the formControls’ value. We can be extra thorough by testing for both scenarios, and ensuring that we’re using the NGX Bootstrap TypeAhead API appropriately.

If the FormControl associated with the typeahead has an initial value, that value should be “selected” in the UI initially

it('should select dropdown based on initial formControl value', () => {
  expect(testHostComponent.typeaheadComponent.selected).toEqual('bar');
});

If the FormControl associated with the typeahead gets a new value from calling a method on the FormControl the UI should be updated to “select” the new value

it('should select dropdown based on patched formControl value', () => {
  testHostComponent.galaxy.patchValue(1);
  hostFixture.detectChanges();
  expect(testHostComponent.typeaheadComponent.selected).toEqual('foo');
});

Next we make sure that as we interact with the typeahead, the appropriate form handlers are called.

If a new value is selected in the typeahead ui, the associated FormControl should receive the new value from the change handler

it('should send a change event with the new value when item is selected with new value', () => {
  testHostComponent.typeaheadComponent.selected = {
    id: 2,
    name: 'foo'
  }
  testHostComponent.typeaheadComponent.onSelect({item: {id: 3}});
  hostFixture.detectChanges();
  expect(testHostComponent.galaxy.value).toBe(3);
});

If the typeahead is interacted with but no value is selected/changed, the FormControl should get a touch event

Never trust a test you haven't seen fail

it('should send a touch event when item is selected', ()=> {
  testHostComponent.typeaheadComponent.selected = {
    id: 2,
    name: 'foo'
  }
  const compiled = hostFixture.debugElement.nativeElement;
  let component = compiled.querySelector('gr-typeahead');
  component.querySelector('input').dispatchEvent(new Event('blur'));
  hostFixture.detectChanges();
  expect(testHostComponent.galaxy.touched).toBe(true);
  expect(component.classList.contains('ng-touched')).toBe(true);
});

If a value is cleared from the typeahead ui, the associated FormControl should receive the null value

This test isn’t about implementing the CVA interface properly but handling different ways users can manipulate the NGX bootstrap typeahead and ensuring we account for handling relaying those scenarios to appropriate value capturing behavior.

testHostComponent.typeaheadComponent.selected = {
  id: 2,
  name: 'foo'
}
testHostComponent.typeaheadComponent.onSelect({item: {id: 3}});
expect(testHostComponent.galaxy.value).toBe(3);
testHostComponent.typeaheadComponent.selected = null;
const compiled = hostFixture.debugElement.nativeElement;
compiled.querySelector('gr-typeahead input').dispatchEvent(new Event('blur'));
expect(testHostComponent.galaxy.value).toBe(null);

The component acts das a regular formControl with validity state and corresponding ng-valid/ng-invalid CSS classes

it('should set invalid class when field is required, has been touched, and has no value', () => {
  testHostComponent.galaxy.setValidators([Validators.required]);
  testHostComponent.typeaheadComponent.selected = null;
  const compiled = hostFixture.debugElement.nativeElement;
  let component = compiled.querySelector('gr-typeahead');
  expect(testHostComponent.galaxy.touched).toBe(false);
  expect(component.classList.contains('ng-invalid')).toBe(false);
  component.querySelector('input').dispatchEvent(new Event('blur'));
  hostFixture.detectChanges();
  expect(testHostComponent.galaxy.touched).toBe(true);
  expect(component.classList.contains('ng-invalid')).toBe(true);
});

Some of these tests may seem like “overkill” when looking at the simplicity of what we’re testing, but I like this level of protection when multiple team members are working across features and may have different levels of understanding of the codebase, especially with lesser known parts of the Reactive Forms API like the control value accessor.

Star Rating CVA Example

The next example of a component using the CVA in this repo is the star rating component. As user can hover over and select stars to selecting a rating value.

star rating demo

This component is a bit different, as we’re crafting a completely new UI from scratch and need to handle additional interaction examples - like what should happen if the FormElement is disabled.

Business requirements:

  1. If the FormControl associated with the star rating component has a value, that corresponding star should be “selected” in the ui initially.
  2. If the FormControl associated with the star rating component gets a new value from calling a method on the FormControl the ui should be updated to “select” the new corresponding star
  3. When a star is clicked the corresponding FormControl should be updated with the chosen value
  4. If the FormControl is disabled, a user should not be able to interact with the UI to select a star.

We’ll use a similar setup as before with a parent host component where we instantiate our star rating component.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, ViewChild } from '@angular/core';

import { StarRaterComponent } from './star-rater.component';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  template: '<gr-star-rater [formControl]="rating"></gr-star-rater>'
})
class TestHostComponent {
  @ViewChild(StarRaterComponent)
  public starRaterComponent: StarRaterComponent;

  public rating: FormControl = new FormControl({value: null, disabled: false});
}

describe('StarRaterComponent', () => {
  let hostFixture: ComponentFixture<TestHostComponent>;
  let testHostComponent: TestHostComponent;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ StarRaterComponent, TestHostComponent ],
      imports: [ ReactiveFormsModule ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    hostFixture = TestBed.createComponent(TestHostComponent);
    testHostComponent = hostFixture.componentInstance;
    hostFixture.detectChanges();
  });

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

});

If the FormControl associated with the star rating component has a value, that corresponding star should be “selected” in the ui initially

it('should set the star rating based on the formControl value', () => {
 testHostComponent.rating.patchValue(3);
 hostFixture.detectChanges();
 expect(testHostComponent.starRaterComponent._value).toEqual(3);
 const compiled = hostFixture.debugElement.nativeElement;
 let selectedStars = compiled.querySelectorAll('gr-star-rater .selected');
 expect(selectedStars.length).toEqual(3)
});

When a star is clicked the corresponding FormControl should be updated with the chosen value

Clicking our stars doesn’t relate to a strict “blur” like event, so the star rating component calls the touched function just when a star is clicked.

it('should call touched when star selected', () => {
 const compiled = hostFixture.debugElement.nativeElement;
 let star1 = compiled.querySelector('gr-star-rater .stars .star');
 star1.dispatchEvent(new Event('click'));
 hostFixture.detectChanges();
 expect(compiled.querySelector('gr-star-rater').classList.contains('ng-touched')).toBe(true);
});

it('should call change with rating value when star selected', () => {
 const compiled = hostFixture.debugElement.nativeElement;
 let star3 = compiled.querySelectorAll('gr-star-rater .stars .star')[2];
 star3.dispatchEvent(new Event('click'));
 hostFixture.detectChanges();
 expect(testHostComponent.rating.value).toBe(3);
});

If the FormControl is disabled, a user should not be able to interact with the UI to select a star

With a disabled FormControl we not only want to ensure that a user cannot interact with the UI, but it is visually conveyed they cannot interact with the form control.

it('should set disabled class when formControl is disabled', () => {
 testHostComponent.rating.disable();
 hostFixture.detectChanges();
 const compiled = hostFixture.debugElement.nativeElement;
 let stars = compiled.querySelector('gr-star-rater .stars');
 expect(stars.classList.contains('disabled')).toBe(true);
});

it('should remove disabled class when formControl is enabled', () => {
 testHostComponent.rating.disable();
 hostFixture.detectChanges();
 const compiled = hostFixture.debugElement.nativeElement;
 let stars = compiled.querySelector('gr-star-rater .stars');
 expect(stars.classList.contains('disabled')).toBe(true);
 testHostComponent.rating.enable();
 hostFixture.detectChanges();
 expect(stars.classList.contains('disabled')).toBe(false);
});

it('should not call touch or change events when disabled', () => {
 testHostComponent.rating.patchValue(3);
 testHostComponent.rating.disable();
 hostFixture.detectChanges();
 const compiled = hostFixture.debugElement.nativeElement;
 let star1 = compiled.querySelector('gr-star-rater .stars .star');
 star1.dispatchEvent(new Event('click'));
 hostFixture.detectChanges();
 expect(testHostComponent.rating.value).toBe(3);
});