Easy Angular Testing – UI Elements – Bottom Sheet

In this post, we’ll look into the Material Bottom Sheet. In our example below, we set up a chat and a custom SVG button to launch that chat. This could mimic a site with a “Chat with Us” option.

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

Bottom sheets are surfaces containing supplementary content that are anchored to the bottom of the screen.

https://material.io/components/sheets-bottom/

And from a usage perspective, Google says

Bottom sheets are supplementary surfaces primarily used on mobile. There are three types suitable for different use cases:

Standard bottom sheets display content that complements the screen’s primary content. They remain visible while users interact with the primary content.

Modal bottom sheets are an alternative to inline menus or simple dialogs on mobile and provide room for additional items, longer descriptions, and iconography. They must be dismissed in order to interact with the underlying content.

Expanding bottom sheets provide a small, collapsed surface that can be expanded by the user to access a key feature or task. They offer the persistent access of a standard sheet with the space and focus of a modal sheet.

https://material.io/components/sheets-bottom/#usage

Bottom sheets give us a way to make non-primary information and actions available to a user, without taking up valuable real estate. Uses of bottom sheets would include real time chat with helpdesk or other staff, a save menu to move data to a cloud document repository, or media play controls. The idea is to make functionality that is not critical to the primary workflow available, without impeding that workflow for the user.

The Code

We’ll start with our front end. The two HTML files are not overly complex. The main HTML file includes an svg that acts as our button to open the bottom sheet. We do this by adding a click handler, and using CSS on the svg path to change our cursor to a pointer.

<div class="right">
  <svg
    id="oursvg"
    (click)="openBottomSheet()"
    xmlns="http://www.w3.org/2000/svg"
    width="1in"
    height="0.60in"
    viewBox="0 0 59 19"
  >
      <path
        class="pointerCursor"
        id="Selection"
        fill="#445599"
        stroke="black"
        stroke-width="1"
        d="M 2.00,19.00
               C 2.00,19.00 58.00,19.00 58.00,19.00
                 58.00,19.00 58.00,1.00 58.00,1.00
                 45.81,1.00 27.33,1.65 16.00,5.70
                 9.20,8.13 3.24,11.33 2.00,19.00 Z"
      />
      <text
        class="pointerCursor"
        x="40"
        y="12"
        fill="#FFFFFF"
        text-anchor="middle"
        alignment-baseline="middle"
      >
        Chat
      </text>
  </svg>
</div>

For the bottom sheet itself, it’s a simple mat list that contains our messages. In a real world build, we might put the list and list items in separate components to make them easier to maintain or swap out for other types of components.

<mat-list role="list">
  <mat-list-item *ngFor="let message of messages">
    <h3 matLine class="from"> {{message.from}} </h3>
    <p matLine>
      <span class="content"> {{message.content}} </span>
    </p>
    <button mat-icon-button class="delete-button"><mat-icon>delete</mat-icon></button>
  </mat-list-item>
</mat-list>

As we mention above, to make sure our cursor changes to a pointer, signifying that it is a link when a user hovers over it, we add a CSS class named pointerCursor to the path and text elements.

.right {
  float: right;
}

.pointerCursor {
  cursor: pointer;
}

We have two components in our component file. In the best scenario, we would create a module with separate components for the main view, the bottom sheet, the list and items in the bottom sheet. Here having both our main and bottom sheet components in the same file works just as well.

First, we set up some test data to display. This should come from a service in our real world case, but here we hard code it so we can focus on the bottom sheet functionality. We inject our bottom sheet variable into the constructor, and in ngOnInit() we make our call to the getMessages() method. Again, this would be a call to an api in the real world, here we just set our messages property to the contents of testMessages.

The real magic is our call to bottomsheet.open(), which takes in the custom bottom sheet component that it will instantiate. The call to open also can take in a config object. In ours we use the config object to send in some data from our main component. To handle that incoming data, our custom component uses the @inject syntax in our constructor, and makes a MAT_BOTTOM_SHEET_DATA injector token available.

import { Component, OnInit, Output, EventEmitter, Inject } from '@angular/core';
import {
  MatBottomSheet,
  MatBottomSheetRef,
  MAT_BOTTOM_SHEET_DATA
} from '@angular/material/bottom-sheet';

@Component({
  selector: 'app-bottomsheet-ut',
  templateUrl: './bottomsheet-ut.component.html',
  styleUrls: ['./bottomsheet-ut.component.css']
})
export class BottomsheetUtComponent implements OnInit {
  messages;
  testMessages = [
    { from: 'Fran', content: 'Is Jim around?' },
    { from: 'You', content: "Haven't seen him today" },
    { from: 'Fran', content: 'ugh, need him to look at some code' },
    { from: 'You', content: 'Anything I can do?' }
  ];

  constructor(private _bottomSheet: MatBottomSheet) {}

  ngOnInit() {
    this.getMessages();
  }

  getMessages() {
    this.messages = this.testMessages;
  }

  openBottomSheet(): void {
    this._bottomSheet.open(BottomSheetOverviewExampleSheetComponent, {
      data: this.messages
    });
  }
}

@Component({
  selector: 'app-bottom-sheet-overview-example-sheet',
  templateUrl: 'bottomsheet-example-sheet.html',
  styleUrls: ['./bottomsheet-ut.component.css']
})
export class BottomSheetOverviewExampleSheetComponent {
  messages;
  constructor(
    private _bottomSheetRef: MatBottomSheetRef<
      BottomSheetOverviewExampleSheetComponent
    >,
    @Inject(MAT_BOTTOM_SHEET_DATA) public data: any
  ) {
    this.getMessages();
  }

  getMessages() {
    this.messages = this.data;
  }
}

The Tests

We have three main tests in our spec, and go directly to work validating the firing of the code to get a bottom sheet, verifying that the bottom sheet shows. We then combine our smaller tests into a larger test that also verifies the data we show in the bottom sheet. To validate the data, we start by adding in some hand coded test data. As always, these would come from a service in the component, and we’d either grab from the api to build our test data, or hand build the data, which is the approach we’ve taken here.

Some code of note lives in our TestBed configuration. Notice at the end of the config, we added an overrideModule() call that contains an entryComponents property with our custom bottom sheet. Because we’re loading this component dynamically, and not at run time, we need to be sure that in both our module.ts, and in the TestBed config, we add that component as an entryComponent.

.overrideModule(BrowserDynamicTestingModule, {
      set: {
        entryComponents: [BottomSheetOverviewExampleSheetComponent]
      }
    });

Our first test ensures that when we press our button, the code to show the proper bottom sheet is actually called. We do this by grabbing our SVG button using the getElementById method. This is possible because we added an ID to that element, so we could easily find it for both styling and here for testing. Since this element does not contain a native click handler, we have to create a MouseEvent(), and then dispatch it from the svgButton reference. We expect our openBottomSheet() method will be called.

  it('Should call our bottom sheet display method on button press', () => {
    const theSpy = spyOn(component, 'openBottomSheet');

    const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();
    expect(theSpy).toHaveBeenCalled();
  });

The next test is our longest here. We ensure that what we tested above happens, that we open the bottom sheet when the button is clicked. We then go the step further and dive in to testing our data. In our example, as mentioned, we don’t call to an API to get our data. If we did, and would in real life, we’d use the HttpClientTestingModule and flush our expected results to be sure we are seeing the correct data.

As in the code above, we grab our svg button and create a click MouseEvent() to dispatch. The next portion will depend on how you have your bottom sheet component set up. If you’re not using a list, you would use whatever code you need to to find and grab the data from inside the bottom sheet. In our case we look for a mat-list nested in an app-bottom-sheet-overview-example-sheet. That long name is what we have as the selector of our custom bottom sheet. That custom bottom sheet is nested in several layers, a cdk-overlay-container gained from the CDK which is where many material components live, a CDK global overlay wrapper, a numbered CDK overlay, and then our mat-bottom-sheet-container. You could look at any piece of this hierarchy to get to where you need to be. The path I chose was the safest and quickest for me. We can be safe in doing this as we can only have one bottom sheet visible at a time.

const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    component.messages = testMessages;

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    let ourMessageListArray;

    const ourMessageList = document.querySelector(
      'app-bottom-sheet-overview-example-sheet mat-list'
    );

After we have our container, we get our mat-list-items and save them as an array using Array.from(). Having this data as an array instead of a collection of HTML Elements gives us more options to iterate through our data. Now that we have our data, we can iterate through our items, and build an object of each individual entry, to validate it exists in our test array. We do this by using the toContain() method on our test data array, passing in a jasmine object with the relevant data points from each mat-list-item.

ourMessageListArray = Array.from(
      ourMessageList.getElementsByClassName('mat-list-item')
    );

    ourMessageListArray.forEach(item => {
      const from = item.getElementsByClassName('from')[0].textContent.trim();
      const content = item
        .getElementsByClassName('content')[0]
        .textContent.trim();

      expect(testMessages).toContain(
        jasmine.objectContaining({
          from,
          content
        })
      );

When we look at this methodology of grabbing pieces of data to validate, we should recognize that we can use a similar method to test actions on the items, like deleting items from the list. Iterate through our list, grab the element with the action button, and invoke that action by dispatching a mouse event or calling a click handler.

Here is our full test.

  it('Should call our chat service and show the bottom sheet with appropriate messages', () => {
    const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    component.messages = testMessages;

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    let ourMessageListArray;

    const ourMessageList = document.querySelector(
      'app-bottom-sheet-overview-example-sheet mat-list'
    );

    ourMessageListArray = Array.from(
      ourMessageList.getElementsByClassName('mat-list-item')
    );

    ourMessageListArray.forEach(item => {
      const from = item.getElementsByClassName('from')[0].textContent.trim();
      const content = item
        .getElementsByClassName('content')[0]
        .textContent.trim();

      expect(testMessages).toContain(
        jasmine.objectContaining({
          from,
          content
        })
      );
    });
  });

Our last test goes a step further than our first, but not as far as our second. In this test, we again grab our SVG button and dispatch the click event. Then we ensure that our mat-bottom-sheet-container element exists in the DOM. We can, again, grab any number of elements to test this such as the app-bottom-sheet-overview-example-sheet. This would probably be the best bet, as it would ensure that the appropriate bottom sheet component opened on the appropriate button press.

  it('Should show our bottom sheet on button press', () => {
    const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    const bottomSheet = document.getElementsByTagName(
      'mat-bottom-sheet-container'
    );

    expect(bottomSheet).toBeTruthy();
  });
});

And here’s the full spec.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatBottomSheetModule } from '@angular/material';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import {
  BottomsheetUtComponent,
  BottomSheetOverviewExampleSheetComponent
} from './bottomsheet-ut.component';
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';

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

  const testMessages = [
    { from: 'Jim', content: 'Do you have the new file?' },
    { from: 'You', content: "I don't, can you resend?" },
    { from: 'Jim', content: 'Yep. Just sent. Let me know if you get it.' },
    { from: 'You', content: 'Got it, thanks!' }
  ];

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        BottomsheetUtComponent,
        BottomSheetOverviewExampleSheetComponent
      ],
      imports: [
        MatBottomSheetModule,
        MatIconModule,
        MatListModule,
        BrowserAnimationsModule
      ]
    }).overrideModule(BrowserDynamicTestingModule, {
      set: {
        entryComponents: [BottomSheetOverviewExampleSheetComponent]
      }
    });
  }));

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

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

  it('Should call our bottom sheet display method on button press', () => {
    const theSpy = spyOn(component, 'openBottomSheet');

    const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();
    expect(theSpy).toHaveBeenCalled();
  });

  it('Should call our chat service and show the bottom sheet with appropriate messages', () => {
    const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    component.messages = testMessages;

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    let ourMessageListArray;

    const ourMessageList = document.querySelector(
      'app-bottom-sheet-overview-example-sheet mat-list'
    );

    ourMessageListArray = Array.from(
      ourMessageList.getElementsByClassName('mat-list-item')
    );

    ourMessageListArray.forEach(item => {
      const from = item.getElementsByClassName('from')[0].textContent.trim();
      const content = item
        .getElementsByClassName('content')[0]
        .textContent.trim();

      expect(testMessages).toContain(
        jasmine.objectContaining({
          from,
          content
        })
      );
    });
  });

  it('Should show our bottom sheet on button press', () => {
    const svgButton = document.getElementById('oursvg');
    const mouseEvent = new MouseEvent('click');

    svgButton.dispatchEvent(mouseEvent);
    fixture.detectChanges();

    const bottomSheet = document.getElementsByTagName(
      'mat-bottom-sheet-container'
    );

    expect(bottomSheet).toBeTruthy();
  });
});

Wrapup

Material Bottom Sheets are great for actions that are needed, but not important enough to place right in front of the user. Like other Material components, they are powerful in that you can embed components that contain media and actions inside of them. Again, we didn’t dive into functionality that would come from outside of this component, like getting data from a service. We didn’t want to muddy the water with testing that is not bottom sheet specific. This is also a common enough pattern that it wouldn’t make sense to tackle in this post.

While we could test the bottom sheet functionality a bit deeper than we do here, and in a much more BDD way, we are only concerned with the basics of mat-bottom-sheet testing. Like other elements, adding in testing of more complex content is made easier once you know how to test the basic functionality. Overall, this is a good first pass at some functionality and tests. While we build this out and refactor, we would begin to see optimizations and update our code along the way. And since we have tests, we could do that nearly fearlessly!

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.