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
- Make your tests easier to read and understand
- Make your tests easier to write by using the APIs to interact with UI components
- 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.
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:
- Access the checkbox element
- Assert the checkbox starts unchecked
- Assert the checkbox doesn't contain the CSS class
task-completed
- Toggle the checkbox to mark as checked
- Assert the checkbox is now checked
- 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 themat-checkbox
element), which is the checkmark - the CSS selector
.mat-checkbox-inner-container
, which is the clickable element of themat-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.
Top comments (1)
I use the idea of PageObject to simplify test APIs usage, or Facade design pattern if you well. Also, I intend to always have one expectation in each test case grouping them under one describe for clarity. lately I've been using spectator, and it is amazing and nicer way to write tests.
Good article, keep up the good work!
spectator
page_object