In this post, we’ll look into several components – Material Input, Mat Form Field and Autocomplete. We also take a peek at form validation. In our example below, we ask a user for a name, city and state. We help autocomplete the U.S. state, and validate the city entered based on the state selection.
What do we use an Input for? Lots of things! It’s probably the most used element for a form. With that, the Material.io site tells us that
Text fields allow users to enter text into a UI. They typically appear in forms and dialogs.
https://material.io/components/text-fields/#
What is a Material Form Field then? From the Angular Material site it says
<mat-form-field>
is a component used to wrap several Angular Material components and apply common Text field styles such as the underline, floating label, and hint messages.…
The following Angular Material components are designed to work inside a
<mat-form-field>
:https://material.angular.io/components/form-field/overview
<input matNativeControl>
&<textarea matNativeControl>,<select matNativeControl>
,<mat-select>,<mat-chip-list>
And finally, looking at the Angular Material site on AutoComplete
The autocomplete is a normal text input enhanced by a panel of suggested options.
https://material.angular.io/components/autocomplete/overview
Now let’s dig in to building our solution and testing it!
The Code
Our first action is to add the following modules from @angular/material to our module.ts file
FormsModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatAutocompleteModule
Now that we have those modules in place, we have a piece of CSS to format our form with one element below the other.
mat-form-field { display: block; width: 25%; }
In our HTML file, we set up a form that takes in a name, a City and a State value. The City and State are are both validated at the form level. Our solution ends up a bit backwards and has multiple flaws. The way we handle validation is to fill in the State value using autocomplete as the first action. We then validate the city value from a list of cities within the state object. We’ll see how that works when we look at our .ts file with our code.
Other than our validation, the form is a straight forward reactive form. The submit button is disabled until validation passes, and we output any errors in a span below the form for testing purposes.
Our autocomplete sits in the State field. The code for autocomplete looks at an observable, so we use the async pipe in our autocomplete element.
<mat-autocomplete #auto="matAutocomplete"> <mat-option *ngFor="let option of filteredOptions | async" [value]="option">{{option}}</mat-option> </mat-autocomplete>
The autcomplete activation on a field is simple. We add the [matAutocomplete] property, pointing to our template reference variable for the autocomplete element. In this case it’s #auto.
<input matInput required formControlName="stateField" type="text" [matAutocomplete]="auto" />
Here is the full HTML file.
<form [formGroup]="addressForm" (submit)="onSubmit()"> <!-- name --> <label for="name" >Name <mat-form-field id="name" appearance="outline"> <input matInput formControlName="nameField" /> </mat-form-field> </label> <!-- City --> <label for="city" >City <mat-form-field id="city" appearance="outline"> <input matInput required formControlName="cityField" /> </mat-form-field> </label> <!-- state - autofill --> <label for="state" >State <mat-form-field id="state" appearance="outline"> <input matInput required formControlName="stateField" type="text" [matAutocomplete]="auto" /> </mat-form-field> </label> <!-- disable if required fields aren't filled --> <button [disabled]="!addressForm.valid">Submit</button> </form> <span>Current form errors: {{ addressForm.errors | json}}</span> <mat-autocomplete #auto="matAutocomplete"> <mat-option *ngFor="let option of filteredOptions | async" [value]="option">{{option}}</mat-option> </mat-autocomplete>
Now on to our code. We first import any modules we need, which includes our rxjs Observable, map, and startWith operators. We then set up our variables, including injecting a FormBuilder object into the constructor. You’ll notice that our options object is short and includes only two states. This is done in the article to save space. In the code we list all US states and “major” cities in each state.We then set up an observable called filteredOptions which is used in our form to populate the autocomplete options available.
constructor(private fb: FormBuilder) {} addressForm: FormGroup; options = [ { state: 'Alabama', city: ['Birmingham', 'Montgomery', 'Huntsville', 'Mobile'], }, { state: 'Alaska', city: ['Anchorage'] } ]; filteredOptions: Observable<string[]>;
In ngOnInit we set up our reactive form, and add a validator to the formgroup that we define in the code. We then subscribe to the stateField value changes, and pipe it through two methods. The Javascript method startsWith() determines if a string starts with a certain value. In our case, we use an empty string. This allows us to show the full list of autocomplete options even if nothing is typed into the focused control.
ngOnInit() { this.addressForm = this.fb.group({ nameField: [''], cityField: [''], stateField: [''], }, {validators: [this.validateCity]}); this.filteredOptions = this.addressForm.controls.stateField.valueChanges.pipe( startWith(''), map((value) => this._filterState(value)) ); }
Our pipe also maps data using the _filterState() method. This method takes in the value from our stateField, as it’s typed, and returns a value from our options variable which has been mapped to only include the state values that match what is being typed.
private _filterState(value: string): string[] { const filterValue = value.toLowerCase(); return this.options .map((option) => option.state) .filter((option) => option.toLowerCase().includes(filterValue)); }
Next we’ll look at our validator. While this validator setup wouldn’t be nice in the real world, we use it to show how to use an autocomplete in one field, and then validate another field based on the value chosen. What makes this “not nice”? We ask for the city value first, and then validate that value against another value we choose later. This is confusing, at least it is to me. One way we could change this is moving the state field above the city field, but this is not intuitive to users in the U.S. who would choose their city before their state.
With the above in mind, let’s get through our validateCity code. To create a validator, we set up a variable that contains an anonymous method. We take in the control we want to validate, in our case it will end up being our FormGroup. We then set up two variables with contain the values of our stateField and cityField. if the stateValue has some data, we filter our options based on the state attribute in the objects, and return the cities array attribute in the object we find. To make the validation work, we then check that stateValue and cityValue exist, and if the city array does not include the value in our cityField control, we return an error. Errors in validators are returned as an object, with the error type as the attribute, and the error text as the value. If we match, we return null.
validateCity = (control: FormGroup) => { const stateValue = control.get('stateField'); const cityValue = control.get('cityField'); let cityOptions; if (stateValue.value !== null) { cityOptions = this.options.filter((option) => option.state.toLowerCase().includes(stateValue.value.toLowerCase()) )[0].city; } return stateValue && cityValue && !cityOptions.includes(cityValue.value) ? {noMatchingCity: 'The state has no city by that name defined'} : null; }
While the nature of validation is fine, as we mentioned above our implementation on the front end leaves a lot to be desired. In this instance, we ask the user to blindly enter a city value, and we don’t validate that value until we fill in the state field from the autocomplete. We could swap those fields, but in the U.S. it would not be intuitive to fill in the state prior to the city. Despite this, we’ll leave it as it is for this exercise.
And here is our full code file.
import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; @Component({ selector: 'app-input-formfield-autocomplete-ut', templateUrl: './input-formfield-autocomplete-ut.component.html', styleUrls: ['./input-formfield-autocomplete-ut.component.css'], }) export class InputFormfieldAutocompleteUtComponent implements OnInit { constructor(private fb: FormBuilder) {} addressForm: FormGroup; options = [ { state: 'Alabama', city: ['Birmingham', 'Montgomery', 'Huntsville', 'Mobile'], }, { state: 'Alaska', city: ['Anchorage'] } ]; filteredOptions: Observable<string[]>; ngOnInit() { this.addressForm = this.fb.group({ nameField: [''], cityField: [''], stateField: [''], }, {validators: [this.validateCity]}); this.filteredOptions = this.addressForm.controls.stateField.valueChanges.pipe( startWith(''), map((value) => this._filterState(value)) ); } onSubmit() { console.log('Submitted ', this.addressForm.value); } private _filterState(value: string): string[] { const filterValue = value.toLowerCase(); return this.options .map((option) => option.state) .filter((option) => option.toLowerCase().includes(filterValue)); } validateCity = (control: FormGroup) => { const stateValue = control.get('stateField'); const cityValue = control.get('cityField'); let cityOptions; if (stateValue.value !== null) { cityOptions = this.options.filter((option) => option.state.toLowerCase().includes(stateValue.value.toLowerCase()) )[0].city; } return stateValue && cityValue && !cityOptions.includes(cityValue.value) ? {noMatchingCity: 'The state has no city by that name defined'} : null; } }
The Tests
Now on to what we’re here for! Let’s look at our tests.
We begin as we always should, importing the needed modules to get our functionality to work. In this case we have an entire list of imports. We need the MatInputModule, MatFormFieldModule, and MatAutocompleteModule for the items we are focused on testing. In addition we need some modules to make the base functionality work. The Forms and ReactiveForms modules, the BrowserAnimationsModule to handle some of our event animations, and the CommonModule for our pipe.
imports: [ MatInputModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, BrowserAnimationsModule, MatAutocompleteModule, CommonModule, ],
We also set up a single testState object here, based on the values from our code. We could add multiple states in here, or have another attribute with bad data to test our error handling. For us, we’re going the easy route and only including the single attribute.
const testState = { state: 'Illinois', city: ['Chicago', 'Aurora', 'Naperville', 'Joliet'], };
Let’s look at our first test, the state input’s autocomplete functionality. Our autocomplete options live in the cdk-overlay-container, in div.mat-autocomplete-panel -> mat-option. And inside of the mat-option element the span.mat-option-text element contains the actual value of the option. This is like the mat-select, and we end up testing this much like we would the mat-select.
In our test, we use async/await to be sure that all events are completed before checking that our cdk values are in place. We start the test by grabbing the options attribute from our component, and pulling out only the state names. We use this const in a minute to validate our control.
const stateOptions = component.options.map((option) => option.state);
We then grab our state field, and click on it. As we know from the walk through of the code above, once our input is in focus, the autocomplete code will trigger and show our full list of options (states).
const stateClicker = fixture.debugElement.query( By.css('mat-form-field#state input') ).nativeElement; stateClicker.click(); fixture.detectChanges();
Once our fixture stabilizes, and all promises are returned, we dig in and grab the mat-autocomplete-panel from the cdk overlay. As we discussed above, the mat-autocomplete-panel is the parent element of our mat-option elements. Because of this, we then loop through all of the children returned, our mat-option elements, and we grab the ng-reflect-value of each to test against our list of states in stateOptions. We use the ng-reflect-value attribute, as attributes that start with ng-reflect- were designed to use for testing.
await fixture.whenStable().then(() => { const autocompleteOptions = fixture.debugElement.query( By.css('.mat-autocomplete-panel') ).nativeElement; for (const option of autocompleteOptions.children) { expect(stateOptions).toContain(option.getAttribute('ng-reflect-value')); } });
And here is the full first test.
it('should populate the state input with the proper autocomplete options', async () => { const stateOptions = component.options.map((option) => option.state); const stateClicker = fixture.debugElement.query( By.css('mat-form-field#state input') ).nativeElement; stateClicker.click(); fixture.detectChanges(); await fixture.whenStable().then(() => { const autocompleteOptions = fixture.debugElement.query( By.css('.mat-autocomplete-panel') ).nativeElement; for (const option of autocompleteOptions.children) { expect(stateOptions).toContain(option.getAttribute('ng-reflect-value')); } }); });
Our next test is a bit longer, it tests the validator attached to our form. A quick note. You’ll notice that we only mildly test the validator. In a real world scenario, our validator would more likely exist in a different module that handles all of your custom validators. So we wouldn’t test the validator here, we would test it where it lives. In our case we jam the validator into our file, so test it here as well.
We start by grabbing our state field and giving it a click. This begins to activate our autocomplete code, as we’ve seen above.
const stateMatFormField = fixture.debugElement.query( By.css('mat-form-field#state input') ).nativeElement; stateMatFormField.click(); fixture.detectChanges();
We then find the autocomplete option we want to test with. In our case we have a testState object, and grab the state value in that object to get the mat-option we want to select. We then click on the option element to select the option, and then click our stateField again to trigger the events that show we’ve filled it in.
const stateAutoOption = fixture.debugElement.query( By.css( '.mat-autocomplete-panel mat-option[ng-reflect-value="' + testState.state + '"]' ) ).nativeElement; stateAutoOption.click(); fixture.detectChanges(); stateMatFormField.click(); fixture.detectChanges();
In this test we are actually executing two tests. While I like to split a test so that it only has one expect() call, in this case we’re triggering two expect() calls in one test to save some room. The first test sets our cityField to an incorrect value based on the state, and checks to be sure our addressForm is invalid. We then set the cityField to a good value, and check again. Now our form is valid! In this case, we use setValue as it triggers the events we need to ensure that the form value is updated.
component.get('cityField').setValue('Houston'); fixture.detectChanges(); expect(component.addressForm.invalid).toBeTruthy(); component.addressForm.get('cityField').setValue('Chicago'); fixture.detectChanges(); expect(component.addressForm.invalid).toBeFalsy();
And here’s the full test.
it('should properly validate the city input when our state is chosen', async () => { const stateMatFormField = fixture.debugElement.query( By.css('mat-form-field#state input') ).nativeElement; stateMatFormField.click(); fixture.detectChanges(); await fixture.whenStable().then(() => { const stateAutoOption = fixture.debugElement.query( By.css( '.mat-autocomplete-panel mat-option[ng-reflect-value="' + testState.state + '"]' ) ).nativeElement; stateAutoOption.click(); fixture.detectChanges(); stateMatFormField.click(); fixture.detectChanges(); component.addressForm.get('cityField').setValue('Houston'); fixture.detectChanges(); expect(component.addressForm.invalid).toBeTruthy(); component.addressForm.get('cityField').setValue('Chicago'); fixture.detectChanges(); expect(component.addressForm.invalid).toBeFalsy(); }); });
Our last two tests check that our submit button only activates if the form is valid. They also abide by my above rule, that a test should do only one thing. These tests are nearly identical, though one tests the truthiness and one falsiness of our functionality. The tests start as our above test does, by setting the value of our city and state fields. We then grab our button and test to be sure that it is not disabled in the first test, and that it is disabled in the second. This test is quite simple so we only show the full test here.
it('should activate submit button if required fields are filled', () => { component.addressForm.get('stateField').setValue(testState.state); component.addressForm.get('cityField').setValue(testState.city[0]); fixture.detectChanges(); const theButton = fixture.debugElement.query(By.css('button')).nativeElement; expect(theButton.disabled).toBeFalsy(); });
And here is our full spec.ts file contents.
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { InputFormfieldAutocompleteUtComponent } from './input-formfield-autocomplete-ut.component'; import { MatInputModule, MatFormFieldModule, MatAutocompleteModule, } from '@angular/material'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { CommonModule } from '@angular/common'; import { By } from '@angular/platform-browser'; import { FixedSizeVirtualScrollStrategy } from '@angular/cdk/scrolling'; describe('InputFormfieldAutocompleteUtComponent', () => { let component: InputFormfieldAutocompleteUtComponent; let fixture: ComponentFixture<InputFormfieldAutocompleteUtComponent>; const testState = { state: 'Illinois', city: ['Chicago', 'Aurora', 'Naperville', 'Joliet'], }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ MatInputModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, BrowserAnimationsModule, MatAutocompleteModule, CommonModule, ], declarations: [InputFormfieldAutocompleteUtComponent], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(InputFormfieldAutocompleteUtComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should poplate the state input with the proper autocomplete options', async () => { const stateOptions = component.options.map((option) => option.state); const stateClicker = fixture.debugElement.query( By.css('mat-form-field#state input') ).nativeElement; stateClicker.click(); fixture.detectChanges(); await fixture.whenStable().then(() => { const autocompleteOptions = fixture.debugElement.query( By.css('.mat-autocomplete-panel') ).nativeElement; for (const option of autocompleteOptions.children) { expect(stateOptions).toContain(option.getAttribute('ng-reflect-value')); } }); }); it('should properly validate the city input when our state is chosen', async () => { const stateMatFormField = fixture.debugElement.query( By.css('mat-form-field#state input') ).nativeElement; stateMatFormField.click(); fixture.detectChanges(); await fixture.whenStable().then(() => { const stateAutoOption = fixture.debugElement.query( By.css( '.mat-autocomplete-panel mat-option[ng-reflect-value="' + testState.state + '"]' ) ).nativeElement; stateAutoOption.click(); fixture.detectChanges(); stateMatFormField.click(); fixture.detectChanges(); component.addressForm.get('cityField').setValue('Houston'); fixture.detectChanges(); expect(component.addressForm.invalid).toBeTruthy(); component.addressForm.get('cityField').setValue('Chicago'); fixture.detectChanges(); expect(component.addressForm.invalid).toBeFalsy(); }); }); it('should activate submit button if required fields are filled', () => { component.addressForm.get('stateField').setValue(testState.state); component.addressForm.get('cityField').setValue(testState.city[0]); fixture.detectChanges(); const theButton = fixture.debugElement.query(By.css('button')).nativeElement; expect(theButton.disabled).toBeFalsy(); }); it('should not activate submit button if required fields are filled incorrectly', () => { component.addressForm.get('stateField').setValue(testState.state); component.addressForm.get('cityField').setValue('Houston'); fixture.detectChanges(); const theButton = fixture.debugElement.query(By.css('button')).nativeElement; expect(theButton.disabled).toBeTruthy(); }); });
Wrap Up
The input and mat-form-field is the basis for most of the forms we write. Testing these components on their own is quite boring, so above we’ve added in some extra functionality, such as validation, to show how we would trigger events and test things like validation.
For our autocomplete, we test that our options are filled with the correct data. In addition we use the same data for our validation. The autocomplete can be a bit tricky to trigger in tests. We need to click our input, then check for our cdk options. Once we have selected an option, we have to click the option and click the input again. This isn’t very intuitive, but a typical pattern while testing components that use the cdk.
In this app example, we only tested the basics of our elements. Even so, you should have enough information to start building out these simple tests, and add whatever is specific to your case.
We also focused on testing the elements directly, and wrote our tests this way for the most part. The goal of testing shouldn’t necessarily be individual element based. We should look for outcomes of a business workflow. Testing a user expectation. So the tests outlined above should be part of a larger “should” and shouldn’t be the ends itself.
All of the code shown here, and more Angular tests, can be found in this GitHub repo.