In this post, we’re looking to dive into testing the Material Dialog. In our example below, we’re going to set up a basic UI with a list that contains a delete button on each list item, and write some high level tests.
What do we use dialogs for? On Google’s Material site it describes the dialog as
Dialogs inform users about a task and can contain critical information, require decisions, or involve multiple tasks.
https://material.io/components/dialogs/
And from a usage perspective, Google says
A dialog is a type of modal window that appears in front of app content to provide critical information or ask for a decision. Dialogs disable all app functionality when they appear, and remain on screen until confirmed, dismissed, or a required action has been taken.
Dialogs are purposefully interruptive, so they should be used sparingly.
https://material.io/components/dialogs/#usage
Dialogs give us an intrusive way to let a user know something is going to happen that is important. While we should use dialogs sparingly, look at studies done around alert fatigue, they’re powerful in that you can force a decision on an action. This gives you an option when a user story contains wording like “I need to be sure” or “I always need to know when”.
The Code
So that we don’t get mired in UI details, our interface is extremely light. It contains only a list with a material line that contains the name of the list item, and a material button that triggers our click handler, here named onDelete(). Our click handler receives the name of the list item as its parameter.
<mat-list id="testList"> <mat-list-item *ngFor="let item of ourItems"> <h4 mat-line>{{ item }}</h4> <button mat-button (click)="onDelete(item)">Delete</button> </mat-list-item> </mat-list>
Our component.ts file actually contains two components. The first is our material dialog component. To make things very compact, I’ve added it directly to our app dialog component, and have added the HTML template right in the code. You’ll more than likely want to make your dialogs more reusable, but if you know it will only be used in one place there’s no harm in wrapping everything together.
Our mat-dialog is very simple. It contains a static title, displays data it receives in a MAT_DIALOG_DATA parameter called data, and has two buttons. The No Thanks button triggers the dialog’s onNoClick method, which closes the dialog. The Ok button closes the dialog and sends data back to our calling method via the mat-dialog-close attribute and its bound value.
@Component({ selector: 'app-dialog-overview-example-dialog', template: '<h1 mat-dialog-title>Really Delete?<h1>\ <div mat-dialog-content id="dataMessage">{{data}} will be permanantly deleted.<div>\ <div mat-dialog-actions>\ <button mat-button id="noThanks" (click)="onNoClick()">No Thanks</button>\ <button mat-button [mat-dialog-close]="data" id="doIt" cdkFocusInitial>Ok</button>\ </div>' }) export class DialogOverviewExampleDialogComponent { constructor( public dialogRef: MatDialogRef<DialogOverviewExampleDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: string ) {} onNoClick(): void { this.dialogRef.close(); } }
The rest of the code in our component.ts file is also pretty straight forward. In this example we don’t use a service to get our data, we added it right into the code to get the example going. We then have our onDelete method that is triggered by the delete button. The onDelete method takes in the name of the item to delete, opens the dialog passing in our item name that will display in the dialog, and then waits for a response. If the user clicks on the Ok button, and we have data coming back from the dialog, we simply console.log a reply. In the real world this would trigger some other action. In our case that would be a call to a service to delete the item.
@Component({ selector: 'app-dialog-ut', templateUrl: './dialog-ut.component.html', styleUrls: ['./dialog-ut.component.css'] }) export class DialogUtComponent { ourItems = ['thing one', 'thing two', 'thing three']; constructor(public dialog: MatDialog) {} onDelete(item) { const dialogRef = this.dialog.open(DialogOverviewExampleDialogComponent, { width: '250px', data: item }); dialogRef.afterClosed().subscribe(result => { if ( result ) { console.log('removing ', result); } }); } }
Here is the entire component.ts code.
import { Component, Inject } from '@angular/core'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @Component({ selector: 'app-dialog-overview-example-dialog', template: '<h1 mat-dialog-title>Really Delete?<h1>\ <div mat-dialog-content id="dataMessage">{{data}} will be permanantly deleted.<div>\ <div mat-dialog-actions>\ <button mat-button id="noThanks" (click)="onNoClick()">No Thanks</button>\ <button mat-button [mat-dialog-close]="data" id="doIt" cdkFocusInitial>Ok</button>\ </div>' }) export class DialogOverviewExampleDialogComponent { constructor( public dialogRef: MatDialogRef<DialogOverviewExampleDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: string ) {} onNoClick(): void { this.dialogRef.close(); } } @Component({ selector: 'app-dialog-ut', templateUrl: './dialog-ut.component.html', styleUrls: ['./dialog-ut.component.css'] }) export class DialogUtComponent { ourItems = ['thing one', 'thing two', 'thing three']; constructor(public dialog: MatDialog) {} onDelete(item) { const dialogRef = this.dialog.open(DialogOverviewExampleDialogComponent, { width: '250px', data: item }); dialogRef.afterClosed().subscribe(result => { if ( result ) { console.log('removing ', result); } }); } }
The tests
The first piece that you might notice, is that I won’t mock the mat-dialog component. Why don’t I mock mat-dialog? First, I don’t want to have to mock someone else’s work if I don’t have to. I’m a bit lazy, and figuring out what I need for my test can take some time. I also know that, or hope that, believe that even, there was or is testing done to the mat-dialog code by the folks that maintain it so I don’t have to. When the dialog opens, I don’t care to test that the folks who wrote the code properly implemented the call to the close() method, I care that when the dialog disappears, the proper code on MY side is called. That code is all me to test.
Next, from my perspective, which has been known to be wrong, what I want to test is that the flow of work is functioning. So, I click the button and the dialog appears, I make a selection in the dialog and the proper thing happens. The proper thing happening isn’t because mat-dialog’s close method worked or was called, it’s because I pressed the button and the right thing happened. The magic is in my code that triggers after the mat dialog is closed, and before it is opened. That’s what I’m interested in testing.
For example, if my user stories are in the realm of “As a data entry clerk, I want to be able to delete an item directly from the list of items in an order, so that I can perform my work faster”, maybe in the current iteration, they have to go INTO the item to delete it. Or another user story could be “As a data entry clerk, I want to be able to confirm deleting an item from the list before it’s removed, to be sure I selected the correct item.” These are what I want to test. Display list with delete buttons for the data entry clerk -> clerk clicks delete button on an item -> dialog displays with delete and don’t delete buttons -> clerk clicks don’t delete, dialog is removed and nothing is done or clerk clicks delete, dialog is removed and list minus proper deleted item is returned. The functionality of the mat-dialog is mundane, repeatable, that’s why it’s library code, and that’s why I don’t want to test it.
Now on to explaining the code. First, you should notice that in my TestBed.configureTestingModule, I have an override of the BrowserDynamicTestingModule that sets an entryComponents attribute of our custom dialog, DialogOverviewExampleDialogComponent. The mat-dialog is created dynamically, imperatively. It is injected into our component, bootstrapped by NgModule and not included in the template. In order to receive that component to make it available, it needs to be set as an entryComponent in our testBed. If you receive a “No component factory found” error when your tests run, this might be the cause.
Another item to be aware of is that, in the real world we would have more tests here, focused on the data we get and display. In the tests below, we’re focused on the dialog only, so will not explore those other tests. As stated above, we have a list of something, with a delete button next to each item. We click delete, and get a dialog “Are you sure you want to delete {{ item.name }}?” If we click delete, the item is removed from the list. We need an array of data to present, and a “deleted item” as well as “Not deleted item” to test the scenario where we delete the data, and do not delete the data.
Most of our tests below start out similarly. Here’s our first big test, simply making sure our dialog fires. As we should, we’ve added an ID attribute named “testList” to our list so we can find it more easily. We use the Array.from method from javascript to get a shallow copied array from our iterable ourDomListUnderTest. We then filter the list down, using our itemToDelete variable to grab just the element we want to test. We find that element’s delete button and click it, which triggers the showing of our mat-dialog box. And since currently you can only have one material dialog open at a time, we simply look for our mat-dialog-container for confirmation.
it('should launch an alert dialog with a click of the delete button for a list item', () => { const ourDomListUnderTest = document.querySelector('mat-list#testList'); const listItemToDelete = Array.from( ourDomListUnderTest.getElementsByTagName('mat-list-item') ).filter( element => element.getElementsByTagName('h4')[0].innerText === itemToDelete ); const deleteButton = listItemToDelete[0].getElementsByTagName('button')[0]; deleteButton.click(); fixture.detectChanges(); fixture.whenStable().then(() => { const dialogDiv = document.querySelector('mat-dialog-container'); expect(dialogDiv).toBeTruthy(); }); });
For any other testing that needs to be done within the dialog itself, confirming it has the right buttons or text, we fall back to our simple dom testing. In the examples below we use this methodology. Once we capture the dialog container, we can grab buttons to click, or read text, through methods like querySelector, focusing on our ever-present ID attribute. One thing to note about the below example and the #doIt button, is that here we are getting back an HTMLElement, so we need to create our MouseEvent and call dispatchEvent, as the .click() method would not be available.
const dialogDiv = document.querySelector('mat-dialog-container'); const okButton = dialogDiv.querySelector('button#doIt'); const mouseEvent = new MouseEvent('click'); okButton.dispatchEvent(mouseEvent); fixture.detectChanges();
And here is the full test spec.
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MatListModule } from '@angular/material/list'; import { MatDialogModule } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DialogUtComponent, DialogOverviewExampleDialogComponent } from './dialog-ut.component'; import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; describe('ui-noninteractive - DialogUtComponent', () => { let component: DialogUtComponent; let fixture: ComponentFixture<DialogUtComponent>; const itemToDelete = 'thing two'; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [DialogUtComponent, DialogOverviewExampleDialogComponent], imports: [MatListModule, MatDialogModule, BrowserAnimationsModule] }).overrideModule(BrowserDynamicTestingModule, { set: { entryComponents: [DialogOverviewExampleDialogComponent] } }); })); beforeEach(() => { fixture = TestBed.createComponent(DialogUtComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should launch an alert dialog with a click of the delete button for a list item', () => { const ourDomListUnderTest = document.querySelector('mat-list#testList'); const listItemToDelete = Array.from( ourDomListUnderTest.getElementsByTagName('mat-list-item') ).filter( element => element.getElementsByTagName('h4')[0].innerText === itemToDelete ); const deleteButton = listItemToDelete[0].getElementsByTagName('button')[0]; deleteButton.click(); fixture.detectChanges(); fixture.whenStable().then(() => { const dialogDiv = document.querySelector('mat-dialog-container'); expect(dialogDiv).toBeTruthy(); }); }); it('should make call to delete a list item with the list item when the dialog is confirmed', () => { spyOn(component, 'onDelete'); const ourDomListUnderTest = document.querySelector('mat-list#testList'); const listItemToDelete = Array.from( ourDomListUnderTest.getElementsByTagName('mat-list-item') ).filter( element => element.getElementsByTagName('h4')[0].innerText === itemToDelete ); const deleteButton = listItemToDelete[0].getElementsByTagName('button')[0]; deleteButton.click(); fixture.detectChanges(); fixture.whenStable().then(() => { const dialogDiv = document.querySelector('mat-dialog-container'); const okButton = dialogDiv.querySelector('button#doIt'); const mouseEvent = new MouseEvent('click'); okButton.dispatchEvent(mouseEvent); fixture.detectChanges(); }); expect(component.onDelete).toHaveBeenCalledWith(itemToDelete); }); it('should have a dialog that contains the item name that will be deleted', () => { const ourDomListUnderTest = document.querySelector('mat-list#testList'); const listItemToDelete = Array.from( ourDomListUnderTest.getElementsByTagName('mat-list-item') ).filter( element => element.getElementsByTagName('h4')[0].innerText === itemToDelete ); const deleteButton = listItemToDelete[0].getElementsByTagName('button')[0]; deleteButton.click(); fixture.detectChanges(); fixture.whenStable().then(() => { const dialogDiv = document.querySelector('mat-dialog-container'); const dataMessageDiv = dialogDiv.querySelector('#dataMessage'); expect(dataMessageDiv.textContent).toContain(itemToDelete); }); }); });
Wrapup
Material Dialogs are powerful components for alerting and confirming actions. As we stated above, you should be cautious not to over use a an alert dialog, which could lead to alert fatigue. Often, users will blindly begin clicking away alerts if there are too many. From a testing perspective, while we would typically test the functionality a bit deeper than we do here, and in a much more BDD way, here we are only concerned with basic mat-dialog testing. Adding in testing of the content of a mat-dialog is made easier once you know how to trigger and grab the dialog. The rest is mundane. 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.