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.
The typeahead should:
- If the
FormControl
associated with the typeahead has a value, that value should be “selected” in the ui initially. - If the
FormControl
associated with the typeahead gets a new value from calling a method on theFromControl
the ui should be updated to “select” the new value - If a new value is selected in the typeahead ui, the associated
FormControl
should receive the new value from the change handler - If the typeahead is interacted with but no value is selected/changed, the
FormControl
should get a touch event - The component should have appropriate ng-validation classes - ng-dirty, ng-touched, etc
- If a value is cleared from the typeahead ui, the associated
FormControl
should receive the null value - The component should have appropriate ng-validation classes - ng-dirty, ng-touched, etc
- 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
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.
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:
- If the
FormControl
associated with the star rating component has a value, that corresponding star should be “selected” in the ui initially. - If the
FormControl
associated with the star rating component gets a new value from calling a method on theFormControl
the ui should be updated to “select” the new corresponding star - When a star is clicked the corresponding FormControl should be updated with the chosen value
- 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);
});