Testing Angular Material Basic Spreadsheet

Previously, you had an idea to move a spreadsheet from an email trail onto a website. To achieve that, you used an Angular Material Basic Spreadsheet. You showed it to your manager and he thought it had potential if you add a few more features. Before going ahead with that, you realise that, to achieve complexity scale, you will have to start writing some tests.

As a reminder, the key elements of the Angular Material Basic Spreadsheet are the component that displays individual orders. This component is used by an Angular Material table based table component that maps a number of orders onto the screen similar to a spreadsheet based on the date and product of the order, as shown below.

The component to be tested.

Testing Individual Order Component

The following is the code and template behind the individual order component.
order.component.ts
order.component.html

// order.component.ts
import { Component, OnInit, Input } from '@angular/core';

import { Order } from './order.model';

@Component({
  selector: 'app-order',
  templateUrl: './order.component.html',
  styleUrls: ['./order.component.css']
})
export class OrderComponent implements OnInit {
  @Input() order: Order;

  constructor() { }

  ngOnInit() {
  }

}
<!-- order.component.html -->
<ng-container *ngIf="order">{{ order.value | number }}</ng-container>

To figure out the tests that need to be written, let’s think about what could go wrong. Firstly, we could have forgotten to import something or setup something incorrectly. Next, we could have assumed that anyone that uses the component will always pass in an order. Thirdly, when an order is passed in, we could have forgotten to use the number pipe to display the value. The following specification tests for those three cases.
order.component.spec.ts

// order.component.spec.ts
import { TestBed, ComponentFixture } from '@angular/core/testing';

import { OrderComponent } from './order.component';
import { DebugElement } from '@angular/core';

describe('OrderComponent', () => {
  let fixture: ComponentFixture<OrderComponent>;
  let component: OrderComponent;
  let debugElement: DebugElement;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [OrderComponent]
    });

    fixture = TestBed.createComponent(OrderComponent);
    component = fixture.componentInstance;
    debugElement = fixture.debugElement;
  });

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

  describe('order null', () => {
    it('should display empty template.', () => {
      // Setting order to null
      component.order = null;

      // Triggering change detection
      fixture.detectChanges();

      // Checking template
      expect(debugElement.nativeElement.textContent).toEqual('');
    });
  });

  describe('order not null', () => {
    it('should display order value formatted as number.', () => {
      // Setting order to null
      component.order = {date: '2000-01-01', product: 'product 1', value: 1000};

      // Triggering change detection
      fixture.detectChanges();

      // Checking template
      expect(debugElement.nativeElement.textContent).toEqual('1,000');
    });
  });
});

On lines 8-20 the standard setup for component tests is defined. The test on lines 22-25 checks that the component is created. The test on lines 27-38 checks what happens if the input Order is null. Finally, the test on lines 40-51 checks that the Order value is displayed using the number pipe.

Here the DebugElement is used to inspect the template. Since the template is relatively simple, we only ever check the text content of the whole template under the different test cases.

Testing Table Component

The following is the code and template for the order table component.
order-table.component.ts
order-table.component.html

// order-table.component.ts
import { Component, OnInit } from '@angular/core';

import { OrderTable } from './order-table.model';
import { OrderService } from './order.service';

@Component({
  selector: 'app-order-table',
  templateUrl: './order-table.component.html',
  styleUrls: ['./order-table.component.css']
})
export class OrderTableComponent implements OnInit {
  orderTable: OrderTable;
  productHeader = 'Product';
  headers: string[];

  constructor(private orderService: OrderService) { }

  ngOnInit() {
    this.orderTable = this.orderService.getOrderTable();
    this.headers = [this.productHeader].concat(this.orderTable.headers);
  }

}
<!-- order-table.component.html -->
<ng-container *ngIf="orderTable.rowLabels.length">
  <table mat-table [dataSource]="orderTable.rowLabels" class="mat-elevation-z8">
    <ng-container [matColumnDef]="header" *ngFor="let header of headers">
      <th mat-header-cell *matHeaderCellDef>
        <ng-container *ngIf="header === productHeader">{{ header }}</ng-container>
        <ng-container *ngIf="header !== productHeader">{{ header | date }}</ng-container>
      </th>
      <td mat-cell *matCellDef="let element">
        <ng-container *ngIf="header === productHeader">{{ element }}</ng-container>
        <ng-container *ngIf="header !== productHeader"><app-order [order]="orderTable.orderMap[header][element]"></app-order></ng-container>
      </td>
    </ng-container>

    <tr mat-header-row *matHeaderRowDef="headers"></tr>
    <tr mat-row *matRowDef="let row; columns: headers;"></tr>
  </table>
</ng-container>

The table component is more complex and will require more tests. Broadly, there are tests for the component class and for the template. The main test for the component class is that the getOrderTable function is called on initialisation. Ideally, component code is tested without the use of the TestBed as it tends to slow down tests. However, since the component code to be tested is linked to an Angular event (OnInit), in this case it is better to use the TestBed and, therefore, the standard Angular lifecycle functions. The template tests are complex due to the number of ngIf statements and other logic in the template. Additionally, there are some complexities testing Angular Material tables. The following is the test code that checks common cases.
order-table.component.spec.ts

// order-table.component.spec.ts
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { Component, Input } from '@angular/core';
import { MatTableModule } from '@angular/material';

import { OrderTableComponent } from './order-table.component';
import { Order } from './order.model';
import { OrderService } from './order.service';

@Component({
  selector: 'app-order',
  template: `
    {{ order.date }}
    {{ order.product }}
    {{ order.value }}
  `
})
class TestOrderComponent {
  @Input() order: Order;
}

describe('OrderTableComponent', () => {
  let fixture: ComponentFixture<OrderTableComponent>;
  let component: OrderTableComponent;
  let nativeElement: HTMLElement;
  let orderServiceSpy: jasmine.SpyObj<OrderService>;

  beforeEach(() => {
    // Creating mock OrderService
    orderServiceSpy = jasmine.createSpyObj('OrderService', ['getOrderTable']);

    TestBed.configureTestingModule({
      declarations: [OrderTableComponent, TestOrderComponent],
      providers: [{provide: OrderService, useValue: orderServiceSpy}],
      imports: [MatTableModule]
    });

    fixture = TestBed.createComponent(OrderTableComponent);
    component = fixture.componentInstance;
    nativeElement = fixture.nativeElement;
  });

  describe('TestOrderComponent', () => {
    it('should create', () => {
      // Defining getOrderTable return value
      orderServiceSpy.getOrderTable.and.returnValue({
        headers: [],
        rowLabels: [],
        orders: [],
        orderMap: {}
      });

      // Triggering change detection
      fixture.detectChanges();

      // Checking component create
      expect(component).toBeTruthy();
    });

    describe('construction', () => {
      it('should call getOrderTable on OrderService', () => {
        // Defining getOrderTable return value
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: [],
          rowLabels: [],
          orders: [],
          orderMap: {}
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking getOrderTable call
        expect(orderServiceSpy.getOrderTable).toHaveBeenCalledWith();
      });
    });

    describe('empty table', () => {
      it('should display empty template.', () => {
        // Defining getOrderTable return value
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: [],
          rowLabels: [],
          orders: [],
          orderMap: {}
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking template
        expect(nativeElement.textContent).toEqual('');
      });
    });

    describe('single item table', () => {
      it('should display Product and date header', () => {
        // Defining getOrderTable return value
        const order = {
          date: '2000-01-01',
          product: 'product 1',
          value: 1
        };
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: ['2000-01-01'],
          rowLabels: ['product 1'],
          orders: [order],
          orderMap: {'2000-01-01': {'product 1': order}}
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking headers in template
        const trs = nativeElement.querySelectorAll('tr');
        expect(trs.length).toEqual(2);
        const ths = trs.item(0).querySelectorAll('th');
        expect(ths.length).toEqual(2);
        ['Product', 'Jan 1, 2000'].forEach((expectedHeader, idx) => expect(ths.item(idx).innerText).toEqual(expectedHeader));
      });

      it('should display single row with Order', () => {
        // Defining getOrderTable return value
        const order = {
          date: '2000-01-01',
          product: 'product 1',
          value: 1
        };
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: ['2000-01-01'],
          rowLabels: ['product 1'],
          orders: [order],
          orderMap: {'2000-01-01': {'product 1': order}}
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking headers in template
        const trs = nativeElement.querySelectorAll('tr');
        expect(trs.length).toEqual(2);
        const tds = trs.item(1).querySelectorAll('td');
        expect(tds.length).toEqual(2);
        ['product 1', '2000-01-01 product 1 1'].forEach((expectedItem, idx) => expect(tds.item(idx).innerText).toEqual(expectedItem));
      });
    });

    describe('multiple item table with single row', () => {
      it('should display Product and date headers', () => {
        // Defining getOrderTable return value
        const orders = [{
          date: '2000-01-01',
          product: 'product 1',
          value: 1
        }, {
          date: '2000-01-02',
          product: 'product 2',
          value: 2
        }];
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: ['2000-01-01', '2000-01-02'],
          rowLabels: ['product 1'],
          orders: orders,
          orderMap: {
            '2000-01-01': {'product 1': orders[0]},
            '2000-01-02': {'product 1': orders[1]}
          }
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking headers in template
        const trs = nativeElement.querySelectorAll('tr');
        expect(trs.length).toEqual(2);
        const ths = trs.item(0).querySelectorAll('th');
        expect(ths.length).toEqual(3);
        [
          'Product', 'Jan 1, 2000', 'Jan 2, 2000'
        ].forEach((expectedHeader, idx) => expect(ths.item(idx).innerText).toEqual(expectedHeader));
      });

      it('should display single row with Orders', () => {
        // Defining getOrderTable return value
        const orders = [{
          date: '2000-01-01',
          product: 'product 1',
          value: 1
        }, {
          date: '2000-01-02',
          product: 'product 1',
          value: 2
        }];
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: ['2000-01-01', '2000-01-02'],
          rowLabels: ['product 1'],
          orders: orders,
          orderMap: {
            '2000-01-01': {'product 1': orders[0]},
            '2000-01-02': {'product 1': orders[1]}
          }
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking headers in template
        const trs = nativeElement.querySelectorAll('tr');
        expect(trs.length).toEqual(2);
        const ths = trs.item(1).querySelectorAll('td');
        expect(ths.length).toEqual(3);
        [
          'product 1', '2000-01-01 product 1 1', '2000-01-02 product 1 2'
        ].forEach((expectedItem, idx) => expect(ths.item(idx).innerText).toEqual(expectedItem));
      });
    });

    describe('multiple item table with single column', () => {
      it('should display Product and date header', () => {
        // Defining getOrderTable return value
        const orders = [{
          date: '2000-01-01',
          product: 'product 1',
          value: 1
        }, {
          date: '2000-01-01',
          product: 'product 1',
          value: 2
        }];
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: ['2000-01-01'],
          rowLabels: ['product 1', 'product 2'],
          orders: orders,
          orderMap: {'2000-01-01': {
            'product 1': orders[0],
            'product 2': orders[1]
          }}
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking headers in template
        const trs = nativeElement.querySelectorAll('tr');
        expect(trs.length).toEqual(3);
        const ths = trs.item(0).querySelectorAll('th');
        expect(ths.length).toEqual(2);
        ['Product', 'Jan 1, 2000'].forEach((expectedHeader, idx) => expect(ths.item(idx).innerText).toEqual(expectedHeader));
      });

      it('should display multiple rows with Orders', () => {
        // Defining getOrderTable return value
        const orders = [{
          date: '2000-01-01',
          product: 'product 1',
          value: 1
        }, {
          date: '2000-01-01',
          product: 'product 2',
          value: 2
        }];
        orderServiceSpy.getOrderTable.and.returnValue({
          headers: ['2000-01-01'],
          rowLabels: ['product 1', 'product 2'],
          orders: orders,
          orderMap: {'2000-01-01': {
            'product 1': orders[0],
            'product 2': orders[1]
          }}
        });

        // Triggering change detection
        fixture.detectChanges();

        // Checking headers in template
        const trs = nativeElement.querySelectorAll('tr');
        expect(trs.length).toEqual(3);
        let tds = trs.item(1).querySelectorAll('td');
        expect(tds.length).toEqual(2);
        ['product 1', '2000-01-01 product 1 1'].forEach((expectedItem, idx) => expect(tds.item(idx).innerText).toEqual(expectedItem));
        tds = trs.item(2).querySelectorAll('td');
        expect(tds.length).toEqual(2);
        ['product 2', '2000-01-01 product 2 2'].forEach((expectedItem, idx) => expect(tds.item(idx).innerText).toEqual(expectedItem));
      });
    });
  });
});

The test setup is done on lines 23-41. The tests on lines 43-76 check the code of the component. The tests on lines 78-286 check the template under a range of scenarios.

Test Setup for Angular Material Table

There are a few interesting pieces to highlight for the tests. As the OrderTemplateComponent has a dependency on the OrderComponent, it needs to be given to the TestBed along with the OrderTableComponent. For testing, it is better to define a mock implementation of the component rather than use the real component, which is done on lines 10-20.

At the time of writing, the DebugElement does not work with the Angular Material table. This means that, to figure out exactly which OrderComponent is displayed in a cell, we need to make use of the template. Each OrderComponent displays an Order. Therefore, it should be enough to display all the member variables of the Order in the template of the mock OrderComponent to know that the correct Order was passed.

Since the template of the OrderTableComponent makes use of the Angular Material table, we need to pass in the MatTableModule to the TestBed.

Component Code Tests

The tests on lines 43-76 check that the component can be constructed and that the getOrderTable function is called on the OrderService during component initialisation. In this case, the component create test is a little more complicated because the OrderService needs to be called during initialisation which means that the getOrderTable function must at least exist. Also, during the initialisation, the headers member variable is used on the returned value of getOrderTable, so we may as well return an empty OrderTable.

Component Template Tests

The main reason for the length of the tests are the definition of the OrderTable to display in each test. There is a trade off between pre-defining or repeatedly defining them in each test. In production code brevity is preferred, so it is often better to define a range of OrderTables and importing them. However, for tests, it is better to have a single test be as much of a complete story as is needed to understand the test. As what is displayed in the template is tightly linked to the OrderTable, there are significant advantages to explicitly stating the OrderTable in each test.

The test scenarios are an empty table, a single item table and tests where either multiple columns or rows are displayed. In each case, the headers and rows are checked.

With these tests you have made an important step towards being able to scale up the complexity of the website. Now it is time to work on additional features! The most important feature request your manager has made is to make the spreadsheet editable so that people can revise the orders that were placed over time.

Angular Material Basic Spreadsheet

Spreadsheets are the lifeblood of many organisations. For example, a fruit company might be keeping track of the number of orders for Apples and Oranges over time using a spreadsheet. To scale, the organisation might have chosen to create 2 teams, one that looks after orders for Apples and the other for Oranges. Management wants a wholistic view of orders, so it instructs you to gather sales of Apples and Oranges from the two teams and aggregate the sales in a single spreadsheet. A trail of emails starts with many attached spreadsheets to keep track of the sales. Arguments ensue on whether the spreadsheet was updated correctly, who updated it and when.

What if there is a simpler solution? You decide to try building an internal website that keeps track of the sales. As a starting point, you only want the website to display orders broken down by product (Apples and Oranges) and date. You decide to try to use work that other people have already done for you. In particular, you decide to use @angular/material. To focus your mind on what you want to achieve, you draw up a draft of what you would like the website to look like:

Draft of what the website should look like.

Order Model

The first step is to define a model for an order. A simple model would keep track of the product, the date of the order and the amount that was ordered.
order.model.ts

// order.model.ts
export class Order {
    date: string;
    product: string;
    value: number;
}

Displaying the Order

To get a feeling for the model, you decide to write a component that can display a single Order. For now, it will be fairly basic and only display the value of the order, formatted as a number.
order.component.ts
order.component.html

// order.component.ts
import { Component, OnInit, Input } from '@angular/core';

import { Order } from './order.model';

@Component({
  selector: 'app-order',
  templateUrl: './order.component.html',
  styleUrls: ['./order.component.css']
})
export class OrderComponent implements OnInit {
  @Input() order: Order;

  constructor() { }

  ngOnInit() {
  }

}

The component code is simple, it just accepts an Order as input.

<!-- order.component.html -->
<ng-container *ngIf="order">
  {{ order.value | number }}
</ng-container>

In the template, you decide to check whether the order is truthy (ie. it has actually been passed in) using the *ngIf statement on line 2 and only display the value in that case.

Order Table Model

You anticipate the need to display multiple orders in a table. This table needs to be displayed by the Angular Material table component. To keep the template as simple as possible, you decide to use the following table model.
order-table.model.ts

// order-table.model.ts
import { Order } from './order.model';

export class OrderTable {
    headers: string[];
    rowLabels: string[];
    orders: Order[];
    orderMap: {[header: string]: {[rowLabel: string]: Order}};
}

It is designed to keep track of the orders in the table using the orders member variable. The ordering of the headers and rows is defined by headers and rowLabels member variables, respectively. To link a header and row label to and Order, you use the orderMap member variable.

Order Service

As you still need to convince your management and the teams you work with that the website is a good idea, for demonstration purposes, you decide to create a service that can easily be extended in the future, but that returns a hardcoded table for now.
order.service.ts

// order.service.ts
import { Injectable } from '@angular/core';

import { Order } from './order.model';
import { OrderTable } from './order-table.model';

@Injectable({
  providedIn: 'root'
})
export class OrderService {

  constructor() { }

  getOrderTable(): OrderTable {
    const orders: Order[] = [
      {date: '2000-01-01', product: 'Apples', value: 1000},
      {date: '2000-02-01', product: 'Apples', value: 2000},
      {date: '2000-01-01', product: 'Oranges', value: 10000}
    ];
    return {
      headers: ['2000-01-01', '2000-02-01'],
      rowLabels: ['Apples', 'Oranges'],
      orders: orders,
      orderMap: {
        '2000-01-01': {
          'Apples': orders[0],
          'Oranges': orders[2]
        },
        '2000-02-01': {
          'Apples': orders[1]
        }
      }
    };
  }
}

For the moment it returns 3 static orders, 2 for Apples and 1 for Oranges.

Displaying the Order Table

To be able to use @angular/material, you need to install some packages as shown in the getting started guide. To be able to use the Angular Material table, you need to import the MatTableModule and put it into the imports array of the module which will display the table.

The component code needs to make use of the OrderService to get the order table. It also needs to define the heading for the product name. The component template needs to dynamically generate headings and rows, depending on the number of Orders that need to be displayed.
table.component.ts
table.component.html

// table.component.ts
import { Component, OnInit } from '@angular/core';

import { OrderTable } from './order-table.model';
import { OrderService } from './order.service';

@Component({
  selector: 'app-order-table',
  templateUrl: './order-table.component.html',
  styleUrls: ['./order-table.component.css']
})
export class OrderTableComponent implements OnInit {
  orderTable: OrderTable;
  productHeader = 'Product';
  headers: string[];

  constructor(private orderService: OrderService) { }

  ngOnInit() {
    this.orderTable = this.orderService.getOrderTable();
    this.headers = [this.productHeader].concat(this.orderTable.headers);
  }

}

Line 14 defines the header under which the product will be displayed. In the component initialiser on line 20, the OrderTable is loaded. On line 21 the headers of the table are concentrated with the header for the product.

<!-- order-table.component.html -->
<table mat-table [dataSource]="orderTable.rowLabels" class="mat-elevation-z8">
  <ng-container [matColumnDef]="header" *ngFor="let header of headers">
    <th mat-header-cell *matHeaderCellDef>
      <ng-container *ngIf="header === productHeader">{{ header }}</ng-container>
      <ng-container *ngIf="header !== productHeader">{{ header | date }}</ng-container>
    </th>
    <td mat-cell *matCellDef="let element">
      <ng-container *ngIf="header === productHeader">{{ element }}</ng-container>
      <ng-container *ngIf="header !== productHeader"><app-order [order]="orderTable.orderMap[header][element]"></app-order></ng-container>
    </td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="headers"></tr>
  <tr mat-row *matRowDef="let row; columns: headers;"></tr>
</table>

The table template is the most complicated piece of the puzzle. Line 2 defines that the data source for the table are the row labels of the table. This allows you to dynamically generate a row for each row label in the table. Line 14 defines how the header columns are defined and line 15 defines how each row puts data into the columns. Line 3 is the generator expression for each column of the table.

Lines 4-7 define how the header row is generated. Line 5 checks whether the current header is the product header, and if so displays the product header. Line 6 checks the opposite and displays the header after passing it through the date pipe.

Lines 8-11 define how the data rows are generated. Similar to the header row, line 9 checks for the product header and then displays the product. Line 10 checks for the opposite, picks out the Order based on the header and row label using the orderMap and passes on the Order to be displayed by the OrderComponent.

Now you have a working website that you can take to your management and the teams you work with as a showcase. They will probably ask you for additional features, such as being able to edit orders and add new orders to the table. To be able to deliver those more complex features, you also start thinking about how you might write tests for what you have created so far.