In this post, we put to work several components – Material Datepicker, Material Toolbar, and Material Sidenav. We don’t dive into Mat-Sidenav or Mat-Toolbar from a testing perspective, as it’s as easy as a div from a testing perspective. Instead we focus more on the datepicker, and what the effect of interactions with that element have on the Mat-Sidenav elements.
What do we use a Datepicker for? It should be pretty obvious, though we’ll take a look still. The Material.io site tells us that
Date pickers let users select a date or range of dates. They should be suitable for the context in which they appear.
Date pickers can be embedded into:
Dialogs on mobile, Text field dropdowns on desktop
https://material.io/components/pickers#usage
The Angular Material site says
The datepicker allows users to enter a date either through text input, or by choosing a date from the calendar. It is made up of several components and directives that work together.
https://material.angular.io/components/datepicker/overview
For the SideNav element, the Material site lists them as a drawer and tells us that
Navigation drawers provide access to destinations and app functionality, such as switching accounts. They can either be permanently on-screen or controlled by a navigation menu icon.
Navigation drawers are recommended for:
Apps with five or more top-level destinations, Apps with two or more levels of navigation hierarchy, Quick navigation between unrelated destinations
https://material.io/components/navigation-drawer#usage
And finally, looking at the Angular Material site on SideNav
Angular Material provides two sets of components designed to add collapsible side content (often navigation, though it can be any content) alongside some primary content. These are the sidenav and drawer components.
The sidenav components are designed to add side content to a fullscreen app.
The drawer component is designed to add side content to a small section of your app. Rather than adding side content to the app as a whole, these are designed to add side content to a small section of your app. They support almost all of the same features, but do not support fixed positioning.
https://material.angular.io/components/sidenav/overview
The Angular Material direction on Sidenav isn’t really helpful as to the why of using sidenav, but shows us that in our example below we probably should have used a mat-drawer. That’s OK, it works for us so let’s dig in to building our solution and testing it!
The Code
The code here hasn’t been optimized. What you are seeing is the solution after the tests have been written, and the first pass of the code. After the code is written, we would go back and refactor but we have not in this example. To shorten the example code here, we’ll pull out some of the elements of our arrays used in the examples.
We’ll start with the CSS and HTML. Our CSS is very simple. We ensure the height of the sidenav container stretches to the full height of the screen. We also put in some css to move our end elements all the way to the right, and to have the sidenav buttons appear in a vertical column.
.example-container { height: 100%; background: #eee; } span#spacefiller { flex: 1 1 auto; } mat-sidenav button { display: block; }
Our HTML is a bit more exciting. We use a mat-toobar with two rows to start off our page. The top row is our sitename as well as a fake username with verified icon. The second row includes a hamburger to show our mat-sidenav drawer, our datepicker, and the mat-input attached to the datepicker. We include the (dateChange) event on the input, so when we have selected our date we can change the text that appears on our page. We give our mat-datepicker the template reference variable #picker so we can reference that datepicker from our input as well as the datepicker toggle.
<input matInput [matDatepicker]="picker" (dateChange)="dateChangeHandler($event)" id="datepickerInput" /> <mat-datepicker-toggle matSuffix [for]="picker" id="mainpicker"> </mat-datepicker-toggle> <mat-datepicker #picker startView="multi-year" [startAt]="startDate" ></mat-datepicker>
In our HTML, one area we can refactor is our sidenav buttons. First, we use buttons as we are not navigating away from our page. The URL is not changing, so we are not going to use links. Second, this is a lot of redundant code, and we can do better to use an *ngfor to generate our buttons.
<mat-sidenav-container class="example-container"> <mat-sidenav mode="side" closed #snav> <button mat-button id='january'
The next piece is our use of ngTemplate to show the proper text in the main container. Here we use ng-templates, one for each month. Each ng-template contains the line of our “poem” that will show on the screen. In addition, the template contains a Template Reference Variable. What we do is, with an event – either a button press or date selection in the date picker – we set the ourMonth variable in our code file with a month value that matches the month we want to show. The ng-container then takes that value, and shows the ng-template that has the same month value using a @ViewChild decorated TemplateRef from our code file. We’ll see both of those items in the code file below.
<ng-template #january><span>January is a month of cold.</span></ng-template>
<mat-sidenav-content ><ng-container [ngTemplateOutlet]="this[ourMonth]"></ng-container ></mat-sidenav-content>
And here is our full HTML file.
<mat-toolbar color="primary"> <mat-toolbar-row> <span id="sitename">Easy Angular Testing</span> <span id="spacefiller"></span> <span >Jim Jenkins <mat-icon class="example-icon" aria-hidden="false" aria-label="Example user verified icon" >verified_user</mat-icon > </span> </mat-toolbar-row> <mat-toolbar-row> <button mat-icon-button (click)="snav.toggle()"> <mat-icon>menu</mat-icon> </button> <span id="spacefiller"></span> <input matInput [matDatepicker]="picker" (dateChange)="dateChangeHandler($event)" id="datepickerInput" /> <mat-datepicker-toggle matSuffix [for]="picker" id="mainpicker"></mat-datepicker-toggle> <mat-datepicker #picker startView="multi-year" [startAt]="startDate" ></mat-datepicker> </mat-toolbar-row> </mat-toolbar> <mat-sidenav-container class="example-container"> <mat-sidenav mode="side" closed #snav> <button mat-button id='january' (click)="ourMonth='january'">January</button> <button mat-button id='february' (click)="ourMonth='february'">February</button> <!-- abbreviated --> </mat-sidenav> <mat-sidenav-content ><ng-container [ngTemplateOutlet]="this[ourMonth]"></ng-container ></mat-sidenav-content> </mat-sidenav-container> <ng-template #january><span>January is a month of cold.</span></ng-template> <ng-template #february><span>february is big and bold.</span></ng-template> <!-- abbreviated -->
On to our ts code file. We set up a startDate attribute to ensure that our datepicker starts well before the current year. We also have the attribute ourMonth that is used to set the template that’s showing in our main HTML container element.
startDate = new Date(1950, 0, 1); ourMonth = 'january';
After that we use @ViewChild to render our ng-template on the screen.
@ViewChild('january') january: TemplateRef<any>;
We could also probably optimize this piece of code, but instead we list all of the TemplateRefs we want to use, one for each month. After the tests pass, this would be on our list for a refactor. These @ViewChild decorated attriutes are what performs some of the magic with our [ngTemplateOutlet] in the HTML file.
The last piece, our dateChangeHandler method, sets the ourMonth attribute from the date contained in the event triggered in the MatDatepicker. Remember, in our HTML file we change the ourMonth attribute in two ways, using the code below from our mat-datepicker, and in our buttons we set ourMonth directly.
dateChangeHandler(event: MatDatepickerInputEvent<Date>) { this.ourMonth = event.value.toLocaleString('default', { month: 'long' }).toLowerCase(); }
And here is the full TS code file.
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { MatDatepickerInputEvent } from '@angular/material/datepicker'; @Component({ selector: 'app-datepicker-navcontainers-ut', templateUrl: './datepicker-navcontainers-ut.component.html', styleUrls: ['./datepicker-navcontainers-ut.component.css'], }) export class DatepickerNavcontainersUtComponent implements OnInit { startDate = new Date(1950, 0, 1); ourMonth = 'january'; @ViewChild('january') january: TemplateRef<any>; @ViewChild('february') february: TemplateRef<any>; // abbreviated constructor() {} ngOnInit() {} dateChangeHandler(event: MatDatepickerInputEvent<Date>) { this.ourMonth = event.value.toLocaleString('default', { month: 'long' }).toLowerCase(); } }
The Tests
Now let’s dive into our tests. Our first order of business is to ensure that all of the modules we need are accounted for. We start our by importing the following modules –
MatSidenavModule, MatToolbarModule, MatIconModule, MatDatepickerModule, MatNativeDateModule,
We also import the BrowserAnimationModule to support animations in our tests.
We then set up our expected results in an array. The array elements include the lowercase name of the result, the uppercased “display” version of that name, and the line of our poem attached to the month, here in an attribute named “cheese”. Something of note, while we want to optimize our code above, we would want to spell out all of our expected results in our spec.ts file, and not use code to generate the expected results. In our tests, we want to use as little code as necessary, we want no complexity here, as we could introduce bugs in our tests.
{ name: 'january', text: 'January', cheese: 'January is a month of cold.' },
Our first test checks if the datepicker event changes the text in the main page. We grab our mat-datepicker-toggle button, as this is going to begin triggering the actions to select a date. We then click the button.
const mainDatePicker = fixture.debugElement.query( By.css('mat-datepicker-toggle#mainpicker button') ).nativeElement; mainDatePicker.dispatchEvent(new MouseEvent('click')); fixture.detectChanges();
Our year, month and day actions are all the same so we’ll only show the year here. The triggering of the mat-datepicker activates our cdk element, which contains what ends up being a dialog that has a table of data representing the month, day and year values on the calendar view. We look for the .mat-calendar-body-cell class, which contains the actual values being displayed in every case, whether that’s the year, month or day values. Once we have those elements, we could look for a specific value to click on. For us we choose a specific element of the array of elements to click on. Once we click on the year or month value, the next set of values appear in our table. Once we get to the day, selecting and clicking on that element triggers our selected event and any events attached to it.
const yearCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); yearCells[0].nativeElement.click(); fixture.detectChanges();
Looking at the Angular Material code on github, they set the value in a much more efficient way. You can use this if you don’t want to set up the test the long way as we have here. This would look like the following.
datepicker.selected = new Date(2017, JAN, 1); fixture.detectChanges();
While this is much more efficient, the code we wrote helps us understand how the element works.
Lastly, we get our main mat-sidenav container, grab the text from that element, and check it against the expected month poem line.
const mainContentText = fixture.debugElement.query( By.css('mat-sidenav-content span') ).nativeElement; expect(mainContentText.innerText).toEqual(monthValues[1].cheese);
And here is the full test.
it('should change the text when a date is chosen from the datepicker', () => { const mainDatePicker = fixture.debugElement.query( By.css('mat-datepicker-toggle#mainpicker button') ).nativeElement; mainDatePicker.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); const yearCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); yearCells[0].nativeElement.click(); fixture.detectChanges(); const monthCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); monthCells[1].nativeElement.click(); fixture.detectChanges(); const dayCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); dayCells[0].click(); fixture.detectChanges(); const mainContentText = fixture.debugElement.query( By.css('mat-sidenav-content span') ).nativeElement; expect(mainContentText.innerText).toEqual(monthValues[1].cheese); });
Our next test ensures that selecting a button in our mat-sidenav container changes our main text. We start by grabbing the sidenav container that contains our elements. It lives at mat-sidenav -> div.mat-drawer-inner-container. We then run through each element in a for loop, clicking on each element. We grab the mat-sidenav-content span that contains the updated text, and check it against our expected values for that button click.
it('should change the text when a month is chosen from the sidenav', () => { const sidenavContainer = fixture.debugElement.query( By.css('mat-sidenav div.mat-drawer-inner-container') ).nativeElement; for (const element of sidenavContainer.children) { element.click(); fixture.detectChanges(); const mainContentText = fixture.debugElement.query( By.css('mat-sidenav-content span') ).nativeElement; const expectedCheese = monthValues.filter( (value) => value.name === element.getAttribute('id') ); expect(mainContentText.innerText).toEqual(expectedCheese[0].cheese); } });
In the last test, we check that all of the buttons we expect in our mat-sidenav are there. We really have to check this in two ways. The first is to ensure that each button in the sidenav is in our expected elements array. This ensures that there are no elements in the sidenav, that are not also in the expected elements array. The second test is to ensure that all expected elements from the array, made it into the sidenav. I would typically put these in two separate tests, but we do this in one test for the example.
We start by grabbing the buttons and looping through them. We populate the expectedButtons variable with the month value that matches the id of the button. We then also set ourselves up for our next test, by getting the id of the current button and pushing that into the arrayOfButtons. We then check that our button id matches the expected button’s name attribute. In this way, we’re supporting our first need, to ensure that each button shown is expected and we have no extra or incorrectly named buttons.
for (const element of sidenavContainer.children) { const expectedButtons = monthValues.filter( (value) => value.name === element.getAttribute('id') ); arrayOfButtons.push(element.getAttribute('id')); expect(element.getAttribute('id')).toEqual(expectedButtons[0].name); }
We then make a quick test of the length of our buttons array and our expected monthValues array.
expect(arrayOfButtons.length).toEqual(monthValues.length);
The last piece of this test is to ensure that each of our expected months, actually has a button in the sidenav. This supports the second test we want to do, as outlined above.
for (const expectedMonth of monthValues) { expect(arrayOfButtons).toContain(expectedMonth.name); }
Here is the full test.
it('should add the buttons we expect to the mat-sidenav container', () => { const sidenavContainer = fixture.debugElement.query( By.css('mat-sidenav div.mat-drawer-inner-container') ).nativeElement; const arrayOfButtons = []; for (const element of sidenavContainer.children) { const expectedButtons = monthValues.filter( (value) => value.name === element.getAttribute('id') ); arrayOfButtons.push(element.getAttribute('id')); expect(element.getAttribute('id')).toEqual(expectedButtons[0].name); } expect(arrayOfButtons.length).toEqual(monthValues.length); for (const expectedMonth of monthValues) { expect(arrayOfButtons).toContain(expectedMonth.name); } });
And here is our full spec.ts content.
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { DatepickerNavcontainersUtComponent } from './datepicker-navcontainers-ut.component'; import { MatSidenavModule, MatToolbarModule, MatIconModule, MatDatepickerModule, MatNativeDateModule, } from '@angular/material'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; describe('DatepickerNavcontainersUtComponent', () => { let component: DatepickerNavcontainersUtComponent; let fixture: ComponentFixture<DatepickerNavcontainersUtComponent>; const monthValues = [ { name: 'january', text: 'January', cheese: 'January is a month of cold.' }, { name: 'february', text: 'February', cheese: 'february is big and bold.' }, // abbreviated ]; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ MatSidenavModule, MatToolbarModule, MatIconModule, MatDatepickerModule, MatNativeDateModule, BrowserAnimationsModule, ], declarations: [DatepickerNavcontainersUtComponent], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(DatepickerNavcontainersUtComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should change the text when a date is chosen from the datepicker', () => { const mainDatePicker = fixture.debugElement.query( By.css('mat-datepicker-toggle#mainpicker button') ).nativeElement; mainDatePicker.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); const yearCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); yearCells[0].nativeElement.click(); fixture.detectChanges(); const monthCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); monthCells[1].nativeElement.click(); fixture.detectChanges(); const dayCells = fixture.debugElement.queryAll( By.css('.mat-calendar-body-cell') ); dayCells[0].nativeElement.click(); fixture.detectChanges(); const mainContentText = fixture.debugElement.query( By.css('mat-sidenav-content span') ).nativeElement; expect(mainContentText.innerText).toEqual(monthValues[1].cheese); }); it('should change the text when a month is chosen from the sidenav', () => { const sidenavContainer = fixture.debugElement.query( By.css('mat-sidenav div.mat-drawer-inner-container') ).nativeElement; for (const element of sidenavContainer.children) { element.click(); fixture.detectChanges(); const mainContentText = fixture.debugElement.query( By.css('mat-sidenav-content span') ).nativeElement; const expectedCheese = monthValues.filter( (value) => value.name === element.getAttribute('id') ); expect(mainContentText.innerText).toEqual(expectedCheese[0].cheese); } }); it('should add the buttons we expect to the mat-sidenav container', () => { const sidenavContainer = fixture.debugElement.query( By.css('mat-sidenav div.mat-drawer-inner-container') ).nativeElement; const arrayOfButtons = []; for (const element of sidenavContainer.children) { const expectedButtons = monthValues.filter( (value) => value.name === element.getAttribute('id') ); arrayOfButtons.push(element.getAttribute('id')); expect(element.getAttribute('id')).toEqual(expectedButtons[0].name); } expect(arrayOfButtons.length).toEqual(monthValues.length); for (const expectedMonth of monthValues) { expect(arrayOfButtons).toContain(expectedMonth.name); } }); });
Wrap Up
In this app example, we really focused on the mat-datepicker and testing the triggering of one action. For the mat-sidenav and mat-toolbar, there’s not much to test as these are easily identified and retrieved from code. Even so, you should have enough information to start building out these simple tests, and add whatever is specific to your case.
We did fairly well meeting our general goal of testing, which is to look for outcomes of a business workflow. Testing a user expectation. Now we would be ready to begin looking at some of the refactoring opportunities we recognized above. This is another great part of going back through your tests after your code is written and the tests pass, is that you have a new perspective on both and discover potential areas for improvement.
All of the code shown here, and more Angular tests, can be found in this GitHub repo.