Easy Angular Testing – UI Elements – Lists

In this post, we’re looking to expand our coding chops by understanding how to test the mat-list component. In our example below, we’re going to set up a basic list with a few buttons, and write some high level tests.

What’s this list thing good for? On Google’s Material site it describes the list as

A continuous group of text or images. They are composed of items containing primary and supplemental actions, which are represented by icons and text.

https://material.io/components/lists/#

Lists are a nice way of displaying your data in a concise way. They’re powerful in that you can embed other components, such as expansion panels and cards, directly in them. This gives you a lot of options. While users can interact with certain types of lists, such as action or selection lists, lists are defined as layout components. Here we’re going to group them in with non-interactive elements. We’ll save more interactive elements like buttons, checkboxes and drop downs for other exercises, and re-explore the interactive side of lists, including testing embedded components, then.

Our code under test

While the tests below don’t encompass all of the power of the list, we show the basics of setting up tests for a list. This will allow us to add other tests on to get at functionality like embedded expansion panels, actions, and other non-list specific items.

We start out with our HTML. Here we have a simple setup with three buttons, and a basic mat-list element that loops through the data we want to show.

<button
  mat-button
  id="landAnimalButton"
  (click)="showFilteredAnimals('land')"
  aria-label="Show an example land animal list"
>
  Land Animals
</button>

<button
  mat-button
  id="airAnimalButton"
  (click)="showFilteredAnimals('air')"
  aria-label="Show an example air animal list"
>
  Air Animals
</button>

<button
  mat-button
  id="allAnimals"
  (click)="showAllAnimals()"
  aria-label="Show all animals by type"
>
  All Animals
</button>

<mat-list id="{{ listType }}">
  <mat-list-item *ngFor="let animal of listData">
    <span>
      <img
        [src]="animal.avatar"
        alt="{{ animal.name }}"
        class="img-responsive"
        style="max-height: 50px;"
      />
    </span>
    <h4 mat-line>{{ animal.name }}</h4>
    <p mat-line>{{ animal.description }}</p>
  </mat-list-item>
</mat-list>

Next we have the functionality that backs our HTML from our component.ts. To make things simple, we don’t call an API to get our data. Instead, and only for the purposes of this testing, we embed the data we are going to show right in this component. Actually, this isn’t such a bad idea when you’re first putting an idea into code. By keeping all of your data and functionality in one place, it allows you some piece of mind that if there are any issues, it’s the functionality you’re working directly with and not some other component or service causing you pain.

In our code, we start out by showing all of the animals in our list by adding the call to showAllAnimals in ngOnInit(). We then have four simple methods, two that get our data and two that will be called from our buttons. It’s good to separate out the calls for getting the data with the methods that might need to perform some extra actions to get the data ready for display. In our case this is setting two attributes, one that is used for our mat-list class name that defines what type of animal is being shown, and one with our actual data that will be shown.

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-list-ut',
  templateUrl: './list-ut.component.html',
  styleUrls: ['./list-ut.component.css']
})
export class ListUtComponent implements OnInit {
  apiReturnedAnimals = [
    {
      name: 'Blue Bull',
      description: 'This is a Blue Bull',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/7/72/1000BlueBull.jpg',
      category: 'land'
    },
    {
      name: 'Cervus',
      description: 'This is a Cervus',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/6/62/102Cervus.jpg',
      category: 'land'
    },
    {
      name: 'Black Buck',
      description: 'This is a Black Buck',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/f/f0/10BlackBuck.jpg',
      category: 'land'
    },
    {
      name: 'Mynah',
      description: 'This is a Mynah',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/2/2f/3617brahminy-mynah.jpg',
      category: 'air'
    },
    {
      name: 'Malabar Parkeet',
      description: 'This is a parkeet',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/7/71/2005-malabar-parkeet-p.jpg',
      category: 'air'
    }
  ];

  public listData;
  public listType;
  public singleTypeAnimals: boolean;
  constructor() {}

  ngOnInit() {
    // start showing everything.
    this.showAllAnimals();
  }

  getAllAnimals() {
    return this.apiReturnedAnimals;
  }

  getAnimalsByType(animalType: string) {
    this.listData = this.getAllAnimals();
    return this.listData.filter(animal => animal.category === animalType);
  }

  showFilteredAnimals(animalType: string) {
    this.listType = animalType;
    this.listData = this.getAnimalsByType(animalType);
  }

  showAllAnimals() {
    this.listType = 'all';
    this.listData = this.getAllAnimals();
  }
}

Our tests

Now on to our tests. Our first task is to ensure that we have some static data to test against. Like we did in our component.ts file, to get mock data while writing our methods, we set up three pieces of static data – our full array to test against, and two sub-category arrays segregated by animal type. We do this because we have buttons that will show only a single type of animal, so we will pass in our mock allAnimals array and ensure that what we get back is one of those sub-category arrays. To get this data in the real world, what I like to do is grab a subset of data from the actual API I’m coding to, and embed that data into our code. It would seem a downside of this methodology is, when our data structure changes we would need to update our tests data. We would more than likely need to change our code in this case anyway, which means (in our TDD world) we would need to change our tests.

An aside, why don’t we just take the full array of animals, and then use a filter() to get our subsets? You could certainly do that! When I test, I like to use as little functionality as possible in my test that isn’t actually testing something. The best way to ensure that our test is working properly is to have it as bare as possible. This includes setting up test data by hand.

What we’re going to do functionally in our tests is activate the methods to show our mat-list elements. Here we do that by clicking on a button. We’re only testing our landAnimalButton here, but if we had other buttons we would want to verify their actions triggered the correct outcomes as well. Remember, it’s not just testing the functionality but testing the outcome of some user flow. So if they would click on the button to show air animals, you should test that too.

const buttonDe: DebugElement = fixture.debugElement;
const buttonEl: HTMLElement = buttonDe.nativeElement;
const button = buttonEl.querySelector('button#landAnimalButton');

const mouseEvent = new MouseEvent('click');
button.dispatchEvent(mouseEvent);
fixture.detectChanges();

As we said earlier, we would most likely get our data from a service that will either retrieve it from a DB or a web API.  Here we are spying on a call to getAnimalsByType, which will be called from our show method.   If we were getting this data from a service, we would still spy on, and mock, that call. The point here is that we shouldn’t test functionality from an outside service here, we should test the service in that service’s tests. So we trust the data from our service is good, because it’s tested already.

spyOn(component, 'getAnimalsByType').and.returnValue(landAnimals);

Now let’s find our mat-list element under test. It’s in our best interest to add an id to elements. In cases like this, where we need to find the element to test, as well as for styling and other functionality, we can more easily find our element and verify that it is the element we need.

In our test, we use an animalType variable to find our element. We could easily use a single it statement to loop through multiple animalTypes. Here we are focused only on the one, land animals.

As we discussed above, the button code is going to get all of our animals and then filter that list to the animals we are looking for. We validate, with our test code’s static data, that we are ONLY showing the animals we expect to. So we are looking through our DOM, and finding the mat-list element. We could then look through our mat-list-item, mat-list-item-content and mat-list-text elements, and you might need to in your test. For us though, we know that our animal name is in an h4 tag, so we go right after our h4 tags. We return them in an array, loop through each one and test that the innerText, our animal name in this case, is in our landAnimals array.

fixture.whenStable().then(() => {
    const ourDomListUnderTest = document.querySelector('mat-list#' + animalType);
      Array.from(ourDomListUnderTest.getElementsByTagName('h4')).forEach(
    element => {
        expect(landAnimals).toContain(
            jasmine.objectContaining({ name: element.innerText })
        );
    });
});

!Beware! There is a gap in this test! We can’t be sure that all animals in the array are accounted for in our list, only that each animal we DO have in our list is in the array. While this could be a dangerous miss, I’ll leave it up to you, dear reader, to fill in that gap. We also have a test that verifies that all animals we show in the list are in the source data. Again we have the same gap as above, as we use the same methodology for testing this case.

In our final test, we verify that our avatar is the correct avatar for the animal. Most of the functionality in this test is the same as the above tests. The change is how we traverse our array. Above, after we have our mat-list, we go deep into the nested elements and directly grab our h4 elements, which contain the animal names. Here we do stay higher level and go after the mat-list-item element. We do this because we’ll not only need our h4 element, but also our image element, and these both live under the mat-list-item. Once we have those elements, we dive a bit deeper into the image element to get the URI located in the src attribute. We then create an object containing both our animal name, and the avatar URI. We use the jasomine.objectContaining method to test that object against our landAnimals array. This will let us know if there are objects in the landAnimals array with both attributes. If the animal name we have in the object isn’t congruent with the URI we also have, compared with our test data, then we will display the wrong avatar. The error this is looking for is a case where we might not be updating some variable in our code, and holding on to stale data.

Array.from(
    ourDomListUnderTest.getElementsByTagName('mat-list-item')
    ).forEach(element => {
        const animalName = element.getElementsByTagName('h4')[0].innerText;
    const animalAvatar = element
        .getElementsByTagName('img')[0]
        .getAttribute('src');

        expect(landAnimals).toContain(
        jasmine.objectContaining({ name: animalName, avatar: animalAvatar })
    );
 });

And here is the full test spec, in all its glory.

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

import { ListUtComponent } from './list-ut.component';
import { DebugElement } from '@angular/core';

describe('ui-noninteractive - ListUtComponent', () => {
  let component: ListUtComponent;
  let fixture: ComponentFixture<ListUtComponent>;

  const landAnimals = [
    {
      name: 'Blue Bull',
      description: 'This is a Blue Bull',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/7/72/1000BlueBull.jpg',
      category: 'land'
    },
    {
      name: 'Cervus',
      description: 'This is a Cervus',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/6/62/102Cervus.jpg',
      category: 'land'
    },
    {
      name: 'Black Buck',
      description: 'This is a Black Buck',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/f/f0/10BlackBuck.jpg',
      category: 'land'
    }
  ];

  const airAnimals = [
    {
      name: 'item four',
      description: 'This is item four',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/2/2f/3617brahminy-mynah.jpg',
      category: 'air'
    },
    {
      name: 'Malabar Parkeet',
      description: 'This is a parkeet',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/7/71/2005-malabar-parkeet-p.jpg',
      category: 'air'
    }
  ];

  const allAnimals = [
    {
      name: 'Blue Bull',
      description: 'This is a Blue Bull',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/7/72/1000BlueBull.jpg',
      category: 'land'
    },
    {
      name: 'Cervus',
      description: 'This is a Cervus',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/6/62/102Cervus.jpg',
      category: 'land'
    },
    {
      name: 'Black Buck',
      description: 'This is a Black Buck',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/f/f0/10BlackBuck.jpg',
      category: 'land'
    },
    {
      name: 'Mynah',
      description: 'This is a Mynah',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/2/2f/3617brahminy-mynah.jpg',
      category: 'air'
    },
    {
      name: 'Malabar Parkeet',
      description: 'This is a parkeet',
      avatar:
        'https://upload.wikimedia.org/wikipedia/commons/7/71/2005-malabar-parkeet-p.jpg',
      category: 'air'
    }
  ];

  const listItems = [];

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [MatListModule],
      declarations: [ListUtComponent]
    }).compileComponents();
  }));

  beforeEach(() => {
    listItems.concat(landAnimals, airAnimals);

    fixture = TestBed.createComponent(ListUtComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  it('should create a list with land animals', () => {
    const buttonDe: DebugElement = fixture.debugElement;
    const buttonEl: HTMLElement = buttonDe.nativeElement;
    const button = buttonEl.querySelector('button#landAnimalButton');

    const animalType = 'land';

    spyOn(component, 'getAnimalsByType').and.returnValue(landAnimals);

    const mouseEvent = new MouseEvent('click');
    button.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    fixture.whenStable().then(() => {
      const ourDomListUnderTest = document.querySelector(
        'mat-list#' + animalType
      );
 Array.from(ourDomListUnderTest.getElementsByTagName('h4')).forEach(
        element => {
          expect(landAnimals).toContain(
            jasmine.objectContaining({ name: element.innerText })
          );
        }
      );
    });
  });

  it('should create a list with all animals', () => {
    const buttonDe: DebugElement = fixture.debugElement;
    const buttonEl: HTMLElement = buttonDe.nativeElement;
    const button = buttonEl.querySelector('button#allAnimals');

    const animalType = 'all';

    spyOn(component, 'getAllAnimals').and.returnValue(allAnimals);

    const mouseEvent = new MouseEvent('click');
    button.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    fixture.whenStable().then(() => {
      const ourDomListUnderTest = document.querySelector(
        'mat-list#' + animalType
      );
 Array.from(ourDomListUnderTest.getElementsByTagName('h4')).forEach(
        element => {
          expect(allAnimals).toContain(
            jasmine.objectContaining({ name: element.innerText })
          );
        }
      );
    });
  });

  it('should show the user the correct avatar', () => {
    const buttonDe: DebugElement = fixture.debugElement;
    const buttonEl: HTMLElement = buttonDe.nativeElement;
    const button = buttonEl.querySelector('button#airAnimalButton');

    const animalType = 'air';

    spyOn(component, 'getAnimalsByType').and.returnValue(landAnimals);

    const mouseEvent = new MouseEvent('click');
    button.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    fixture.whenStable().then(() => {
      const ourDomListUnderTest = document.querySelector(
        'mat-list#' + animalType
      );

      Array.from(
        ourDomListUnderTest.getElementsByTagName('mat-list-item')
      ).forEach(element => {
        const animalName = element.getElementsByTagName('h4')[0].innerText;
        const animalAvatar = element
          .getElementsByTagName('img')[0]
          .getAttribute('src');

        expect(landAnimals).toContain(
          jasmine.objectContaining({ name: animalName, avatar: animalAvatar })
        );
      });
    });
  });
});

Wrapup

Material Lists can be powerful components for showing all types of data. While we would typically add some type of action in our list, here we are only concerned with the basic list. Adding in testing of other elements or embedded components is made easier once you know how to extract them from the list. Always remember though, 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.