Easy Angular Testing – UI Elements – Select, Radio Button and Checkbox

In this post, we’ll look into several components – Material Select, Radio Button and Checkbox. In our example below, we develop a sandwich picker. It allows selection of sandwich type, sides, and extras.

What do we use a Select for? On the Angular Material site it describes the Select element as

<mat-select> is a form control for selecting a value from a set of options, similar to the native <select> element. 

https://material.angular.io/components/select/overview

For the radio button, the Angular Material site says

<mat-radio-button> provides the same functionality as a native <input type="radio"> enhanced with Material Design styling and animations.

https://material.angular.io/components/radio/overview

And what does it say about the checkbox?

<<mat-checkbox> provides the same functionality as a native <input type="checkbox"> enhanced with Material Design styling and animations.

https://material.angular.io/components/checkbox/overview

Google’s Material site has a bit more on the use of Radio Buttons and Checkboxes

Radio buttons allow for a single option to be selected from a visible list.

Radio buttons should be used instead of checkboxes if only one item can be selected from a list.

Use checkboxes to: Select one or more options from a list, Present a list containing sub-selections, Turn an item on or off in a desktop environment.

https://material.io/components/selection-controls/#usage

Enough about the elements and when we should use them, let’s dig in, build a solution and test it!

The Code

First we’ll dive into the HTML. We use a reactive form in this case, and will take a dive into the code behind it a bit later in this post. Our first field is our mat-select, used to select the main sandwich. Here we wrap it in a mat-form-field to get it linked into our reactive form. We also wire this up with our (selectionChange) event pointing to the method selectionChanged($event). We’ll take a deeper look into that method, for now know that when a selection changed, our code evaluates what should show up in the extras section. We then populate our select from a list in code that in the real world might come from an API call.

  <mat-form-field id="sandwich" class="block-field">
    <mat-label>Sandwich: </mat-label>

    <mat-select
      required
      formControlName="sandwich"
      (selectionChange)="selectionChanged($event)"
      id="sandwichSelect"
    >
      <mat-option *ngFor="let sandwich of sandwichList" [value]="sandwich.name">{{ sandwich.text }}</mat-option>
    </mat-select>
  </mat-form-field>

We then have our side selection. We wrap it in a div to control the layout, and then wrap the radio buttons in a mat-radio-group. The mat-radio-group groups our mat-radio-buttons in a way so that only one of those buttons can be selected at a time. The mat-radio-group is what links us in to our reactive form. Like we do in the select above, we grab our mat-radio-button data from a list in code here, but could grab it from an API call.

  <div class="block-field">
    <mat-label for="side">Side: </mat-label>

    <mat-radio-group
      aria-label="Would you like a side?"
      formControlName="side"
      id="side"
    >
      <mat-radio-button *ngFor="let side of sides" [value]="side">{{
        side
      }}</mat-radio-button>
    </mat-radio-group>
  </div>

Other than our submit button, our last elements in this form are used to add extras to our sandwich. We again wrap the elements in a div to control layout. We use a FormArray() here so we can have a variable number of elements added or removed from this group of controls. That number in our form changes with the type of sandwich we choose, as we have an extra meat option, which shouldn’t show for veggie type sandwiches. More on that later.

 <div class="block-field" id="sandwichExtras">
    <mat-label>Extras: </mat-label>
    <mat-label
      formArrayName="extras"
      *ngFor="
        let extra of sandwichFormGroup.controls.extras.controls;
        index as i
      "
    >
      <mat-checkbox formControlName="{{ i }}">{{
        extrasAvailable[i].text
      }}</mat-checkbox>
    </mat-label>
  </div>

And here is our full html file.

<form [formGroup]="sandwichFormGroup" (submit)="onSubmit()">
  <mat-form-field id="sandwich" class="block-field">
    <mat-label>Sandwich: </mat-label>

    <mat-select
      required
      formControlName="sandwich"
      (selectionChange)="selectionChanged($event)"
      id="sandwichSelect"
    >
      <mat-option *ngFor="let sandwich of sandwichList" [value]="sandwich.name">{{ sandwich.text }}</mat-option>
    </mat-select>
  </mat-form-field>

  <div class="block-field">
    <mat-label for="side">Side: </mat-label>

    <mat-radio-group
      aria-label="Would you like a side?"
      formControlName="side"
      id="side"
    >
      <mat-radio-button *ngFor="let side of sides" [value]="side">{{
        side
      }}</mat-radio-button>
    </mat-radio-group>
  </div>

  <div class="block-field" id="sandwichExtras">
    <mat-label>Extras: </mat-label>
    <mat-label
      formArrayName="extras"
      *ngFor="
        let extra of sandwichFormGroup.controls.extras.controls;
        index as i
      "
    >
      <mat-checkbox formControlName="{{ i }}">{{
        extrasAvailable[i].text
      }}</mat-checkbox>
    </mat-label>
  </div>

  <button mat-button id="submitButton">Order</button>
</form>

We add some CSS to ensure that our sides and extras blocks show up with each element next to each other, and add some margins to separate the elements.

.block-field {
  display: block;
  margin-bottom: 0.5em;
}

#sandwich {
  width: 15rem;
}

mat-radio-button, mat-checkbox {
  margin-left: 1em;
}

Now on to our code!

We start out with a few lists of data that we use to populate our view. As we’ve said in the HTML section, this data often comes from an API when we build solutions in the real world. We don’t want to add that complexity here.

We’re going to start by looking at the addCheckboxes() method. It’s used several times in our code, so we want to understand this bit first. The code does just what it says, it adds checkboxes to our FormArray for choosing the sandwich extras. We probably could have named it addExtrasCheckboxes, or changeExtrasCheckboxes, and that would be a good first change in a refactor.

The code first clears out our extrasAvailable const using Array.splice to get it ready to accept a new set of selections. We then use a map/filter to grab any sandwiches of type nonMeat, and adding that to the nonMeat const.

    this.extrasAvailable.splice(0, this.extrasAvailable.length);

    const nonMeat = this.sandwichList
      .map(v => (v.type === 'nonMeat' ? v.name : null))
      .filter(v => v !== null);

This allows us to dynamically determine what sandwiches do not contain meat. Next, we take in our extraList, which contains ALL of our extras data, and begin to loop through each one using forEach. We look at our current sandwich selection, which is stored in this.sandwichFormGroup.controls.sandwich.value, and use an if statement to check if that sandwich is in the nonMeat array or not.

if (nonMeat.includes(sandwichType) === false)

This literally says, if our array that contains the nonMeat sandwiches does NOT include the current sandwich, we allow adding extra meat. This next line, to exclude the extra meat option, is not coded in the best way, though it is a good start.

if ([1].indexOf(o.id) < 0) 

What it says is, if the id of the current option is NOT a number in this array, here we have 1 to represent the extra meat option, then add it to the controls. This works because if an ID is not in the array, it will return something less than 0. Two ways we could make this better are to populate the array dynamically, and also add in a named variable to make it more clear what we are trying to do here.

From a technical perspective, in either case we push our “extras” controls into the FormArray to make them available on the page. Here is the full method.

  private addCheckboxes() {
    this.extrasAvailable.splice(0, this.extrasAvailable.length);

    const nonMeat = this.sandwichList
      .map(v => (v.type === 'nonMeat' ? v.name : null))
      .filter(v => v !== null);

    this.extraList.forEach((o, i) => {
      const sandwichType = this.sandwichFormGroup.controls.sandwich.value;

      if (nonMeat.includes(sandwichType) === false) {
        const control = new FormControl();
        (this.sandwichFormGroup.controls.extras as FormArray).push(control);
        this.extrasAvailable.push({ id: o.id, text: o.text });
      } else {
        if ([1].indexOf(o.id) < 0) {
          const control = new FormControl();
          (this.sandwichFormGroup.controls.extras as FormArray).push(control);
          this.extrasAvailable.push({ id: o.id, text: o.text });
        }
      }
    });
  }

Now that we’ve seen the addCheckboxes() code, let’s go back to our ngOnInit() method. Here we have our FormGroup, which contains our sandwich and side FormControls as well as the FormArray that is populated from the above code. When our ngOnInit() lifecycle event triggers, we call the addCheckboxes() method to initially populate the form with extras.

  ngOnInit() {
    this.sandwichFormGroup = new FormGroup({
      sandwich: new FormControl(),
      side: new FormControl(),
      extras: new FormArray([])
    });

    this.addCheckboxes();
  }

Next we have our selectionChanged() method. This method removes all of our extras controls from the FormArray first, using a while loop, so we start with a fresh FormArray.

    while (this.sandwichFormGroup.controls.extras.length > 0) {
      this.sandwichFormGroup.controls.extras.removeAt(0);
    }

This is different than what happens in our addCheckboxes method, where we remove the items from the extrasAvailable array, which is then used to populate the FormArray. Something of note, this code is using Angular 7, so we use a while loop that removes each control until the FormArray is empty. starting in Angular 8, we can use the following call instead

(this.sandwichFormGroup.controls.extras as FormArray).clear();

We then call our addCheckboxes() method to re-populate the FormArray with extras options that match our current sandwich selection. Here is the selectionChanged() method

  selectionChanged(selected) {
    while (this.sandwichFormGroup.controls.extras.length > 0) {
      this.sandwichFormGroup.controls.extras.removeAt(0);
    }

    this.addCheckboxes();
  }

Our last method is onSubmit(). The first thing this method does is grab our extras and stores them in the selectedOrderIds const. We do this because our FormGroup value for the FormArray will have null as the value for any extra not selected. We ONLY want what was selected, to avoid sending null data. So here we use a map/filter to grab our IDs, then filter on the null value to remove what we don’t want anymore. After we have that, and our sandwichOrder, we replace the sandwhichOrder’s extras node with our updated, compacted version.

  onSubmit() {
    const selectedOrderIds = this.sandwichFormGroup.value.extras
      .map((v, i) => (v ? this.extraList[i].id : null))
      .filter(v => v !== null);

    this.sandwichOrder = this.sandwichFormGroup.value;


    this.sandwichOrder.extras = selectedOrderIds;
    console.log('updated sammy ', this.sandwichOrder);
  }

And here is our full .ts file

import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, FormArray } from '@angular/forms';

@Component({
  selector: 'app-select-radio-checkbox-ut',
  templateUrl: './select-radio-checkbox-ut.component.html',
  styleUrls: ['./select-radio-checkbox-ut.component.css']
})
export class SelectRadioCheckboxUtComponent implements OnInit {
  sides: string[] = ['Chips', 'Fruit', 'Salad', 'None'];

  extraList = [
    { id: 1, text: 'Xtra Meat' },
    { id: 10, text: 'Avocado' },
    { id: 11, text: 'Cilantro' }
  ];
  extrasAvailable = [];

  sandwichList = [
    { name: 'veggie', text: 'Veggie', type: 'nonMeat' },
    { name: 'beef', text: 'Beef', type: 'meat' },
    { name: 'grilledChicken', text: 'Grilled Chicken', type: 'meat' },
    { name: 'grilledFish', text: 'Grilled Fish', type: 'meat' },
    { name: 'chickenSalad', text: 'Chicken Salad', type: 'meat' },
    { name: 'tunaSalad', text: 'Tuna Salad', type: 'meat' }
  ];
  sandwichFormGroup;
  sandwichOrder;

  constructor() {}

  ngOnInit() {
    this.sandwichFormGroup = new FormGroup({
      sandwich: new FormControl(),
      side: new FormControl(),
      extras: new FormArray([])
    });

    this.addCheckboxes();
  }

  private addCheckboxes() {
    // clear our extras before we reFill it
    this.extrasAvailable.splice(0, this.extrasAvailable.length);

    const nonMeat = this.sandwichList
      .map(v => (v.type === 'nonMeat' ? v.name : null))
      .filter(v => v !== null);

    this.extraList.forEach((o, i) => {
      const sandwichType = this.sandwichFormGroup.controls.sandwich.value;
      // we need to trigger checkboxes when a sandwich is selected
      // if it's a non-meat sammy, don't have the xtra meat option

      if (nonMeat.includes(sandwichType) === false) {
        const control = new FormControl();
        (this.sandwichFormGroup.controls.extras as FormArray).push(control);
        this.extrasAvailable.push({ id: o.id, text: o.text });
      } else {
        if ([1].indexOf(o.id) < 0) {
          const control = new FormControl();
          (this.sandwichFormGroup.controls.extras as FormArray).push(control);
          this.extrasAvailable.push({ id: o.id, text: o.text });
        }
      }
    });
  }

  selectionChanged(selected) {
    // we can use this on ng 8
    // (this.sandwichFormGroup.controls.extras as FormArray).clear();
    while (this.sandwichFormGroup.controls.extras.length > 0) {
      this.sandwichFormGroup.controls.extras.removeAt(0);
    }

    this.addCheckboxes();
  }

  onSubmit() {
    const selectedOrderIds = this.sandwichFormGroup.value.extras
      .map((v, i) => (v ? this.extraList[i].id : null))
      .filter(v => v !== null);

    this.sandwichOrder = this.sandwichFormGroup.value;
    console.log('the sandwich order ', this.sandwichOrder);
  }
}

The Tests

Now on to the reason we’re here, the tests. We start writing our tests as we always do, by adding some imports and setting up our expected results variables. Our expected results often come from calling our APIs and using a set of “real” data, copy pasted into our consts.

Our first test looks at the mat-select itself. We want to be sure that it is appropriately populated from our API. Of course, as we just mentioned, we don’t actually call the API in our tests, we set up our test data manually. The first piece of this test is to grab our test sandwichList, and use map() to populate our expectedSandwichList ONLY with the names of the sandwiches.

    const expectedSandwichList = sandwichList.map(v => v.name);

We’ll use this data in our expect clause, and will discuss it in a moment. Our next order of business is to grab our mat-select. If we look at the hierarchy of the mat-select element, we see that our first child is a mat-select-trigger. In our material world, elements named trigger are often the elements we want to use to trigger actions on an element. So we grab that trigger element and grab it’s nativeElement.

    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

NativeElement is the underlying element of every ElementRef, that is every element in the DOM is a nativeElement. We do have to be careful grabbing the nativeElement if we’re doing this in front end code that will be deployed to users, as this opens us up to XSS attacks. But here in the comfort of our test we can absolutely grab the nativeElement.

Next we click on the select trigger and detect the DOM change.

    trigger.click();
    fixture.detectChanges();

We use async await in this test, so we can wait for the trigger to fire and change to the DOM to complete. Our first order of business when we drop into our promise handler is to grab the mat-select-panel, which contains our mat-options.

      const inquiryOptions = fixture.debugElement.query(
        By.css('.mat-select-panel')
      ).nativeElement;

The full hierarchy, the “meat” of our mat-select, is contained in our CDK overlay. So we descend into the container by moving through cdk-overlay-container -> div.cdk-overlay-N -> div.mat-select-panel-wrap -> mat-select-panel -> mat-option

Since we have multiple options, we want the parent element, in this case again it is mat-select-panel. Once we have that we use a for of loop and check that the current element is contained in the expectedSandwichList that we created at the top of the test. In our expect, we grab the option and get the ng-reflect-value attribute, which in this case is the text name of our sandwich option. In Angular, the ng-reflect- attributes are made for testing, so when they are available use them!

      for (const option of inquiryOptions.children) {
        expect(expectedSandwichList).toContain(
          option.getAttribute('ng-reflect-value')
        );
      }

Here is the full test for populating our mat-select.

  it('should populate our sandwich select from a list in code', async () => {
    const expectedSandwichList = sandwichList.map(v => v.name);
    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

    trigger.click();
    fixture.detectChanges();

    await fixture.whenStable().then(() => {
      const inquiryOptions = fixture.debugElement.query(
        By.css('.mat-select-panel')
      ).nativeElement;

      for (const option of inquiryOptions.children) {
        expect(expectedSandwichList).toContain(
          option.getAttribute('ng-reflect-value')
        );
      }
    });
  });

The next two tests mirror each other, so we’ll only look at the first of these two tests. These tests ensure that our extra options appear appropriately for non-meat and meat sandwiches. From a business case. we would not want a customer to be able to select extra meat on a non-meat sandwich.

As we have been doing, we get our expected extras list by using map() to pull only the text value of each option from the data we gathered from the API.

    const expectedExtrasList = veggieExtraList.map(v => v.text);

We then, like above, grab our mat-select-trigger so we can actually select our sandwich option. We do this instead of simply calling a method directly (which we would want to do as well!), because we want to test a flow of work, and not just the micro level methods. Then we simulate a click on our trigger to fire any events attached to our mat-select.

    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

    trigger.click();
    fixture.detectChanges();

Now that we’ve triggered the event, we once again use async await and wait for the promise to return. This first part of this piece of code could have been done better. Specifically this line of code –

 By.css('.mat-select-panel mat-option[ng-reflect-value="veggie"]')

What we don’t like about this test, is that the actual sandwich name we’re after is hard coded into the test, in this code it’s “veggie”. A better way to do this might be to look for the first instance of each sandwich type in our sandwichList and using that to dynamically populate our test. The way we do it is a bad idea, because it makes the test fragile to any type of change made on the front end. That said, again we use the ng-reflect- attribute to grab the name of our sandwich.

      const nonMeatSandwich = fixture.debugElement.query(
        By.css('.mat-select-panel mat-option[ng-reflect-value="veggie"]')
      ).nativeElement;

      nonMeatSandwich.click();
      fixture.detectChanges();

We now have the option element, which is a child of our mat-select-panel, and the actual element we need to click on to trigger an action from the mat-select. Just like the trigger above, we click on the option element and detect the changes in our DOM.

At this point our option is selected, but no events have fired. Our trigger from above, the mat-select-trigger element, needs to be clicked again to actually fire change detection at the mat-select level. After we do that, any events related to changing an option have fired.

The next step for us is to grab the checkboxes that are in our DOM and ensure that what’s showing is what we expected. To grab our checkboxes, we’ve enclosed them in a div named sandwichExtras. We like to add IDs and use containers to make grabbing DOM elements easier for testing. Once we have that, we can look for the attribute formarrayname with the value extras. The formarrayname attribute is added to our FormArray components, which we add dynamically in our code file. We then look for a span named mat-checkbox-label, which contains the name of each checkbox element.

      const currentExtras = fixture.debugElement.queryAll(
        By.css(
          'div#sandwichExtras mat-label[formarrayname="extras"] span.mat-checkbox-label'
        )
      );

Our expect is wrapped in a for-of that grabs each checkbox. We then look at each item, grab the innerText which contains the name of the extra item, and ensure that it exists in our expectExtrasList.

      for (const item of currentExtras) {
        expect(expectedExtrasList).toContain(item.nativeElement.innerText);
      }

Here is the full test.

  it('should display the appropriate extra options for non-meat sandwich type', async () => {
    const expectedExtrasList = veggieExtraList.map(v => v.text);

    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

    trigger.click();
    fixture.detectChanges();

    await fixture.whenStable().then(() => {
      const nonMeatSandwich = fixture.debugElement.query(
        By.css('.mat-select-panel mat-option[ng-reflect-value="veggie"]')
      ).nativeElement;

      nonMeatSandwich.click();
      fixture.detectChanges();

      trigger.click();
      fixture.detectChanges();

      const currentExtras = fixture.debugElement.queryAll(
        By.css(
          'div#sandwichExtras mat-label[formarrayname="extras"] span.mat-checkbox-label'
        )
      );

      for (const item of currentExtras) {
        expect(expectedExtrasList).toContain(item.nativeElement.innerText);
      }
    });
  });

Our last test is fairly easy to go through. This test ensures that we populate our sides correctly from the test values we get from the API. As we only allow one side for each order, we put our mat-radio-buttons in a group. The mat-radio-group is then linked in to our reactive form, so we start out grabbing our mat-radio-group. As we have in our first test, we loop through the children of our group, which are our individual radio buttons. We then build our expect to look at the ng-reflect-value attribute of each button and ensure that the value exists in our expected sides array.

  it('should populate our sides from a list in code', () => {
    const dElement = fixture.debugElement;
    const nElement = dElement.nativeElement;
    const radGroup = nElement.querySelector('mat-radio-group');

    for (const radButton of radGroup.children) {
      expect(sides).toContain(radButton.getAttribute('ng-reflect-value'));
    }
  });

And here is our full spec.ts file.

import {
  async,
  ComponentFixture,
  TestBed,
} from '@angular/core/testing';

import { SelectRadioCheckboxUtComponent } from './select-radio-checkbox-ut.component';
import {
  MatRadioModule,
  MatCheckboxModule,
  MatSelectModule
} from '@angular/material';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';

describe('SelectRadioCheckboxUtComponent', () => {
  let component: SelectRadioCheckboxUtComponent;
  let fixture: ComponentFixture<SelectRadioCheckboxUtComponent>;

  const sandwichList = [
    { name: 'veggie', text: 'Veggie', type: 'nonMeat' },
    { name: 'beef', text: 'Beef', type: 'meat' },
    { name: 'grilledChicken', text: 'Grilled Chicken', type: 'meat' },
    { name: 'grilledFish', text: 'Grilled Fish', type: 'meat' },
    { name: 'chickenSalad', text: 'Chicken Salad', type: 'meat' },
    { name: 'tunaSalad', text: 'Tuna Salad', type: 'meat' }
  ];

  const sides: string[] = ['Chips', 'Fruit', 'Salad', 'None'];

  const meatExtraList = [
    { id: 1, text: 'Xtra Meat' },
    { id: 10, text: 'Avocado' },
    { id: 11, text: 'Cilantro' }
  ];

  const veggieExtraList = [
    { id: 10, text: 'Avocado' },
    { id: 11, text: 'Cilantro' }
  ];

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [SelectRadioCheckboxUtComponent],
      imports: [
        MatRadioModule,
        MatCheckboxModule,
        MatSelectModule,
        FormsModule,
        ReactiveFormsModule,
        BrowserAnimationsModule
      ]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(SelectRadioCheckboxUtComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should populate our sandwich select from a list in code', async () => {
    const expectedSandwichList = sandwichList.map(v => v.name);
    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

    trigger.click();
    fixture.detectChanges();

    await fixture.whenStable().then(() => {
      const inquiryOptions = fixture.debugElement.query(
        By.css('.mat-select-panel')
      ).nativeElement;

      for (const option of inquiryOptions.children) {
        expect(expectedSandwichList).toContain(
          option.getAttribute('ng-reflect-value')
        );
      }
    });
  });

  it('should display the appropriate extra options for non-meat sandwich type', async () => {
    const expectedExtrasList = veggieExtraList.map(v => v.text);

    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

    trigger.click();
    fixture.detectChanges();

    await fixture.whenStable().then(() => {
      const nonMeatSandwich = fixture.debugElement.query(
        By.css('.mat-select-panel mat-option[ng-reflect-value="veggie"]')
      ).nativeElement;

      nonMeatSandwich.click();
      fixture.detectChanges();

      trigger.click();
      fixture.detectChanges();

      const currentExtras = fixture.debugElement.queryAll(
        By.css(
          'div#sandwichExtras mat-label[formarrayname="extras"] span.mat-checkbox-label'
        )
      );

      for (const item of currentExtras) {
        expect(expectedExtrasList).toContain(item.nativeElement.innerText);
      }
    });
  });

  it('should display the appropriate extra options for meat sandwich type', async () => {
    const expectedExtrasList = meatExtraList.map(v => v.text);

    const trigger = fixture.debugElement.query(By.css('.mat-select-trigger'))
      .nativeElement;

    trigger.click();
    fixture.detectChanges();

    await fixture.whenStable().then(() => {
      const meatSandwich = fixture.debugElement.query(
        By.css('.mat-select-panel mat-option[ng-reflect-value="beef"]')
      ).nativeElement;

      meatSandwich.click();
      fixture.detectChanges();

      trigger.click();
      fixture.detectChanges();

      const currentExtras = fixture.debugElement.queryAll(
        By.css(
          'div#sandwichExtras mat-label[formarrayname="extras"] span.mat-checkbox-label'
        )
      );

      for (const item of currentExtras) {
        expect(expectedExtrasList).toContain(item.nativeElement.innerText);
      }
    });
  });

  it('should populate our sides from a list in code', () => {
    const dElement = fixture.debugElement;
    const nElement = dElement.nativeElement;
    const radGroup = nElement.querySelector('mat-radio-group');

    for (const radButton of radGroup.children) {
      expect(sides).toContain(radButton.getAttribute('ng-reflect-value'));
    }
  });
});

Wrapup

The Select is a veteran of many forms. It allows us to group options in a small space, and can be used as a single or multi select element. In the Angular Material world, it can be a bit tricky to get the testing correct. For example, having to click the trigger before and after the selection of the option is not intuitive. The checkbox and radio button are also mainstays of the forms world. They are quite a bit easier to test, and as we have seen using the ng-reflect- attributes in our tests are the way to go.

In our app here, we have hard coded in our form option values. In addition, we have hard coded in some of the other values in our .ts code and our spec.ts code. In the real world, we would want to work on making these pieces more dynamic. Why? Let’s take this line of code, which checks the extras options for the xtra meat option, which has an ID of 1.

       if ([1].indexOf(o.id) < 0) {

If we add other options that should not be used on a certain type of sandwich, we would need to make a code change. Also, if we have to move that item in the database and it no longer has an ID of 1, we break our code. And more importantly, when we look back at this code a day, week, or year later, we would have no idea what this code does by looking at it. We should, as much as possible, isolate our code from front or back end breaking changes. In the real world, we would want to set up our code so that users of our app would be able to manage the data, without our code caring about those changes.

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, 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.