Tidy up your tests using component test harnesses (1/3)

One of the things I love most about Angular is that testing is a first-class citizen of the framework. But, interacting with UI components in automating testing can still be tedious. You may spend more time worrying about HOW to write a test instead of focusing on testing the interaction. Your tests may still be difficult to read and understand at a glance, and your tests may depend on the UI component libraries' internal selectors, which may change. 😬

✨ You can tidy up your tests and focus on writing meaningful tests using component test harnesses. ✨

Test harnesses

Test harnesses are part of the testing APIs in @angular/cdk/testing library, in the Angular Component Development Kit(CDK). The CDK testing library supports testing interactions with components. The idea for test harnesses comes from the PageObject pattern, used for integration style testing.

A page object wraps an HTML page, or fragment, with an application-specific API, allowing you to manipulate page elements without digging around in the HTML.
– Martin Fowler, PageObject

Component test harnesses

UI components then implement the CDK's test harness APIs to create a component test harness. When there is a component test harness, it allows a test to interact with the component in a supported way.

Component test harnesses can

  1. Make your tests easier to read and understand
  2. Make your tests easier to write by using the APIs to interact with UI components
  3. Make your tests more resilient because you don't depend on the internals of a UI component

You'll have tidy tests that are less brittle. 😍

Testing with component test harnesses

The CDK test harness loader supports two environments — unit and e2e. Out of the box, you have support for loading test harnesses in unit tests using Karma and e2e tests using Protractor. If your favorite testing library is something different, the API allows creating test harness environments.

Angular Material is a UI component library maintained by the Angular team. All Angular Material components provide test harnesses in Angular Material components version 12. However, the effort started in version 9, so if you aren't on the latest version of Angular, you might have access to some component test harnesses.

A side by side comparison of tests

Let's look at an example unit test and compare a test with and without test harnesses. We'll look at a sample To-do app written using Angular Material UI components.

We'll focus on testing the behavior of applying a CSS class that draws a strikethrough on the checkbox text of completed tasks.

This post assumes knowledge of building a site using Angular and writing unit tests using Karma. The examples shown are a simplified version from the project GitHub repo.

GitHub logo alisaduncan / component-harness-code

Sample app with unit tests with and without test harnesses, and a custom component test harness for the component test harness presentation

The code we'll test

We're focusing on the checkbox element and adding a ngClass attribute to conditionally add the CSS class .task-completed when the task is complete. The .task-completed CSS class adds a strikethrough on the text.

If you haven't used Angular Material before, all components have a mat prefix, so a checkbox becomes mat-checkbox. A snippet of code to display a to-do task and handle the strikethrough behavior for a MatCheckbox component looks something like this.

<mat-checkbox
  #task
  [ngClass]="task.checked ? 'task-completed' : ''">
      {{todo.description}}
</mat-checkbox>

What we'll test

We'll do the following operations in the test:

  1. Access the checkbox element
  2. Assert the checkbox starts unchecked
  3. Assert the checkbox doesn't contain the CSS class task-completed
  4. Toggle the checkbox to mark as checked
  5. Assert the checkbox is now checked
  6. Assert the checkbox now contains the CSS class task-completed

A test without harnesses

Let's start with what an example test for this logic might look like without test harnesses. We'll skip the TestBed setup and dive right into the test.

it('should apply completed class to match task completion', () => {

   // 1. Access mat-checkbox and the checkbox element within
   const matCb = fixture.debugElement.query(By.css('mat-checkbox'));
   expect(matCb).toBeTruthy();

   const cbEl = matCb.query(By.css('input'));
   expect(cbEl).toBeTruthy();

   // 2. Assert the checkbox element is not checked  
   expect(cbEl.nativeElement.checked).toBe(false);

   // 3. Assert the mat-checkox doesn't contain the CSS class
   expect(matCb.nativeElement.classList).not.toContain('task-completed');

   // 4. Toggle the mat-checkbox to mark as checked
   const cbClickEl =
      fixture.debugElement.query(By.css('.mat-checkbox-inner-container'));
   cbClickEl.nativeElement.click();
   fixture.detectChanges();

   // 5. Assert the checkbox element is checked
   expect(cbEl.nativeElement.checked).toBe(true);

   // 6. Assert the mat-checkbox contains the CSS class
   expect(matCb.nativeElement.classList).toContain('task-completed');
});

There's a lot of selectors and querying the DOM going on here. To access the checkbox element and interact with it, we get

  • the checkbox element itself (mat-checkbox), which has the bindings for the attribute directive
  • the input element (input within the mat-checkbox element), which is the checkmark
  • the CSS selector .mat-checkbox-inner-container, which is the clickable element of the mat-checkbox

With these three elements, we can proceed with testing operations. But to identify how to write this test, we had to look at the inner workings of mat-checkbox implementation and use potentially non-supported selectors, which could change in the future.

A test with component test harnesses

Let's contrast this with a test using MatCheckbox component test harnesses. To make it easier to compare, we'll follow the same order of operations.

Here's the same test using MatCheckbox test harnesses

it('should apply completed class to match task completion', async () => {

   // 1. Access the mat-checkbox
   const cbHarness = await loader.getHarness(MatCheckboxHarness);

   // 2. Assert the checkbox element is not checked. 
   expect(await cbHarness.isChecked()).toBeFalse();

   // 3. Assert the mat-checkox doesn't contain the CSS class
   const cbHost = await cbHarness.host();
   expect(await cbHost.hasClass('task-completed')).not.toBeTrue();

   // 4. Toggle the mat-checkbox to mark as checked
   await cbHarness.toggle();

   // 5. Assert the checkbox element is checked
   expect(await cbHarness.isChecked()).toBeTrue();

   // 6. Assert the mat-checkbox contains the CSS class
   expect(await cbHost.hasClass('task-completed')).toBeTrue();
});

Notice this test is a lot shorter, a lot easier to read, and we didn't have to worry about digging into the inner workings of the MatCheckbox code to write this test. Everything we did was via the public API of the MatCheckboxHarness.

The value of test harnesses

Now that we compared an example test with and without harnesses, we see the value the test harnesses provide. With component test harnesses, we're able to focus on testing behaviors and better communicate the goal of the test.

In tomorrow's post, we'll dive into the @angular/cdk/testing API to better understand what we get from the library.

Let me know in the comments below if you write component tests and what techniques you use, such as PageObjects or something else.

21