Learn how to create a dynamic form in Angular and then create tests for the form to ensure it works as expected.
This article will cover testing of dynamic forms in Angular. Dynamic forms in Angular are forms that are created using reactive form classes like Form Group and Form Controls. We will write tests for these forms to ensure that they function as intended.
For this article, we’ll be testing a sign-up form. The form is generated dynamically by passing an array of objects describing the input elements to the component; then a FormControl
will be generated for each element before the form is grouped using FormGroup
.
To get started, you have to bootstrap an Angular project using the CLI. To follow this tutorial a basic understanding of Angular is required. Please ensure that you have Node and npm installed before you begin. If you have no prior knowledge of Angular, kindly follow the tutorial here. Come back and finish this tutorial when you’re done.
Initializing Application
To get started, we will use the CLI (command line interface) provided by the Angular team to initialize our project.
First, install the CLI by running npm install -g @angular/cli
. npm is a package manager used for installing packages. It will be available on your PC if you have Node installed, if not, download Node here.
To create a new Angular project using the CLI, open a terminal and run:ng new dynamic-form-tests
Enter into the project folder and start the Angular development server by running ng serve
in a terminal in the root folder of your project.
Creating Sign-up Form
To get started, we’ll set up the sign-up form to get ready for testing. The form itself will be rendered by a component separate from the App
component. Run the following command in a terminal within the root folder to create the component:
ng generate component dynamic-form
Open the dynamic-form.component.html
file and copy the following content into it:
<!-- src/app/dynamic-form/dynamic-form.component.html --><form[formGroup]="form"(submit)="onSubmit()"><div*ngFor="let element of formConfig"><div[ngSwitch]="element.inputType"><label[for]="element.id">{{ element.name }}</label><span*ngIf="element?.required">*</span><br/><div*ngSwitchCase="'input'"><div*ngIf="element.type === 'radio'; else notRadio"><div*ngFor="let option of element.options"><input[type]="element.type"[name]="element.name"[id]="option.id"[formControlName]="element.name"[value]="option.value"/><label[for]="option.id">{{ option.label }}</label><span*ngIf="element?.required">*</span></div></div><ng-template#notRadio><input[type]="element.type"[id]="element.name"[formControlName]="element.name"/></ng-template></div><select[name]="element.name"[id]="element.id"*ngSwitchCase="'select'"[formControlName]="element.name"><option[value]="option.value"*ngFor="let option of element.options">{{
option.label
}}</option></select></div></div><button>Submit</button></form>
We use the ngSwitch
binding to check for the input
type before rendering. The inputType
of the select
element is different, so it is rendered differently using the *ngSwitchCase
binding. You can add several inputTypes
and manage them using the *ngSwitchCase
. The file
input element, for example, might be rendered differently from the other input elements. In that case, the inputType
specified can be file
.
For each input element, we add a formControlName
directive which takes the name
property of the element. The directive is used by the formGroup
to keep track of each FormControl
value. The form element also takes formGroup
directive, and the form
object is passed to it.
Let’s update the component to generate form controls for each input field and to group the elements using the FormGroup
class. Open the dynamic-form.component.ts
file and update the component file to generate form controls for each input and a form group.
// src/app/dynamic-form/dynamic-form.component.tsimport{ Component, OnInit, Input }from'@angular/core';import{ FormControl, FormGroup }from'@angular/forms'
@Component({
selector:'app-dynamic-form',
templateUrl:'./dynamic-form.component.html',
styleUrls:['./dynamic-form.component.css']})exportclassDynamicFormComponentimplementsOnInit{constructor(){}
@Input()formConfig =[]
form: FormGroup;
userGroup ={};onSubmit(){
console.log(this.form.value);}ngOnInit(){for(let config ofthis.formConfig){this.userGroup[config.name]=newFormControl(config.value ||'')}this.form =newFormGroup(this.userGroup);}}
The component will take an Input
(formConfig
) which will be an array of objects containing information about each potential input. In the OnInit
lifecycle of the component, we’ll loop through the formConfig
array and create a form control for each input using the name
and value
properties. The data will be stored in an object userGroup
, which will be passed to the FormGroup
class to generate a FormGroup
object (form
).
Finally, we’ll update the app.component.html
file to render the dynamic-form
component and also update the app.component.ts
file to create the formConfig
array:
<-- src/app/app.component.html -->
<section><app-dynamic-form[formConfig]="userFormData"></app-dynamic-form></section>
Next is the component file. Open the app.component.ts
file and update it with the snippet below:
import{ Component, OnInit }from'@angular/core';
@Component({
selector:'my-app',
templateUrl:'./app.component.html',
styleUrls:['./app.component.css']})exportclassAppComponent{
userFormData =[{
name:'name',
value:'',type:'text',
id:'name',
inputType:'input',
required:true},{
name:'address',
value:'',type:'text',
id:'address',
inputType:'input',},{
name:'age',
value:'',type:'number',
id:'age',
inputType:'input',},{
name:'telephone',
value:'',type:'tel',
id:'telephone',
inputType:'input',},{
name:'sex',type:'radio',
inputType:'input',
options:[{
id:'male',
label:'male',
value:'male'},{
id:'female',
label:'female',
value:'female'}]},{
name:'country',
value:'',type:'',
id:'name',
inputType:'select',
options:[{
label:'Nigeria',
value:'nigeria'},{
label:'United States',
value:'us'},{
label:'UK',
value:'uk'}]},]}
The userForm
array contains objects with properties like type
, value
, name
. These values will be used to generate appropriate fields on the view. This lets us add more input fields in the template without manually updating the template. This array is passed to the dynamic-form
component.
Don’t forget that to use Reactive Forms, you have to import the ReactiveFormsModule
. Open the app.module.ts
file and update it to include the ReactiveFormsModule
:
// other imports ...import{ FormsModule, ReactiveFormsModule }from'@angular/forms';
@NgModule({
imports:[// ...other imports
ReactiveFormsModule
],//...})exportclassAppModule{}
Testing the Form
When generating components, Angular generates a spec
file alongside the component for testing. Since we’ll be testing the dynamic-form
component, we’ll be working with the dynamic-form.component.spec.ts
file.
The first step is to set up the test bed for the component. Angular already provides a boilerplate for testing the component, and we’ll simply extend that. Open the dynamic-form.component.spec.ts
and update the test bed to import the ReactiveFormsModule
that the component depends on:
import{async, ComponentFixture, TestBed }from'@angular/core/testing';import{ ReactiveFormsModule }from'@angular/forms';import{ DynamicFormComponent }from'./dynamic-form.component';describe('DynamicFormComponent',()=>{let component: DynamicFormComponent;let fixture: ComponentFixture<DynamicFormComponent>;beforeEach(async(()=>{
TestBed.configureTestingModule({
declarations:[ DynamicFormComponent ],
imports:[ ReactiveFormsModule ],}).compileComponents();}));beforeEach(()=>{
fixture = TestBed.createComponent(DynamicFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();});it('should create',()=>{expect(component).toBeTruthy();});});
We’ll be testing our form using the following cases:
- Form rendering: here, we’ll check if the component generates the correct input elements when provided a
formConfig
array. - Form validity: we’ll check that the form returns the correct validity state
- Input validity: we’ll check if the component responds to input in the view template
- Input errors: we’ll test for errors on the required input elements.
To begin testing, run the following command in your terminal: yarn test
or npm test
Form Rendering
For this test, we’ll pass an array of objects containing data about the input elements we wish to create, and we’ll test that the component renders the correct elements. Update the component’s spec file to include the test:
describe('DynamicFormComponent',()=>{// ... test bed setupbeforeEach(()=>{
fixture = TestBed.createComponent(DynamicFormComponent);
component = fixture.componentInstance;
component.formConfig =[{
name:'name',
value:'',type:'text',
id:'name',
inputType:'input',
required:true},{
name:'address',
value:'',type:'text',
id:'address',
inputType:'input',},]
component.ngOnInit();
fixture.detectChanges();});it('should render input elements',()=>{const compiled = fixture.debugElement.nativeElement;const addressInput = compiled.querySelector('input[id="address"]');const nameInput = compiled.querySelector('input[id="name"]');expect(addressInput).toBeTruthy();expect(nameInput).toBeTruthy();});});
We updated the test suite with the following changes:
- We assigned an array to the
formConfig
property of the component. This array will be processed in theOnInit
lifecycle to generate form controls for the input elements and then a form group. - Then we triggered the
ngOnInit
lifecycle. This is done manually because Angular doesn’t do this in tests. - As we’ve made changes to the component, we have to manually force the component to detect changes. Thus, the
detectChanges
method is triggered. This method ensures the template is updated in response to the changes made in the component file. - We get the compiled view template from the
fixture
object. From there, we’ll check for the input elements that should have been created by the component. We expected two components — anaddress
input and aname
input. - We’ll check if the elements exist using the
toBeTruthy
method.
Form Validity
For this test, we’ll check for the validity state of the form after updating the values of the input elements. For this test, we’ll update the values of the form
property directly without accessing the view. Open the spec file and update the test suite to include the test below:
it('should test form validity',()=>{const form = component.form;expect(form.valid).toBeFalsy();const nameInput = form.controls.name;
nameInput.setValue('John Peter');expect(form.valid).toBeTruthy();})
For this test, we’re checking if the form responds to the changes in the control elements. When creating the elements, we specified that the name
element is required. This means the initial validity state of the form should be INVALID
, and the valid
property of the form
should be false
.
Next, we update the value of the name
input using the setValue
method of the form control, and then we check the validity state of the form. After providing the required input of the form, we expect the form should be valid.
Input Validity
Next we’ll check the validity of the input elements. The name
input is required, and we should test that the input acts accordingly. Open the spec file and add the spec below to the test suite:
it('should test input validity',()=>{const nameInput = component.form.controls.name;const addressInput = component.form.controls.address;expect(nameInput.valid).toBeFalsy();expect(addressInput.valid).toBeTruthy();
nameInput.setValue('John Peter');expect(nameInput.valid).toBeTruthy();})
In this spec, we are checking the validity state of each control and also checking for updates after a value is provided.
Since the name
input is required, we expect its initial state to be invalid. The address
isn’t required so it should be always be valid
. Next, we update the value
of the name
input, and then we test if the valid
property has been updated.
Input Errors
In this spec, we’ll be testing that the form controls contain the appropriate errors; the name
control has been set as a required input. We used the Validators
class to validate the input. The form control has an errors
property which contains details about the errors on the input using key-value pairs.
The screenshot above shows an example of how a form control containing errors looks. For this spec, we’ll test that the required name
input contains the appropriate errors. Open the dynamic-form.component.spec.ts
file and add the spec below to the test suite:
it('should test input errors',()=>{const nameInput = component.form.controls.name;expect(nameInput.errors.required).toBeTruthy();
nameInput.setValue('John Peter');expect(nameInput.errors).toBeNull();});
First, we get the name
form control from the form
form group property. We expect the initial errors
object to contain a required
property, as the input’s value is empty. Next, we update the value of the input, which means the input shouldn’t contain any errors, which means the errors
property should be null
.
If all tests are passing, it means we’ve successfully created a dynamic form. You can push more objects to the formConfig
array and add a spec to test that a new input element is created in the view.
Conclusion
Tests are vital when programming because they help detect issues within your codebase that otherwise would have been missed. Writing proper tests reduces the overhead of manually testing functionality in the view or otherwise. In this article, we’ve seen how to create a dynamic form and then we created tests for the form to ensure it works as expected.
One More Thing: End-to-End UI Test Automation Coverage
On top of all unit, API, and other functional tests that you create, it is always a good idea to add stable end-to-end UI test automation to verify the most critical app scenarios from the user perspective. This will help you prevent critical bugs from slipping into production and will guarantee superb customer satisfaction.
Even if a control is fully tested and works well on its own, it is essential to verify if the end product - the combination of all controls and moving parts - is working as expected. This is where the UI functional automation comes in handy. A great option for tooling is Telerik Test Studio. This is a web test automation solution that enables QA professionals and developers to craft reliable, reusable, and maintainable tests.