Testing Reactive Forms A simple form

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.

Form Component HTML
<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.

Form Component Typescript
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.

ReactiveFormsModule Import
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.

Should Create Test
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.

Email Pattern Test Values
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').

Email Value Test
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.

Email Pattern Error Message Display Test
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.

Max Length Test Values
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.

Email Max Length Test
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.

Email Required Test
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.

Testing for an Associated Label
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.

Nested label and input
<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.

Testing Error's Accessibility
  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.

Autocomplete Test
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.

Submit Button Test
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.

Reset Button Test
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!

Consulting

Our expertise helps your team ramp up on new technologies or practices, or to fill short-term skills gaps.

Read more about Consulting with Andromeda