Easy Angular Testing – UI Elements – Card

In this post, we’ll look into Material Cards. In our example below, we set up a three card example that would mimic a social site. There are two different types of cards, user and business, and we test two statuses, online and offline.

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

Cards contain content and actions about a single subject.

https://material.io/components/cards/

And from a usage perspective, Google says

Cards are surfaces that display content and actions on a single topic.

They should be easy to scan for relevant and actionable information. Elements, like text and images, should be placed on them in a way that clearly indicates hierarchy.

https://material.io/components/cards/#usage

Cards give us a way to show topical information in a consolidated way to help make scanning that data more efficient. Examples of uses of cards would be posts on a social site, or a menu for a restaurant, entries on a product rating site, or entries on a site that shows virtual business cards. The idea is to group together information on one topic, and show it in a single view along with other cards that group information on other topics.

Enough explanation on what a material card is or could be. Let’s get into testing!

The Code

We’ll start with the html in our example. We are not generating our cards dynamically, which we would normally do in this situation. Instead we’ve hand coded three cards, each with slightly different features and data. In our first card, we use the most basic card without headers. We do include a Reservation button which triggers our makeReservation() method. We’ll test the firing of that button.

Our second card includes a header and avatar, as well as an action to message a user. While we would want to verify that the avatar is the appropriate one for the rest of the data, we don’t test whether the avatar is showing in the correct location. This is because the Material Card takes care of this for us by using a mat-card-header. No matter where we put the img tag with the mat-card-avatar directive, it will always show up in the same place, before the rest of the header data.

Our last card is like the second card, a user card. This user is Offline, and their status disables the message button. We’ll test that. As in the second card, we use a header like feature, the mat-card-title-group. Like the second card this will push our avatar to the same place regardless of where it is declared, but in this case the avatar will be after the rest of the content.

And here is the content of the file.

<mat-card class='business'>
  <img mat-card-image src="{{ cardOne.img }}" />
  <mat-card-title>{{ cardOne.title }}</mat-card-title>
  <mat-card-subtitle>{{ cardOne.subtitle }}</mat-card-subtitle>
  <mat-card-content>{{ cardOne.content }}</mat-card-content>
  <mat-card-actions>
    <button mat-button (click)="makeReservation()">Reservation</button>
  </mat-card-actions>
</mat-card>

<mat-card class='user online'>
  <mat-card-header>
    <mat-card-title>{{ cardTwo.title }}</mat-card-title>
    <mat-card-subtitle>{{ cardTwo.subtitle }}</mat-card-subtitle>
    <img mat-card-avatar src="{{ cardTwo.avatar }}" />
  </mat-card-header>
  <img mat-card-image src="{{ cardTwo.img }}" />
  <mat-card-content>{{ cardTwo.content }}</mat-card-content>
  <mat-card-actions>
        <button mat-button (click)="messageUser()">Message</button>
  </mat-card-actions>
</mat-card>

<mat-card class='user offline'>
  <mat-card-title-group>
    <img mat-card-avatar src="{{ cardThree.avatar }}" />
    <mat-card-title>{{ cardThree.title }}</mat-card-title>
    <mat-card-subtitle>{{ cardThree.subtitle }}</mat-card-subtitle>
  </mat-card-title-group>

  <img mat-card-image src="{{ cardThree.img }}" />
  <mat-card-content>{{ cardThree.content }}</mat-card-content>
  <mat-card-actions>
        <button mat-button (click)="messageUser()" [disabled]="cardThree.subtitle === 'Offline'">Message</button>
  </mat-card-actions>  <mat-card-footer></mat-card-footer>
</mat-card>

Be sure to add some CSS to break the cards apart. If you don’t, you might not see the bounding box’s lines. We’ve also added some not so eye pleasing, colors based on the class of the card.

mat-card {
  margin: 1rem;
  width: 25rem;
}

.business {
  background-color: aliceblue;
}

.online {
  background-color: lightgreen;
}

.offline {
  background-color: lightgrey;
}

Our component is straight forward. In this case, it contains some static test data which would typically come from a service. This also contains our messageUser() and makeReservation() methods, which in our case only post a log to console.

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

@Component({
  selector: 'app-card-ut',
  templateUrl: './card-ut.component.html',
  styleUrls: ['./card-ut.component.css']
})
export class CardUtComponent implements OnInit {
  cardOne = {
    img: '../../../assets/images/gray-foods-on-wicker-baskets.png',
    title: 'Little Boulangerie',
    subtitle: 'Bread and Baked Goods',
    content: 'Little home bakery specializing in breads and sweet treats.'
  };

  cardTwo = {
    img: '../../../assets/images/gray-foods-on-wicker-baskets.png',
    avatar: '../../../assets/images/face.jpg',
    title: 'Stucky Jim',
    subtitle: 'Available',
    content:
      'Sitting at Little Boulangerie having an espresso and some pain au chocolate!'
  };

  cardThree = {
    img: '../../../assets/images/gray-foods-on-wicker-baskets.png',
    avatar: '../../../assets/images/face.jpg',
    title: 'Stucky Jim',
    subtitle: 'Offline',
    content: 'What an experience!'
  };

  constructor() {}

  ngOnInit() {}

  makeReservation() {
    console.log('Reserve');
  }

  messageUser() {
    console.log('Message user');
  }
}

The Tests

To test this code, we take a standard approach to data setup. We have our static, hand built data array that we’ll use to inject into our tests. While this can be difficult to maintain in the long term as we would break these tests in the case of data model changes, it also makes us very aware of data model changes. In addition, this method of building our test data statically, and the knowledge that these tests could break with model changes, forces us to think about how we can change-proof our code against model changes. That is, how do we ensure that our code will continue to run, faithfully, when a data model changes.

A note on the tests below. We’re not testing instantiating a card. That would typically be an *ngFor in our html. We also don’t need to do the work of building dom elements in code and injecting them to show how to test cards. So we won’t. Also, overall the tests and functionality are not optimal, from data structure to code. Something I would do differently is have an attribute on the data that denoted whether the user was available or unavailable, and set the user’s status based on that. We could even have two attributes, an availability attribute that is something like “online|offline” and a status attribute, that the user could set themselves. If online, set status, if offline, set as offline. Despite these failings, and others, these tests are a good base to start with, and can be used with the above enhancements.

Our first test verifies that the content we receive from our service is displayed appropriately. That is, the title, subtitle, and content in the card all belongs together. This tests that we’re not munging our data before it’s displayed, or that our logic to create our cards isn’t mixing data up, like what would happen if we have a variable that is declared in a scope where it holds its value inappropriately. In the test, we grab our individual mat-card elements and push them into an Array. We do this to make the rest of the testing easier. We then dive into each array item, grab each element from that item, and create a Jasmine object with that data. This allows us to test back to the original array, but only using the data we need to and not every property on the object. We use only enough data to verify that our code is doing the right thing, though we could grab all of the displayed elements.

  it('should create cards with proper title, subtitle, and content', () => {
    const ourDomCardsUnderTest = Array.from(
      document.getElementsByTagName('mat-card')
    );

    ourDomCardsUnderTest.forEach(card => {
      const cardTitle = card.getElementsByTagName('mat-card-title')[0]
        .textContent;
      const cardSubtitle = card.getElementsByTagName('mat-card-subtitle')[0]
        .textContent;
      const cardContent = card.getElementsByTagName('mat-card-content')[0]
        .textContent;

      expect(testData).toContain(
        jasmine.objectContaining({
          title: cardTitle,
          subtitle: cardSubtitle,
          content: cardContent
        })
      );
    });
  });

While I was writing these tests, this first test kept failing, but I couldn’t find any issue with the data. While debugging I found there was a space being injected somewhere in one of the content elements. Turns out, in our HTML, I had an errant space. This is exactly why we test!

Our second and third tests are very similar. They are identical other than the expect line. In these tests, we find a card or cards that have the class we want, the second test grabs an online user and the third an offline user. Then we grab the action button we want to test, to verify sending a message to the user. We would probably put a class on the button to make it easier to find, particularly if we have multiple action buttons in our card.

We set up a spy to spy on the method we will call with the button press. In the second test we verify that the spied on method was called, since the user was online. In the third test we want to verify that it did NOT happen.

  it('should message the user when the send message button is pressed', () => {
    const theSpy = spyOn(component, 'messageUser');

    const ourDomCardUnderTest = document.querySelector('mat-card.user.online');
    const buttonUnderTest = ourDomCardUnderTest.getElementsByTagName(
      'button'
    )[0];

    buttonUnderTest.click();
    fixture.detectChanges();

    expect(theSpy).toHaveBeenCalled();
  });

And here is our full test spec.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatCardModule } from '@angular/material/card';

import { CardUtComponent } from './card-ut.component';
import { when } from 'q';

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

  const testData = [
    {
      img: '../../../assets/images/gray-foods-on-wicker-baskets.png',
      title: 'Little Boulangerie',
      subtitle: 'Bread and Baked Goods',
      content: 'Little home bakery specializing in breads and sweet treats.'
    },
    {
      img: '../../../assets/images/gray-foods-on-wicker-baskets.png',
      avatar: '../../../assets/images/face.jpg',
      title: 'Stucky Jim',
      subtitle: 'Available',
      content:
        'Sitting at Little Boulangerie having an espresso and some pain au chocolate!'
    },
    {
      img: '../../../assets/images/gray-foods-on-wicker-baskets.png',
      avatar: '../../../assets/images/face.jpg',
      title: 'Stucky Jim',
      subtitle: 'Offline',
      content: 'What an experience!'
    }
  ];

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [CardUtComponent],
      imports: [MatCardModule]
    }).compileComponents();
  }));

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

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

  it('should create cards with proper title, subtitle, and content', () => {
    const ourDomCardsUnderTest = Array.from(
      document.getElementsByTagName('mat-card')
    );

    ourDomCardsUnderTest.forEach(card => {
      const cardTitle = card.getElementsByTagName('mat-card-title')[0]
        .textContent;
      const cardSubtitle = card.getElementsByTagName('mat-card-subtitle')[0]
        .textContent;
      const cardContent = card.getElementsByTagName('mat-card-content')[0]
        .textContent;

      expect(testData).toContain(
        jasmine.objectContaining({
          title: cardTitle,
          subtitle: cardSubtitle,
          content: cardContent
        })
      );
    });
  });

  it('should make a reservation when the make reservation button is pressed', () => {
    const theSpy = spyOn(component, 'makeReservation');

    const ourDomCardUnderTest = document.querySelector('mat-card.business');
    const buttonUnderTest = ourDomCardUnderTest.getElementsByTagName(
      'button'
    )[0];

    buttonUnderTest.click();
    fixture.detectChanges();

    expect(theSpy).toHaveBeenCalled();
  });

  it('should message the user when the send message button is pressed', () => {
    const theSpy = spyOn(component, 'messageUser');

    const ourDomCardUnderTest = document.querySelector('mat-card.user.online');

    const buttonUnderTest = ourDomCardUnderTest.getElementsByTagName(
      'button'
    )[0];

    buttonUnderTest.click();
    fixture.detectChanges();

    expect(theSpy).toHaveBeenCalled();
  });

  it('should disable the message button when status moves to offline', () => {
    const theSpy = spyOn(component, 'messageUser');

    const ourDomCardUnderTest = document.querySelector('mat-card.user.offline');

    const buttonUnderTest = ourDomCardUnderTest.getElementsByTagName(
      'button'
    )[0];

    buttonUnderTest.click();
    fixture.detectChanges();

    expect(theSpy).not.toHaveBeenCalled();
  });
});

Wrapup

Material Cards are perfect for showing groups of data, while focusing the user on a single topic at a time. A nice feature of the mat-card is the ability to embed media along with actions, making the card a powerful way to show data while making it interactive. We didn’t dive into generating the cards dynamically, or grouping cards and sorting them here. Those pieces of functionality might not come from this component, but from a service, and some of that is done with very standard Angular functionality, such as *ngFor.

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, we are only concerned with the basics of mat-card testing. Like many other elements, adding in testing of more complex content of a mat-card is made easier once you know how to test the basic functionality. The rest is mundane. 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.