Although part of most applications (because let’s not kid ourselves—the vast majority of applications are CRUD apps) a well designed, user friendly, testable form is still one of those things that is really hard to achieve. There are so many factors to remember: validation, accessibility, UX, and content organization. Although as developers we create them often, it is easy to forget a piece. Let's look at tests we can write to validate our inputs for validation or error handling.
Let's create a reactive form with 1 email field. In this form we will make sure to include a label, hint, error messages, validation and 2 buttons: reset and submit. We will also disable the submit button when the form does not validate.
<main>
<form (ngSubmit)="submit()" [formGroup]="form" novalidate>
<h1>Form</h1>
<label for="email">Email</label>
<input id="email" type="email" aria-errormessage="emailError" autocomplete="email" formControlName="email">
<div class="error" id="emailError" role="alert">
<p *ngIf="hasError('email', 'required')">This is a required field</p>
<p *ngIf="hasError('email', 'maxlength')">Email should be less than or equal to 50 characters</p>
<p *ngIf="hasError('email', 'pattern')">Please provide a valid email address</p>
</div>
<div class="actions">
<button type="submit" [disabled]="form.invalid">Submit</button>
<button type="button" (click)="reset()">Reset</button>
</div>
</form>
</main>
In our Typescript, we will create the form and write a helper function to show the different error messages when the inputs have been touched by a user but are invalid. We also have the submit and error functions.
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-form',
templateUrl: './form.component.html',
styleUrls: ['./form.component.scss'],
})
export class FormComponent {
form: FormGroup = this.createForm();
constructor(private _fb: FormBuilder) { }
createForm(): FormGroup {
const emailRegex = /^\S{1,}@\S*?\.\S+$/;
return this._fb.group({
email: ['', [Validators.maxLength(50), Validators.pattern(emailRegex), Validators.required]],
})
}
hasError(controlName: string, error: string): boolean {
const control = this.form.get(controlName);
return !!control?.dirty && !!control.getError(error);
}
reset(): void { this.form.reset(); }
submit() { console.log('submit'); }
}
Setting Up Our Tests
The first thing we want to do is handle our imports. We are using reactive forms so we need to add the ReactiveFormsModule
to our imports.
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ ReactiveFormsModule ],
declarations: [ FormComponent ],
})
.compileComponents();
});
If we used the Angular CLI to generate our component, we already have an on create test in our .spec
file for the component. At this point, if we run the test, again using the cli with the ng test
command, our test should pass.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { FormComponent } from './form.component';
describe('FormComponent', () => {
let component: FormComponent;
let fixture: ComponentFixture<FormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ ReactiveFormsModule ],
declarations: [ FormComponent ],
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FormComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
We can now start to test our inputs.
Testing the Field's Validation
Pattern
Let's first test that the regex pattern we are using for the email input is correctly validating email addresses. We used the following pattern: /^\S{1,}@\S*?\.\S+$/
. Lets check some email addresses, some that should pass, and some that should fail, and make sure the formControl returns the appropriate valid / invalid values. We first create an array of values and expected validity.
const emailTests = [
{ value: 'john@email.com', valid: true },
{ value: 'john@email.us', valid: true },
{ value: 'john@email.', valid: false },
{ value: 'john@email', valid: false },
{ value: 'john@', valid: false },
{ value: 'john', valid: false },
{ value: 'teacher@district.k12.edu', valid: true },
{ value: 0, valid: false },
{ value: 1, valid: false }
]
We then loop over the array and test to make sure we get the expected result. As part of our setup, we defined and set up the component. Using the component, we can get the form itself and then each individual control. In this case we are interested in the email control. We then patch our test value to the control and test against the expected validity. We check the validity in 2 different ways, the overall validity using control.valid
, followed by pattern's validity using control.getError('pattern')
.
emailTests.forEach(test => {
it(`should validate email pattern: ${test.value}`, () => {
const control = component.form.get('email');
control?.patchValue(test.value);
// Test overall validity of the email control
expect(control?.valid).toBe(test.valid);
// Test pattern validity of the email control
expect(!!control.getError('pattern')).toBe(test.valid)
});
})
This list above is very small subsection of the possible combinations that can be tested for. A couple of email pattern often overlooked that I like to make sure to always have are emails with 2 letter TLDs such as user@email.us
, and the ability to include non alphanumeric characters in the local part of the email address: user+test@email.com
which is a pattern often used for testers, or even a hyphenated or period-separated name, alexander-the.great@gmail.com
for example. A formal list of email validation considerations can be found at https://datatracker.ietf.org/doc/html/rfc3696#section-3.
Let's continue adding to the test. When the email is valid, we want to hide the pattern related error message versus show the message when invalid. Since we are only showing the error when the input is dirty, we will need to simulate the input being touched. Simply patching to the input as we do above does not mark the input as dirty. To accomplish this task, we will need to identify the input in the DOM, trigger an input event on the field, and then detect changes on the fixture. We can then check the error div for whether the pattern error message is present.
emailTests.forEach(test => {
it(`should validate email pattern: ${test.value}`, () => {
const control = component.form.get('email');
// Identify the input element by looking for an input with id of email
const input = fixture.nativeElement.querySelector('#email');
// Identify the error div by looking for a div with id of emailError
const errors = fixture.nativeElement.querySelector('#emailError');
// dispatch an input event so the input get marked as dirty
input.dispatchEvent(new Event('input'))
control?.patchValue(test.value);
// update fixture
fixture.detectChanges();
// check that it is in fact dirty
expect(control?.dirty).toBeTrue();
// test that
// email pattern error message is not shown when valid
// email pattern error message is shown when not valid
const emailPatternMessage = 'Please provide a valid email address';
expect(errors.innerText.includes(emailPatternMessage)).toBe(!test.valid)
expect(control?.valid).toBe(test.valid);
expect(!!control?.getError('pattern')).toBe(!test.valid)
});
})
Max Length
Let's use the same structure to test field length. We are going to set up the test the exact same way as for the pattern but check the input's length. We first set up our array of options. To make sure we are testing on the length itself, we want to make sure our we are not triggering any other errors such as pattern, so our email values should pass our pattern validation.
const emailTests = [
{ value: 'john@email.com', valid: true },
// A value of exactly 50 characters (our max length)
{ value: 'userHasAVeryVeryVeryLongUserName@longLongEmail.com', valid: true },
// Greater than our max length (54 characters)
{ value: 'userHasAVeryVeryVeryLongUserName@longLongLongEmail.com', valid: false },
]
We then make a couple of updates to check for length's validity instead of the pattern's.
emailLengthTests.forEach(test => {
// change the name of the test
it(`should validate email length: ${test.value}`, () => {
const control = component.form.get('email');
const input = fixture.nativeElement.querySelector('#email');
const errors = fixture.nativeElement.querySelector('#emailError');
input.dispatchEvent(new Event('input'))
control?.patchValue(test.value);
fixture.detectChanges();
expect(control?.dirty).toBeTrue();
// Change the error message
const emailLengthMessage = 'Email should be less than or equal to 50 characters';
expect(errors.innerText.includes(emailLengthMessage)).toBe(!test.valid)
expect(control?.valid).toBe(test.valid);
// change the error type we are looking for
expect(!!control?.getError('maxlength')).toBe(!test.valid)
});
})
Required
Required is simpler because it's a binary situation, so our array will only have 2 options, a valid email value and an empty value.
const emailRequiredTests = [
{ value: 'john@email.com', valid: true },
{ value: null, valid: false },
]
emailRequiredTests.forEach(test => {
// change the name of the test
it(`should validate email length: ${test.value}`, () => {
const control = component.form.get('email');
const input = fixture.nativeElement.querySelector('#email');
const errors = fixture.nativeElement.querySelector('#emailError');
input.dispatchEvent(new Event('input'))
control?.patchValue(test.value);
fixture.detectChanges();
expect(control?.dirty).toBeTrue();
// Change the error message
const emailRequiredMessage = 'This is a required field';
expect(errors.innerText.includes(emailRequiredMessage)).toBe(!test.valid)
expect(control?.valid).toBe(test.valid);
// change the error type we are looking for
expect(!!control?.getError('required')).toBe(!test.valid)
});
})
Accessibility
Now that the validation for the form is doing what we expect and correctly showing error messages when appropriate, we check that our input is appropriately labeled and that our errors have a role of alert. We will also check that our input field has an autocomplete attribute as they are helpful in helping user agents fill out the values for the user. They also guide browsers to the type of information the form is expecting.
Label
Let's first check that our input has a label and that they are correctly associated to one another using for
and id
. The label should have a for attribute that matches the input's id. To test the label association, we will get the input's ID, and then query for a label with a for attribute that matches the id. We will then check that the input has text content which includes the word email.
it(`should have an associated label`, () => {
// Finds the first input in the component
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
// get the input's id
const id = input.getAttribute('id')
// find a label with a for attribute that matches the ID
const label = fixture.nativeElement.querySelector(`label[for="${id}"`);
// Check that label Exists
expect(label).toBeTruthy();
// Check that label includes the word "email"
// To lowercase to prevent failure due to capitalization
expect(label.innerText.toLowerCase()).toContain('email')
});
Another way to associate an input and its label is to next the control inside of the label.
<label>
Is checked
<input type="checkbox">
</label>
This can be done for any type of control (input, selected, etc.) but has marked advantages for radio controls and checkmarks as it makes the label a clickable area to interact with the input increasing the clickable surface area. To test this method of associating inputs and labels rather than using id
and for
would require a different test.
Errors
Now let's check that errors are appropriately linked back to the input and have a role of alert. We use aria-errormessage
to associate the input and the error so that assistive technologies can guide users to which field is problematic. The role alert makes sure that the user is immediately notified of the issue.
it(`should handle error accessibility`, () => {
// Finds the first input in the component
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
// get the error div's Id
const errorMessageId = input.getAttribute('aria-errormessage')
// find a div with a id that matches the attribute
const errors = fixture.nativeElement.querySelector(`#${errorMessageId}`);
// check that element exists
expect(errors).toBeTruthy()
// Check that is has a role="alert"
const role = errors.getAttribute('role')
expect(role).toEqual('alert')
})
Autocomplete
To check autocomplete we will use similar techniques as in the previous tests. We will get the element, then get the attribute value and check that the value equals email.
it(`has an autocomplete attribute`, () => {
// Find our email field
const email = fixture.nativeElement.querySelector('#email') as HTMLInputElement;
// get the autocomplete attribute value
const autocomplete = email.getAttribute('autocomplete');
// test that autocomplete has a value
expect(autocomplete).toBeTruthy();
// test autocomplete value is equal to email
expect(autocomplete).toEqual('email');
})
Buttons
We have tested the inputs Six ways to Sunday at this point, the last piece we haven't looked at are the buttons. We have 2 buttons at the bottom of the form: Submit and Reset. The submit button needs to be disabled if the form is invalid but enabled when valid. The reset button however needs to stay enabled regardless of the form state. We can also test that it actually resets the form. We will start with the Submit button.
Submit
We can test the submit button in one test which contains multiple expects. We will manipulate the form values using patch to force both valid and invalid check, then check to see whether the button is disabled or not. We will need make the fixture check for changes after changing the form values as it will not do that automatically with our current setup.
it(`should toggle disabling the submit button based on form state`, () => {
// Find our button
const button = fixture.nativeElement.querySelector('button[type="submit"]');
// Patch the form with valid values
component.form.patchValue({ email: 'user@gmail.com' });
// update fixture
fixture.detectChanges()
// Check form validity, should be valid
expect(component.form.valid).toBeTrue();
// Check button state, should be disabled
expect(button.disabled).toBeFalse();
// now patch invalid value
component.form.patchValue({ email: 'invalid value' });
// update fixture
fixture.detectChanges();
// Check form validity, should be invalid
expect(component.form.valid).toBeFalse();
// Check button state, should be disabled
expect(button.disabled).toBeTrue();
});
Reset
To check the reset button we will repeat the above test checking that regardless of the state, the button stays enabled. Then we will simulate a click and make sure the form is reset once the button is pressed.
it(`should reset form`, () => {
// Find our button
const button = fixture.nativeElement.querySelector('button[type="button"]');
// Patch the form with valid values
component.form.patchValue({ email: 'user@gmail.com' });
// update fixture
fixture.detectChanges()
// Check form validity, should be valid
expect(component.form.valid).toBeTrue();
// Check button state, should be enabled
expect(button.disabled).toBeFalse();
// now patch invalid value
component.form.patchValue({ email: 'invalid value' });
// update fixture
fixture.detectChanges();
// Check form validity, should be invalid
expect(component.form.valid).toBeFalse();
// Check button state, should be enabled
expect(button.disabled).toBeFalse();
// click the button
button.click();
// update fixture
fixture.detectChanges();
// Check that form is back to a pristine state
expect(component.form.pristine).toBeTrue()
// get form value (should no longer have an email value)
expect(component.form.get('email')?.value).toBe(null)
});
Closing Thoughts
Although definitely not exhaustive, the above set of tests shows different ways a simple form can be tested. Other areas that would still need attention include keyboard accessibility or that the form is calling appropriate services or performing the correct behavior on submit. There is still much more than be tested for. Ideally, these would be written based off acceptance criteria so that they can validate that requirements were not overlooked. Another benefit is that the tests validate that the behavior we expect is being exhibited by the form.
The code full working code can be found on github in the martine-dowden/angular-unit-testing project, reactive-form-test branch.
Happy Coding!