The Angular CDK provides code for creating component test harnesses. A component harness is a class that lets a test interact with a component via a supported API. Each harness's API interacts with a component the same way a user would. By using the harness API, a test insulates itself against updates to the internals of a component, such as changing its DOM structure. The idea for component harnesses comes from the PageObject pattern commonly used for integration testing.
Angular Material offers test harnesses for many of its components. The Angular team strongly encourages developers to use these harnesses for testing to avoid creating brittle tests that rely on a component's internals.
This guide discusses the advantages of using component test harnesses and shows how to use them.
There are two primary benefits to using the Angular Material component harnesses in your tests:
The following sections will illustrate these benefits in more detail.
The Angular CDK's component harnesses are designed to work in multiple different test environments.
Support currently includes Angular's Testbed environment in Karma unit tests and Selenium WebDriver
end-to-end (e2e) tests. You can also support additional environments by creating custom extensions
of the CDK's HarnessEnvironment
and TestElement
classes.
The foundation for all test harnesses lives in @angular/cdk/testing
. Start by importing either
TestbedHarnessEnvironment
or SeleniumWebDriverHarnessEnvironment
based on whether you're writing a
unit test or an e2e test. From the HarnessEnvironment
, you can get a HarnessLoader
instance,
which you will use to load Angular Material component harnesses. For example, if we're writing unit
tests for a UserProfile
component, the code might look like this:
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
let loader: HarnessLoader;
describe('my-component', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({imports: [MyModule], declarations: [UserProfile]})
.compileComponents();
fixture = TestBed.createComponent(UserProfile);
loader = TestbedHarnessEnvironment.loader(fixture);
});
}
This code creates a fixture for UserProfile
and then creates a HarnessLoader
for that fixture.
The HarnessLoader
can then locate Angular Material components inside UserProfile
and create
harnesses for them. Note that HarnessLoader
and TestbedHarnessEnvironment
are loaded from
different paths.
@angular/cdk/testing
contains symbols that are shared regardless of the environment your tests
are in. @angular/cdk/testing/testbed
contains symbols that are used only in Karma tests.@angular/cdk/testing/selenium-webdriver
(not shown above) contains symbols that are used only in
Selenium WebDriver tests.The HarnessLoader
provides two methods that can be used to load harnesses, getHarness
and
getAllHarnesses
. The getHarness
method gets a harness for the first instance
of the matching component, while getAllHarnesses
gets a list of harnesses, one
for each instance of the corresponding component. For example, suppose UserProfile
contains three
MatButton
instances. We could load harnesses for them as follows:
import {MatButtonHarness} from '@angular/material/button/testing';
...
it('should work', async () => {
const buttons = await loader.getAllHarnesses(MatButtonHarness); // length: 3
const firstButton = await loader.getHarness(MatButtonHarness); // === buttons[0]
});
Notice the example code uses async
and await
syntax. All component harness APIs are
asynchronous and return Promise
objects. Because of this, the Angular team recommends using the
ES2017 async
/await
syntax
with your tests.
The example above retrieves all button harnesses and uses an array index to get the harness for a
specific button. However, if the number or order of buttons changes, this test will break. You can
write a less brittle test by instead asking for only a subset of harnesses inside UserProfile
.
You can load harnesses for a sub-section of the DOM within UserProfile
with the getChildLoader
method on HarnessLoader
. For example, say that we know UserProfile
has a div,
<div class="footer">
, and we want the button inside that specific <div>
. We can accomplish this
with the following code:
it('should work', async () => {
const footerLoader = await loader.getChildLoader('.footer');
const footerButton = await footerLoader.getHarness(MatButtonHarness);
});
You can also use the static with
method implemented on all Angular Material component harnesses.
This method creates a HarnessPredicate
, an object that filters loaded harnesses based on the
provided constraints. The particular constraint options vary depending on the harness class, but all
harnesses support at least:
selector
- CSS selector that the component must match (in addition to its host selector, such
as [mat-button]
)ancestor
- CSS selector for a some ancestor element above the component in the DOMIn addition to these standard options, MatButtonHarness
also supports
text
- String text or regular expressions that matches the text content of the button Using this method we could locate buttons as follows in our test:
it('should work', async () => {
// Harness for mat-button whose id is 'more-info'.
const info = await loader.getHarness(MatButtonHarness.with({selector: '#more-info'}));
// Harness for mat-button whose text is 'Cancel'.
const cancel = await loader.getHarness(MatButtonHarness.with({text: 'Cancel'}));
// Harness for mat-button with class 'confirm' and whose text is either 'Ok' or 'Okay'.
const okButton = await loader.getHarness(
MatButtonHarness.with({selector: '.confirm', text: /^(Ok|Okay)$/}));
});
The Angular Material component harnesses generally expose methods to either perform actions that a
real user could perform or to inspect component state that a real user might perceive. For
example, MatButtonHarness
has methods to click, focus, and blur the mat-button
, as well as
methods to get the text of the button and its disabled state. Because MatButton
is a very simple
component, these harness methods might not seem very different from working directly with the DOM.
However, more complex harnesses like MatSelectHarness
have methods like open
and isOpen
which
capture more knowledge about the component's internals.
A test using the MatButtonHarness
to interact with a mat-button
might look like the following:
it('should mark confirmed when ok button clicked', async () => {
const okButton = await loader.getHarness(MatButtonHarness.with({selector: '.confirm'});
expect(fixture.componentInstance.confirmed).toBe(false);
expect(await okButton.isDisabled()).toBe(false);
await okButton.click();
expect(fixture.componentInstance.confirmed).toBe(true);
});
Note that the code above does not call fixture.detectChanges()
, something you commonly see in
unit tests. The CDK's component harnesses automatically invoke change detection after performing
actions and before reading state. The harness also automatically waits for the fixture to be stable,
which will cause the test to wait for setTimeout
, Promise
, etc.
Consider an <issue-report-selector>
component that you want to test. It allows a user to
choose an issue type and display the necessary form create report for that issue type. You need a
test to verify that when the user chooses an issue type the proper report displays. First consider
what the test might look like without using component harnesses:
describe('issue-report-selector', () => {
let fixture: ComponentFixture<IssueReportSelector>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IssueReportSelectorModule],
declarations: [IssueReportSelector],
}).compileComponents();
fixture = TestBed.createComponent(IssueReportSelector);
fixture.detectChanges();
});
it('should switch to bug report template', async () => {
expect(fixture.debugElement.query('bug-report-form')).toBeNull();
const selectTrigger = fixture.debugElement.query(By.css('.mat-select-trigger'));
selectTrigger.triggerEventHandler('click', {});
fixture.detectChanges();
await fixture.whenStable();
const options = document.querySelectorAll('.mat-select-panel mat-option');
options[1].click(); // Click the second option, "Bug".
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.debugElement.query('bug-report-form')).not.toBeNull();
});
});
The same test, using the Angular Material component harnesses might look like the following:
describe('issue-report-selector', () => {
let fixture: ComponentFixture<IssueReportSelector>;
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IssueReportSelectorModule],
declarations: [IssueReportSelector],
}).compileComponents();
fixture = TestBed.createComponent(IssueReportSelector);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should switch to bug report template', async () => {
expect(fixture.debugElement.query('bug-report-form')).toBeNull();
const select = await loader.getHarness(MatSelectHarness);
await select.open();
const bugOption = await select.getOption({text: 'Bug'});
await bugOption.click();
expect(fixture.debugElement.query('bug-report-form')).not.toBeNull();
});
});
The code above shows that adopting the harnesses in tests can make them easier to understand.
Specifically in this example, it makes the "open the mat-select" logic more obvious. An unfamiliar
reader may not know what clicking on .mat-select-trigger
does, but await select.open()
is
self-explanatory.
The harnesses also make clear which option should be selected. Without the harness, you need a comment that
explains what options[1]
means. With MatSelectHarness
, however, the filter API makes the code
self-documenting.
Finally, the repeated calls to detectChanges
and whenStable()
can obfuscate the underlying
intent of the test. By using the harness APIs, you eliminate these calls, making the test more
concise.
Notice that the test without harnesses directly uses CSS selectors to query elements within
<mat-select>
, such as .mat-select-trigger
. If the internal DOM of <mat-select>
changes, these
queries may stop working. While the Angular team tries to minimize this type of change, some
features and bug fixes ultimately require restructuring the DOM. By using the Angular Material
harnesses, you avoid depending on internal DOM structure directly.
In addition to DOM structure, component asynchronicity often offers a challenge when updating
components. If a component changes between synchronous and asynchronous, downstream unit tests may
break due to expectations around timing. Tests then require the addition or removal of some
arcane combination of whenStable
, flushMicroTasks
, tick
, or detectChanges
. Component
harnesses, however, avoid this problem by normalizing the asynchronicity of all component behaviors
with all asynchronous APIs. When a test uses these harnesses, changes to asynchronicity become
far more manageable.
Both DOM structure and asynchronicity are implementation details of Angular Material's components. When tests depend on the implementation details, they become a common source of failures due to library changes. Angular CDK's test harnesses makes component library updates easier for both application authors and the Angular team, as the Angular team only has to update the harness once for everyone.