February 23, 2021 •
written by:
The Angular 9 release has brought along a great new feature that helps us to make our component tests more robust, more simple and more readable - component test harnesses.
"In version 9, we are introducing component harnesses, which offer an alternative way to test components. By abstracting away the implementation details, you can make sure your unit tests are correctly scoped and less brittle." - Stephen Fluin (Developer Advocate for Angular)
In this article you will get a brief introduction to the topic of using Angular Material's component harnesses in your tests. Its benefits will be made obvious with an example. We will also will look at how the setup for Karma component tests differs from the already known setup.
Component test harnesses allow our tests to interact with components via supported APIs. These APIs have the ability to interact with components in a way much alike the users', and can isolate themselves from changes in the DOM. The idea for component harnesses has derived from the PageObject pattern, which is commonly used for integration tests.
“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
Before component harnesses existed, CSS selectors had been a common approach to finding components and triggering events. This meant that whenever changes were being made to the component, all tests that relied on the changed component had to be updated. With the latest release, Angular 11 offers some innovations to the component harnesses for Angular Material. It is now possible to test not only individual parts of the application, but all components designed with Angular Material.
The Angular Material team had already introduced component harnesses for all Material components before the release of Angular 9. The following example will give you a small introduction to the topic of Angular Material's component harnesses. We will demonstrate the advantages with a component test of the Material Select component.
We are testing a Material Select component that has three options. This is a test case to test if the mat-select
has
three mat-options
and if the third option, which is being selected, has the value "Tacos".
This is the classic test without harness:
it('should be able to get the value text from a select (classic test)', () => {
const compiledDom = fixture.debugElement.nativeElement;
const select = compiledDom.querySelector('mat-select');
select.click();
fixture.detectChanges();
const optionSelectList: NodeListOf<HTMLElement> = overlay.querySelectorAll('mat-option');
expect(optionSelectList.length).toBe(3);
optionSelectList[2].click();
fixture.detectChanges();
expect(select.textContent).toEqual('Tacos');
});
Let's take a look at what happens here.
mat-select
to trigger a click event on it.overlayContainer
, which causes changes in the DOM. Therefore, we need to
call fixture.detectChanges()
. Calling the detectChanges
method will ensure that any changes are also being
applied to the template.mat-options
and check if the optionSelectList
, which is located in the OverlayContainer
, has
the length of three.textContent
of the select field equals the name of the selected
option. In this case, it's "Tacos".it('should be able to get the value text from a select (with harness)', async () => {
const select = await loader.getHarness(MatSelectHarness);
await select.open();
const options = await select.getOptions();
expect(options.length).toEqual(3);
await options[2].click();
expect(await select.getValueText()).toBe('Tacos');
});
What is happening here?
MatSelect
with the getHarness
method. This method and the getAllHarness
method
are provided by the HarnessLoader
. We will come back to this later.MatSelectHarness
to open the overlayContainer
with the options.
The getOptions
method gets us the option. The method is provided by the MatSelectHarness
API.The first test doesn't seem particularly complicated, yet some parts of it are prone to error.
detectChanges
method, our test will fail.mat-select
component are changed, our test will fail, even though our application might
still work as we expect it to.Tests that query the elements using selectors are generally not very robust, since these selectors can change over time.
The second test is way more readable and easier to understand than the first one. Also, a big advantage is that the implementation details are irrelevant to the purpose of testing. This means that the shape of the component model, the data binding API, and the DOM structure of the component template are unimportant because we do not need to rely on them in our test cases.
All component harness APIs are asynchronous and must return a promise. Therefore, we must use async
and await
.
You might have noticed the omission of fixture.detectChanges()
, unlike in the first test. The component harnesses
automatically call change detection after any interaction and before state is read. Another advantage is that there is
also no need for fixture.whenStable()
, which we often have to use in our classic tests. The harness automatically
waits for the fixture to be stable, which causes the test to wait for setTimeout
, Promise
, etc.
Good news: We only have to apply small changes to our classic setup in order to use Angular Material's test harnesses.
We extend the classic setup with the HarnessLoader
. From the HarnessEnvironment
, you can get a HarnessLoader
instance, which enables us to load Angular Material component harnesses. In the beforeEach()
, we create
a HarnessLoader
for our fixture for our SelectHarnessExampleComponent
.
let component: SelectHarnessExampleComponent;
let fixture: ComponentFixture<SelectHarnessExampleComponent>;
let overlay: HTMLElement;
beforeEach(() => {
fixture = TestBed.createComponent(SelectHarnessExampleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
let component: SelectHarnessExampleComponent;
let fixture: ComponentFixture<SelectHarnessExampleComponent>;
let overlay: HTMLElement;
let loader: HarnessLoader;
beforeEach(() => {
fixture = TestBed.createComponent(SelectHarnessExampleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});
As you can see, we only need to add two more lines to our previous setup.
Testing Material components requires us to test the TypeScript code plus the corresponding template - so we don't perform an isolated unit test. This makes our test more difficult than a unit test where merely a TypeScript file is tested. Testing Material components had been even more complicated and error prone before harnesses were introduced. We were required to understand the implementation details to know how to access our elements that we needed to test. Thanks to Material's harnesses, we don't need to worry about the structure of the component in our tests. Tests that make use of harnesses result in less brittle tests as implementation details are hidden from test suites.
With harnesses, the Angular Team offers us a great feature which makes our lives much easier when it comes to testing Angular Material components.
If you want to learn more about this topic, check out the guide. Among other things, you can get information on how to write harnesses for your own components in this documentation.