Easy Angular Testing – UI Elements – Tab and Expansion Panel

In this post, we’ll look into the Material Tab and Expansion Panel. In our example below, we develop a tab based dinner menu, that separates menu items into three groups.

What do we use tabs for? On Google’s Material site it describes the tab as

Tabs organize content across different screens, data sets, and other interactions.

https://material.io/components/tabs

And from a usage perspective, Google says

Angular Material tabs organize content into separate views where only one view can be visible at a time. Each tab’s label is shown in the tab header and the active tab’s label is designated with the animated ink bar.

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

What do we use expansion panels for? On the Angular Material site it says

<mat-expansion-panel> provides an expandable details-summary view.

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

As it states above tabs give us a way to add groups of data, with only one group visible at a time. Expansion panels allow us to dive into details of an item, without showing those details at the outset. Tabs can use either text or icons as the label. Expansion panel content can contain text, images, components or media.

The Code

As we often do, we write out our code and tests long hand here to show how testing is done. There are a lot of efficiencies we can gain by refactoring this code, which is easy once we have these tests in place!

Let’s start looking at our HTML file. Nothing special here, we call out our tabs for each menu, then use *ngFor to create our expansion panels. Here is one of the tabs to show us the way.

  <mat-tab label="Beef">
    <mat-accordion id='beef'>
      <mat-expansion-panel *ngFor="let beef of beefMenu">
        <mat-expansion-panel-header>
          <mat-panel-title>
            {{ beef.name }}
          </mat-panel-title>
          <mat-panel-description>
            {{ beef.description }}
          </mat-panel-description>
        </mat-expansion-panel-header>
        <span>{{beef.img}}</span>
      </mat-expansion-panel>
    </mat-accordion>
  </mat-tab>

And here is the full HTML file.

<mat-tab-group id='menu'>
  <mat-tab label="Beef">
    <mat-accordion id='beef'>
      <mat-expansion-panel *ngFor="let beef of beefMenu">
        <mat-expansion-panel-header>
          <mat-panel-title>
            {{ beef.name }}
          </mat-panel-title>
          <mat-panel-description>
            {{ beef.description }}
          </mat-panel-description>
        </mat-expansion-panel-header>
        <span>{{beef.img}}</span>
      </mat-expansion-panel>
    </mat-accordion>
  </mat-tab>
  <mat-tab label="Chicken">
    <mat-accordion id='chicken'>
      <mat-expansion-panel *ngFor="let chicken of chickenMenu">
        <mat-expansion-panel-header>
          <mat-panel-title>
            {{ chicken.name }}
          </mat-panel-title>
          <mat-panel-description>
            {{ chicken.description }}
          </mat-panel-description>
        </mat-expansion-panel-header>
        <span>{{chicken.img}}</span>
      </mat-expansion-panel>
    </mat-accordion>
  </mat-tab>
  <mat-tab label="Pork">
    <mat-accordion id='pork'>
      <mat-expansion-panel *ngFor="let pork of porkMenu">
        <mat-expansion-panel-header>
          <mat-panel-title>
            {{ pork.name }}
          </mat-panel-title>
          <mat-panel-description>
            {{ pork.description }}
          </mat-panel-description>
        </mat-expansion-panel-header>
        <span>{{pork.img}}</span>
      </mat-expansion-panel>
    </mat-accordion></mat-tab
  >
</mat-tab-group>

And in the ultimate of ease, there is no real functionality in our component.ts file. Our code simply contains our data. In the real world, we could get this data from services. We could then dynamically create the tabs and groupings from the data we received, allowing us infinite flexibility.

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

@Component({
  selector: 'app-tab-expansion-panel-ut',
  templateUrl: './tab-expansion-panel-ut.component.html',
  styleUrls: ['./tab-expansion-panel-ut.component.css']
})
export class TabExpansionPanelUtComponent implements OnInit {
  beefMenu = [
    { name: 'Filet', description: 'Yummy', img: '' },
    { name: 'T-bone', description: 'Good', img: '' }
  ];

  chickenMenu = [
    { name: 'Grilled breast', description: 'Yummy', img: '' },
    { name: 'Fried drumsticks', description: 'Good', img: '' }
  ];

  porkMenu = [
    { name: 'Tenderloin Medalliion', description: 'Yummy', img: '' },
    { name: 'Steak', description: 'Good', img: '' }
  ];
  constructor() {}

  ngOnInit() {}
}

The Tests

Our tests here have a lot to be desired. There is definitely room for pulling out utility functions to avoid writing the same code over and over again. If we were going to refactor, we would look here in the tests as well as in our app’s code. With that said, these tests gets the job done, and show us how to test the functionality we have.

There’s nothing special in the top half of our spec. We import the modules we need, and set up our expected data. We could have pulled this data right from our service API, or hand built it to the API spec. Either way gives us real world data to test with. While this forces us to rework this test data with each API change, it ensures that we review this code from time to time.

Our first test verifies that the tabs we expect are showing and stops there. First, we wait for the page to stop rendering with the call to fixture.whenRenderingDone(), then we grab everything with a class of mat-tab-label-content and an id of menu. We add the #menu id to our tab-group element so we can differentiate between other tab groups that might be on our page. This is a favorite of mine, adding ids to items I need to style or test. It makes finding them much easier.

We then convert our tab labels to an Array with the call to Array.from(). This makes it so we can easily iterate through our labels and check them against our variable that contains the tabs we expect to see, which we do in the expect statement.

  it('should have the correct tabs', () => {
    fixture.whenRenderingDone().then(() => {
      const tabLabels = document.querySelectorAll(
        '#menu .mat-tab-label-content'
      );

      Array.from(tabLabels).forEach(element => {
        expect(expectedTabLabels).toContain(element.textContent);
      });
    });
  });

We then run through each tab individually and check that each tab has the right data, and each expansion panel in that tab has the correct data. The test I show here is a bit longer than the first test that does this same function. The reason this test is longer, is that we grab the tab we want to dive in to and click on it, to ensure it’s activated.

The first thing we do here is grab the tab-header. We do this by looking for the mat-tab-labels class, which lives in the div that encloses all of our tab labels. Here we only have one group of these labels, so we go after those at index 0. We then iterate through the child nodes in this container, looking for the node that contains the tab we’re looking for. We save that tab in the theElement variable, which we use to dispatch a mouse click. This activates the tab we’re looking for.

    const labelContainer = document.getElementsByClassName('mat-tab-labels')[0];

    let theElement: ChildNode;

    labelContainer.childNodes.forEach(element => {
      if (element.textContent === expectedTabLabels[1]) {
        theElement = element;
      }
    });

    theElement.dispatchEvent(new MouseEvent('click'));
    fixture.detectChanges();

Things they will be a changing! We add in the call to fixture.whenStable() to wait until the page is done making updates before we dive into the next set of functionality. Here we grab the our container, using the mat-accordion element selector labeled with the id of chicken, and go a level deeper for the mat-expansion-panels themselves.

    fixture.whenStable().then(() => {
      const expansionPanels = document.querySelectorAll(
        'mat-accordion#chicken  mat-expansion-panel'
      );

We then use our Array.from to iterate through our expansion panels. This time we’re grabbing the title, description and content of each expansion panel and saving that data in variables, which we’ll use in a minute to verify we have the data we expect. Here you can either grab every piece of data from the expansion panel, or only the amount you need to verify each expansion panel exists. It’s up to you and your need. Here we grab it all, and could do the same even if we had other components embedded in the panel.

      Array.from(expansionPanels).forEach(element => {
        const headerTitle = element.querySelector(
          'mat-expansion-panel-header mat-panel-title'
        );
        const headerDescription = element.querySelector(
          'mat-expansion-panel-header mat-panel-description'
        );
        const content = element.querySelector('div .mat-expansion-panel-body');

And now our magic! We create an object in the call to jasmine.objectContaining() that includes any data we want to verify. A thing of note, I like to call .trim() on any text elements here, as we might add whitespace to ensure our design looks good. We call our expect on the expected data variable we have in the spec, that we added way at the top of this spec.

        expect(chickenMenu).toContain(
          jasmine.objectContaining({
            name: headerTitle.textContent.trim(),
            description: headerDescription.textContent.trim(),
            img: content.textContent.trim()
          })
        );

And we do this for each tab under test that we need to verify. If each tab has similar data, this is where we can refactor our tests down to perhaps a single it clause, and use utility functions that iterate through each tab and executes the tests for us. The benefit of this is if we add more tabs full of data, we would only need to add in test data and the tests would handle the rest.

Here is our full it clause from above.

  it('should have the correct data in the chicken tab', () => {
    const labelContainer = document.getElementsByClassName('mat-tab-labels')[0];

    let theElement: ChildNode;

    labelContainer.childNodes.forEach(element => {
      if (element.textContent === expectedTabLabels[1]) {
        theElement = element;
      }
    });

    theElement.dispatchEvent(new MouseEvent('click'));
    fixture.detectChanges();

    fixture.whenStable().then(() => {
      const expansionPanels = document.querySelectorAll(
        'mat-accordion#chicken  mat-expansion-panel'
      );

      Array.from(expansionPanels).forEach(element => {
        const headerTitle = element.querySelector(
          'mat-expansion-panel-header mat-panel-title'
        );
        const headerDescription = element.querySelector(
          'mat-expansion-panel-header mat-panel-description'
        );
        const content = element.querySelector('div .mat-expansion-panel-body');

        expect(chickenMenu).toContain(
          jasmine.objectContaining({
            name: headerTitle.textContent.trim(),
            description: headerDescription.textContent.trim(),
            img: content.textContent.trim()
          })
        );
      });
    });
  });

And here is our full spec.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatTabsModule, MatTabLabel } from '@angular/material/tabs';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { TabExpansionPanelUtComponent } from './tab-expansion-panel-ut.component';

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

  const expectedTabLabels = ['Beef', 'Chicken', 'Pork'];

  const beefMenu = [
    {
      name: 'Filet',
      description: 'Yummy',
      img: ''
    },
    {
      name: 'T-bone',
      description: 'Good',
      img: ''
    }
  ];

  const chickenMenu = [
    {
      name: 'Grilled breast',
      description: 'Yummy',
      img: ''
    },
    {
      name: 'Fried drumsticks',
      description: 'Good',
      img: ''
    }
  ];

  const porkMenu = [
    {
      name: 'Tenderloin Medalliion',
      description: 'Yummy',
      img: ''
    },
    {
      name: 'Steak',
      description: 'Good',
      img: ''
    }
  ];

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [TabExpansionPanelUtComponent],
      imports: [MatExpansionModule, MatTabsModule, BrowserAnimationsModule]
    }).compileComponents();
  }));

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

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

  it('should have the correct tabs', () => {
    fixture.whenRenderingDone().then(() => {
      const tabLabels = document.querySelectorAll(
        '#menu .mat-tab-label-content'
      );

      Array.from(tabLabels).forEach(element => {
        expect(expectedTabLabels).toContain(element.textContent);
      });
    });
  });

  it('should have the correct data in the beef tab', () => {
    const expansionPanels = document.querySelectorAll(
      'mat-accordion#beef mat-expansion-panel'
    );

    Array.from(expansionPanels).forEach(element => {
      const headerTitle = element.querySelector(
        'mat-expansion-panel-header mat-panel-title'
      );
      const headerDescription = element.querySelector(
        'mat-expansion-panel-header mat-panel-description'
      );

      const content = element.querySelector('div .mat-expansion-panel-body');

      expect(beefMenu).toContain(
        jasmine.objectContaining({
          name: headerTitle.textContent.trim(),
          description: headerDescription.textContent.trim(),
          img: content.textContent.trim()
        })
      );
    });
  });

  it('should have the correct data in the chicken tab', () => {
    const labelContainer = document.getElementsByClassName('mat-tab-labels')[0];

    let theElement: ChildNode;

    labelContainer.childNodes.forEach(element => {
      if (element.textContent === expectedTabLabels[1]) {
        console.log('the element ', element.textContent);
        theElement = element;
      }
    });

    theElement.dispatchEvent(new MouseEvent('click'));
    fixture.detectChanges();

    fixture.whenStable().then(() => {
      const expansionPanels = document.querySelectorAll(
        'mat-accordion#chicken  mat-expansion-panel'
      );

      Array.from(expansionPanels).forEach(element => {
        const headerTitle = element.querySelector(
          'mat-expansion-panel-header mat-panel-title'
        );
        const headerDescription = element.querySelector(
          'mat-expansion-panel-header mat-panel-description'
        );

        const content = element.querySelector('div .mat-expansion-panel-body');

        expect(chickenMenu).toContain(
          jasmine.objectContaining({
            name: headerTitle.textContent.trim(),
            description: headerDescription.textContent.trim(),
            img: content.textContent.trim()
          })
        );
      });
    });
  });
});

Wrapup

Material Tabs are great for separating similar data, that exists in distinct groups. They are great for showing data that you don’t want to have users sort, like items in a food menu. Material Expansion Panels are great in that we can easily see a small piece of data, enough to help us know if we are looking at the item we want to know more about, and then dig deep with a click. They 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 Tabs and Expansion Panels here, just enough to grab the bits and pieces we would need to then dive deeper. Because of the potential to add just about any content to the expansion panel and tab, including custom components and all types of interactive elements, we stopped just before we would need to grab more. We also did not use the icon functionality in the tabs, which you might have seen, for example, in mobile apps like the FitBit app, or on the web on the material.io site itself in the header.

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.