Coder.Haus | Easy Angular Testing – UI Elements – Stepper
We're a family of geeks who live to design scalable software and hardware solutions. We focus on solid, simple designs focused on industry good standard practices.
ITIL, SLA, Service Level Agreement, Change, Change Management, Development, Coding, Programming, Javascript, Java, Kotlin, Arduino, RaspberryPi, RPi, Android, VSCode, Hacker, Maker, Infrastructure
15923
post-template-default,single,single-post,postid-15923,single-format-standard,ajax_fade,page_not_loaded,,qode-title-hidden,qode_grid_1300,qode_popup_menu_push_text_top,qode-content-sidebar-responsive,qode-theme-ver-17.2,qode-theme-bridge,disabled_footer_bottom,wpb-js-composer js-comp-ver-5.6,vc_responsive

Easy Angular Testing – UI Elements – Stepper

In this post, we’ll look into the Material Stepper. In our example below, we develop a checkout workflow that could be used in a shopping site.

What do we use the stepper for? On Angular’s Material site it describes the stepper as

Angular Material’s stepper provides a wizard-like workflow by dividing content into logical steps.

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

As it states above the stepper give us a way to guide a user through a workflow. Steppers allow us to group those workflows into logical steps, and ensure previous steps are completed before moving on to new steps. Step headers can use either text or icons as the label. The actual step content can contain text, images, components or media.

The Code

In our example, we use the mat-card in one of the steps. In order to separate the look of those cards, we add a bit of css to the component.

.example-card {
  max-width: 400px;
  margin: 1rem;
}

Our html file is fairly long, as we include our functionality right in the file instead of using separate components. Here we’re using the horizontal stepper, though using the vertical stepper the testing would be the same. Your stepper will have as many mat-step elements as there are steps, logically. Within the mat step you can add any component you’d like, as well as text, images, and media. Here is the generation of one of our steps, and includes a button that takes us to the next step. Your matStepperNext button will generate a button with type=”submit”.

<mat-horizontal-stepper [linear]="isLinear" #stepper>
  <mat-step id='orderItems'>
    <!-- <form> -->
    <ng-template matStepLabel>Verify your order</ng-template>
    <mat-card class="example-card" *ngFor="let item of itemsInCart">
      <mat-card-header>
        <mat-card-title>{{ item.name }}</mat-card-title>
        <mat-card-subtitle>
          Author: {{ item.author }}
          <br />
          Cost: ${{ item.cost.toFixed(2) }}
        </mat-card-subtitle>
      </mat-card-header>
    </mat-card>
    <div>
      <button mat-button matStepperNext>Next</button>
    </div>
    <!-- </form> -->
  </mat-step>
</mat-horizontal-stepper>

And here is the entire HTML.

<mat-horizontal-stepper [linear]="isLinear" #stepper>
  <mat-step id='orderItems'>
    <!-- <form> -->
    <ng-template matStepLabel>Verify your order</ng-template>
    <mat-card class="example-card" *ngFor="let item of itemsInCart">
      <mat-card-header>
        <mat-card-title>{{ item.name }}</mat-card-title>
        <mat-card-subtitle>
          Author: {{ item.author }}
          <br />
          Cost: ${{ item.cost.toFixed(2) }}
        </mat-card-subtitle>
      </mat-card-header>
    </mat-card>
    <div>
      <button mat-button matStepperNext>Next</button>
    </div>
    <!-- </form> -->
  </mat-step>
  <mat-step [stepControl]="addressFormGroup">
    <form [formGroup]="addressFormGroup" id="addressForm">
      <ng-template matStepLabel>Fill out your address</ng-template>
      <!-- make a more realistic address group -->
      <mat-form-field>
        <mat-label>Street</mat-label>
        <input
          matInput
          formControlName="street"
          placeholder="Ex. 1 Main St, New York, NY"
          required
        />
      </mat-form-field>
      <br />
      <mat-form-field>
        <mat-label>City</mat-label>
        <input
          matInput
          formControlName="city"
          placeholder="Ex. 1 Main St, New York, NY"
          required
        />
      </mat-form-field>
      <br />
      <mat-form-field>
        <mat-label>State</mat-label>
        <input
          matInput
          formControlName="state"
          placeholder="Ex. 1 Main St, New York, NY"
          required
        />
      </mat-form-field>
      <br />

      <mat-form-field>
        <mat-label>Country</mat-label>
        <input
          matInput
          formControlName="country"
          placeholder="Ex. 1 Main St, New York, NY"
          required
        />
      </mat-form-field>
      <!-- end -->
      <div>
        <button mat-button matStepperPrevious>Back</button>
        <button mat-button matStepperNext>Next</button>
      </div>
    </form>
  </mat-step>
  <mat-step [stepControl]="paymentFormGroup">
    <form [formGroup]="paymentFormGroup" id="paymentForm">
      <ng-template matStepLabel>Enter payment information</ng-template>
      <!-- make a more realistic payment group -->
      <mat-form-field>
        <mat-label>Payment Type: </mat-label>
        <mat-select formControlName="paymentOption">
          <mat-option *ngFor="let payType of paymentTypes" [value]="payType">
            {{ payType }}
          </mat-option>
        </mat-select>
      </mat-form-field>
      <div>
        <button mat-button matStepperPrevious>Back</button>
        <button mat-button matStepperNext>Next</button>
      </div>
    </form>
  </mat-step>
  <mat-step>
    <ng-template matStepLabel>Validate Order</ng-template>
    <h4>Validate your order details</h4>
    <p>
      {{ this.itemsInCart | json }}
    </p>
    <p>
      {{ addressFormGroup.value | json }}
    </p>
    <p>
      {{ paymentFormGroup.value | json }}
    </p>
    <div>
      <button mat-button matStepperPrevious>Back</button>
      <button mat-button (click)="processStepperData()" matStepperNext>
        Submit
      </button>
    </div>
  </mat-step>
  <mat-step>
    <ng-template matStepLabel>Order Placed!</ng-template>
    <p>
      Your order is placed. You should receive a notification within 24 hours.
    </p>
    <div>
      <button mat-button (click)="stepper.reset()">Reset</button>
    </div>
  </mat-step>
</mat-horizontal-stepper>

Here’s the code that runs the functionality. As we typically do, instead of getting our data from a service like we would in the real world, we add static data to play with. We set up basic forms, and a method that will be called at the end of the workflow. Outside of this, there is no deeper functionality in our component.ts file, as we want to keep things simple and focus on testing.

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-stepper',
  templateUrl: './stepper.component.html',
  styleUrls: ['./stepper.component.css']
})
export class StepperComponent implements OnInit {
  isLinear = false;
  addressFormGroup: FormGroup;
  paymentFormGroup: FormGroup;
  orderFormGroup: FormGroup;

  itemsInCart = [
    { name: 'Become a SuperHero Coder', author: 'Ja, Nin', cost: 1000.0 },
    {
      name: 'Learn Angular in 24 Seconds',
      author: 'Codesalot, Suzy',
      cost: 4242.0
    },
    {
      name: 'The Perils of Imposter Syndrome',
      author: 'Itsok, Dr. Stephen',
      cost: 1.0
    }
  ];

  paymentTypes = ['Cash', 'Credit', 'Lines of Code'];

  constructor(private _formBuilder: FormBuilder) {}

  ngOnInit() {
    this.addressFormGroup = this._formBuilder.group({
      street: ['', Validators.required],
      city: ['', Validators.required],
      state: ['', Validators.required],
      country: ['', Validators.required]
    });
    this.paymentFormGroup = this._formBuilder.group({
      paymentOption: ['', Validators.required]
    });
  }

  processStepperData() {
    console.log('submitted');
    console.log('order form ', this.itemsInCart);
    console.log('add form grou ', this.addressFormGroup.value);
    console.log('pay form ', this.paymentFormGroup.value);
  }
}

Finally, we have to add some imports into our module.ts file.

import { MatStepperModule } from '@angular/material/stepper';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule, MatSelectModule } from '@angular/material';
import { MatCardModule } from '@angular/material/card';

The Tests

We start our tests by importing the modules we did in our module.ts file. We also add the BrowserAnimationsModule, which handles the stepper “movement”. Next we set up variables which contain our expected outcomes. In the real world we can get that data directly from a few well placed API calls, or hand building the data from the API specification. I like to name these variables starting with the word “expected” so our tests read well. An example would be

expect(itemsInCart).toEqual(expectedItemsInCart)

Here’s an example of on of our expected* variables.

  const expectedItemsInCart = [
    { name: 'Become a SuperHero Coder', author: 'Ja, Nin', cost: 1000.0 },
    {
      name: 'Learn Angular in 24 Seconds',
      author: 'Codesalot, Suzy',
      cost: 4242.0
    },
    {
      name: 'The Perils of Imposter Syndrome',
      author: 'Itsok, Dr. Stephen',
      cost: 1.0
    }
  ];

Finally we dive into the actual tests. We start out very high level, and move further into our tests until we test the full workflow through. Let’s take a look!

Our first test ensures that we are rendering the number of steps we expected. The element with the mat-horizontal-stepper-content class is where our content lives in each step panel. So easily enough, we grab all of the elements that contain that class, and check the length against our constant expectedNumberOfSteps.

  it('should show the proper number of stepper containers as there are steps', () => {
    const steps = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );
    expect(steps.length).toEqual(expectedNumberOfSteps);
  });

Our next test is for our first step. This step for us contains a group of mat-cards, and we’ll want to dive in and verify that we are getting the data in those cards we expect. We once again grab all elements with the class mat-horizontal-stepper-content, and then look at the first step at position 0.

    const stepOneContent = stepContent[0].children;

Our want here is to test only the cards, and not any buttons or other elements in the step, so we go after our step content. We then coerce the content into an array using Array.from. This is something we do often when testing HTML elements that are grabbed through calls like getElementsByClassName. We then begin looping through each element and use the nodeName to grab ONLY the mat-card elements. Once we find a mat-card, we begin grabbing the content from that card. If you want a deeper dive into how to do this, you can see our post detailing it here. We then finish up testing this step by verifying the content of the card against our expected card content. In our test we only validate against the card title, but could test every piece of content in the card if we’d like.

        expect(expectedItemsInCart).toContain(
          jasmine.objectContaining({
            name: cardTitle.trim()
          })
        );

Here’s the full test.

  it('should show the proper items on the first step', () => {
    const stepContent = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );

    const stepOneContent = stepContent[0].children;

    Array.from(stepOneContent).forEach(element => {
      if (element.nodeName === 'MAT-CARD') {
        const cardTitle = element.getElementsByTagName('mat-card-title')[0]
          .textContent;
        const cardSubtitle = element.getElementsByTagName(
          'mat-card-subtitle'
        )[0].textContent;

        expect(expectedItemsInCart).toContain(
          jasmine.objectContaining({
            name: cardTitle.trim()
          })
        );
      }
    });
  });

Our second test is very simple. It contains a form, and though we don’t go in and validate the form itself, we ensure the step contains a form, and that the form has the appropriate id. We could easily expect the form was built in a separate component, and should have been tested there.

  it('should show the correct form on the second step', () => {
    const stepContent = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );

    const stepTwoContent = stepContent[1].children;

    expect(stepTwoContent[0].nodeName).toEqual('FORM');
    expect(stepTwoContent[0].id).toEqual('addressForm');
  });

Our final test is much longer and goes all in. We test the full workflow, clicking the next button in each step. What we would want to do is take the appropriate actions in each step, clicking the next button after each action, and ensure that our code to file the data, in our next to last stepper, is called. Our best course of action would be to set up utility functions to do most of this work, which we could use throughout our tests, but as we typically do in these posts we’ll go ahead and write out a long test.

Our first course of action is to set up a spy for our final method that will be called, processStepperdata(). This will be used in our final expect.

    const spy = spyOn(component, 'processStepperData');

We’ll grab the only button in our first step using our old friend getElementsByTagName. We know, in our case, our first step only has one button, so click it then detect that change.

    const stepOneButton = stepContent[0].getElementsByTagName('button');
    stepOneButton[0].dispatchEvent(new MouseEvent('click'));
    fixture.detectChanges();

Our second step contains our form. In our test below we don’t fill the form out, though in a real world test we would want to have a utility function to fill that form out before pressing the button. We would do that before we grab the buttons. Also in this step, we would have both a back and next button so we need the correct button. Here we know the type would be submit, so we check button.type and then dispatch a mouse click. Our third step is the same, there is a dropdown we would want to take action on, then perform the same step with the button as below.

    const stepTwoContent = stepContent[1].getElementsByTagName('button');
    Array.from(stepTwoContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });
    fixture.detectChanges();

In our next step, the next to last, in our example we show the data from the previous steps to verify. We would want to check that all of the data is appropriate before we take action on the submit button, though we do not below. We then take action on the submit button as we have been in previous steps, detect those changes, and now the moment of truth, we verify that our spied on method was called.

    const stepFourContent = stepContent[3].getElementsByTagName('button');
    Array.from(stepFourContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });

    fixture.detectChanges();

    expect(spy).toHaveBeenCalled();

Here is the full test.

  it('should call processStepperData when the user accepts what is on the next to last tab', () => {

    const spy = spyOn(component, 'processStepperData');

    const stepContent = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );

    const stepOneButton = stepContent[0].getElementsByTagName('button');
    stepOneButton[0].dispatchEvent(new MouseEvent('click'));
    fixture.detectChanges();

    const stepTwoContent = stepContent[1].getElementsByTagName('button');
    Array.from(stepTwoContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });
    fixture.detectChanges();

    const stepThreeContent = stepContent[2].getElementsByTagName('button');
    Array.from(stepThreeContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });
    fixture.detectChanges();

    const stepFourContent = stepContent[3].getElementsByTagName('button');
    Array.from(stepFourContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });

    fixture.detectChanges();

    expect(spy).toHaveBeenCalled();
  });

And now our complete spec.ts file.

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

import { MatStepperModule } from '@angular/material/stepper';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule, MatSelectModule } from '@angular/material';
import { MatCardModule } from '@angular/material/card';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { StepperComponent } from './stepper.component';

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

  const expectedItemsInCart = [
    { name: 'Become a SuperHero Coder', author: 'Ja, Nin', cost: 1000.0 },
    {
      name: 'Learn Angular in 24 Seconds',
      author: 'Codesalot, Suzy',
      cost: 4242.0
    },
    {
      name: 'The Perils of Imposter Syndrome',
      author: 'Itsok, Dr. Stephen',
      cost: 1.0
    }
  ];

  const expectedNumberOfSteps = 5;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [StepperComponent],
      imports: [
        MatStepperModule,
        MatFormFieldModule,
        MatInputModule,
        MatSelectModule,
        MatCardModule,
        FormsModule,
        ReactiveFormsModule,
        BrowserAnimationsModule
      ]
    }).compileComponents();
  }));

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

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

  it('should show the proper number of stepper containers as there are steps', () => {
    const numberOfSteps = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );
    expect(numberOfSteps.length).toEqual(expectedNumberOfSteps);
  });

  it('should show the proper items on the first step', () => {
    const stepContent = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );

    const stepOneContent = stepContent[0].children;

    Array.from(stepOneContent).forEach(element => {
      if (element.nodeName === 'MAT-CARD') {
        const cardTitle = element.getElementsByTagName('mat-card-title')[0]
          .textContent;
        const cardSubtitle = element.getElementsByTagName(
          'mat-card-subtitle'
        )[0].textContent;

        expect(expectedItemsInCart).toContain(
          jasmine.objectContaining({
            name: cardTitle.trim()
          })
        );
      }
    });
  });

  it('should show the correct form on the second step', () => {
    const stepContent = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );

    const stepTwoContent = stepContent[1].children;

    expect(stepTwoContent[0].nodeName).toEqual('FORM');
    expect(stepTwoContent[0].id).toEqual('addressForm');
  });

  it('should call processStepperData when the user accepts what is on the next to last tab', () => {

    const spy = spyOn(component, 'processStepperData');

    const stepContent = document.getElementsByClassName(
      'mat-horizontal-stepper-content'
    );

    const stepOneButton = stepContent[0].getElementsByTagName('button');
    stepOneButton[0].dispatchEvent(new MouseEvent('click'));
    fixture.detectChanges();

    const stepTwoContent = stepContent[1].getElementsByTagName('button');
    Array.from(stepTwoContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });
    fixture.detectChanges();

    const stepThreeContent = stepContent[2].getElementsByTagName('button');
    Array.from(stepThreeContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });
    fixture.detectChanges();

    const stepFourContent = stepContent[3].getElementsByTagName('button');
    Array.from(stepFourContent).forEach(button => {
      if (button.type === 'submit') {
        button.dispatchEvent(new MouseEvent('click'));
      }
    });

    fixture.detectChanges();
    expect(spy).toHaveBeenCalled();
  });
});

Wrapup

Steppers are great for separating a workflow into logical steps, and then guiding a user through those steps. Material Steppers are great in that we can focus on one piece of a workflow at a time, and verify that steps were completed as well as validate the actions and data in a step. They also keep our view compact, and they can contain other components to make them as interactive and interesting as we want.

We only tested the basics of Steppers here, just enough to grab the bits and pieces we would need to then dive deeper. We stopped just before we would need to grab more, and we didn’t test our validation. We also did not use the icon functionality in the header labels. In addition, something we always need to be cautious of, is we did not test library functionality. It wouldn’t make sense to test that our back button changed the focus to the previous step, as we would expect the author of that library to test that.

Always remember, our goal in testing shouldn’t be individual element based it should be looking for outcomes of a 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.

Tags:
,