Create a component harness for your tests with Angular CDK
Lars Gyrup Brink Nielsen
Posted on March 24, 2021
Cover photo by Cassey Cambridge on Pixy.
Original publication date: 2020-03-28.
Updated for Angular CDK and Angular Material version 9.2.
A component harness is a testing API around an Angular directive or component. Component harnesses can be shared between unit tests, integration tests, and end-to-end tests. They result in less brittle tests as implementation details are hidden from test suites.
What does a component harness look like?
As a case study we'll explore the MatButtonHarness
introduced in Angular Material version 9.
The component harness for the MatButton
directive has these methods:
blur(): Promise<void>
click(): Promise<void>
focus(): Promise<void>
getText(): Promise<string>
host(): Promise<TestElement>
isDisabled(): Promise<boolean>
This covers the most important user interactions and observable traits of a Material Button.
All component harness methods must return a promise. This gives the consumer a consistent API feel, but also lets the harness environment take care of asynchronous events, timers, and change detection. This makes async-await
the most convenient statement structure for tests using component harnesses as we'll see later.
The only method here that is out of the ordinary is the host
method. It resolves a TestElement
. We'll explore test elements in a minute. First, it's important to note that test elements should only be exposed to consumers if it wraps an element that the consumer has created. This is always the case for directives' host elements, so it's valid for MatButtonHarness
.
The button harness also has the conventional static with
method which accepts an object with harness filters to select the directive in the DOM, we're interested in.
As an example, we can select a button with the text label Sign up
as seen in Listing 1.
How do I create a component harness?
For our next case study, we will implement a component harness for a favourite ocean creature component which uses Angular Material components to implement the favourite ocean creature picker component.
The component's UI and interactions can be seen in Figures 1 and 2.
As we'll see when we implement and use the test harness for this component, the implementation details won't matter for the purpose of testing, using a test-as-a-user approach. That is, the shape of the component model, the data binding API, and the DOM structure of the component template are unimportant as we don't directly rely on them in our test cases.
In Listing 2, we create a minimal component harness which extends the ComponentHarness
class from @angular/cdk/testing
and specifies a CSS selector for a top-level DOM element of the component or it's template. In this case, we're targeting <app-favorite-ocean-creature>
elements.
This gives test cases access to the host
property which is a promise that resolves to a TestElement
.
The TestElement
interface holds these methods for interaction with a DOM element:
blur(): Promise<void>
clear(): Promise<void>
click(relativeX?: number, relativeY?: number): Promise<void>
getAttribute(name: string): Promise<string | null>
getCssValue(property: string): Promise<string>
-
getDimensions(): Promise<ElementDimensions>
* getProperty(name: string): Promise<any>
isFocused(): Promise<boolean>
focus(): Promise<void>
hasClass(name: string): Promise<string>
hover(): Promise<void>
matchesSelector(selector: string): Promise<boolean>
-
sendKeys(...keys: (string | TestKey)[]): Promise<void>
** text(): Promise<string>
* ElementDimensions
is an interface
with number
properties top
, left
, width
, and height
.
** TestKey
is an enum
with keycodes for non-text keys such as BACKSPACE
, TAB
, ENTER
, LEFT_ARROW
, and F10
.
For every element in our component's DOM, we can query for a TestElement
. However, we should only expose TestElement
s to our consumers (test cases or library users) that interact with DOM elements that they are directly controlling such as a component's host element. In this case, that is the <app-favorite-ocean-creature>
DOM element which is used and controlled by parent components' templates.
Component harness authoring tip: In general, don't expose
TestElement
s directly. Only do so for DOM elements that consumers control.
The reason for this is that we don't want consumers to depend on our DOM structure which is an implementation detail that they should not have to rely on or even worry about. It's up to us as the owners of components and directives to keep our component harnesses in sync with their corresponding DOM structures.
Staging the component test suite
Let's make the test suite for the component drive the API design of our component harness.
First we want to verify which ocean creature is picked initially. To do that, we need to configure the Angular testing module for a test host component which uses the favourite ocean create component.
Listing 3 shows how we create the test hosting component, configure the Angular testing module by disabling animations, declaring the test host component and importing the declaring module of our component.
After configuring the Angular testing module, we first set up a component fixture for the test host component. Then we create a HarnesssLoader
by passing the component fixture to TestbedHarnessEnvironment.loader
. Finally, we query the component harness that represents the favourite ocean creature component in the test host component's template by passing FavoriteOceanCreatureHarness
to HarnessLoader#getHarness
and resolving the promise it returns.
Component harness environments
In unit and integration tests, we use TestbedHarnessEnvironment
to create a HarnessLoader
. The test bed component harness environment support the Karma and Jest test runners, probably also other test runners as long as they support a DOM.
If we use Protractor for end-to-end tests, we can use the ProtractorHarnessEnvironment
to create HarnessLoader
s. For other end-to-end test frameworks such as Cypress, we would have to implement a specialised HarnessEnvironment
or wait for the community to publish one.
If you're interested in providing support for other end-to-end frameworks, read the official component harness guide's section called "API for harness environment authors". The guide teaches about the requirements for implementing a HarnessEnvironment
and a corresponding TestElement
which is what enables component harnesses to interact with the DOM as we saw in its API earlier.
Querying for a child harness
With the test staging in Listing 3, we've got everything we need to start adding test cases to our test-as-a-user component test suite.
As the breathtaking and wise manta ray is obviously the favourite ocean creature of many people, it is the initial pick of our component. We'll assert this in our first test case.
Listing 4 shows the API we want our component harness to support–a method called getFavoriteOceanCreature
which returns a promise that resolves to a string holding the display name of an ocean creature that can be picked as our favourite.
In Listing 5, we add a protected method that returns a promise that resolves to a component harness. The MatSelectHarness
represents a MatSelect
directive. In our case, the select directive used by the favourite ocean picker component.
The AsyncFactoryFn<T>
type in the @angular/cdk/testing
sub-package represents a function that returns Promise<T>
, for example an async
function.
While getDropDown
looks like a property, since we're assigning to it the result of calling another method, it is indeed a method. ComponentHarness#locatorFor
is a utility function often used for creating internal or publicly exposed query methods.
We use this.locatorFor(MatSelectHarness)
to query for the harness representing the first select directive child of the specific favourite ocean creature component.
ComponentHarness#locatorFor
is one of the built-in utility methods of the ComponentHarness
base class. It supports multiple ways of querying for child elements or component harnesses representing them. It also supports DOM selectors and HarnessPredicate
s.
Next, we implement the public method for resolving the display name of the picked ocean creature. This is done by using the asynchronous child harness locator, getDropDown
.
Note that getFavoriteOceanCreature
is an async
method, meaning that whatever value we return is wrapped in a promise and that we can use the await
operator inside its method body.
Using a child harness
Once we have awaited the promise returned by this.getDropDown()
, we have a MatSelectHarness
in the select
constant.
How do we get the display text of the selected option from the select harness? Unfortunately, at the time of writing, the MatSelectHarness
is undocumented in Angular Material's online documentation. But since we're using TypeScript, we have access to its type definition.
We scan MatSelectHarness
' API for what we need:
blur(): Promise<void>
clickOptions(filter?: OptionHarnessFilters): Promise<void>
close(): Promise<void>
focus(): Promise<void>
-
getOptionGroups(filter?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptgroupHarness[]>
* ** -
getOptions(filter?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptionHarness[]>
* ** getValueText(): Promise<string>
isDisabled(): Promise<boolean>
isEmpty(): Promise<boolean>
isOpen(): Promise<boolean>
isRequired(): Promise<boolean>
isMultiple(): Promise<boolean>
isValid(): Promise<boolean>
open(): Promise<void>
*OptionHarnessFilters
is an interface that extends BaseHarnessFilters
with the members isSelected?: boolean
and text?: string | RegExp
. BaseHarnessFilters
in the @angular/cdk/testing
sub-package is an interface with the members ancestor?: string
and selector?: string
.
**Note that MatSelectHarness
itself allows us to query for its child harnesses.
Did you spot a method we can use? Correct, it's getValueText
as you might have noticed earlier, in Listing 5.
The async-await
style used in getFavoriteOceanCreature
is very common and central both when creating component harnesses and using them, since all their methods return promises.
No dependency on implementation details
Circling back to Listing 4, we see that we managed to support a test case without the consumer (our first test case) knowing anything about our component's DOM structure or API.
The test case knows nothing about us using Angular Material's select directive and it knows nothing about which elements need to be clicked to open the drop down or pick an option. In fact, we didn't even have to know any of that about MatSelect
when implementing our component harness.
The result is a test that is easy to follow and uses a language that's close to a user story.
Querying for multiple pieces of content
Next up, we're going to verify that the component shows a list of ocean creatures that the user can pick from.
When using a dropdown, we often allow the consumer to pass the options we want to display. However, this component only lists a fixed collection of awesome ocean creatures as seen in Figure 2.
Because of this, our test asserts the presence of a blue whale which is a different ocean creature than the initially picked manta ray.
Exposing content in queries
What do you think the resolved type of the getOptions
method is? <option>
elements? MatOption
s? No, we don't want to expose information that couples our consumers to our implementation details. If we stop using the MatSelect
directive or the select directive stop using <option>
elements, we don't want to break our own tests or those of a 3rd party.
Instead, we'll simply resolve an array of text strings and pass them to our consumers. You might have noticed this because the test case asserts that the options
contains the 'Blue whale'
text string.
Interacting with a child harness
To support this test case, we only need the getDropDown
locator that we added to our component harness in the previous chapter.
In the getOptions
method, we resolve a select harness like before. But instead of returning a value immediately, we interact with the child select harness.
From the MatSelectHarness
API, we first use the open
method to open the dropdown list, then we query for MatOptionHarness
es by using the getOptions
method.
As we discussed, we map the option harnesses to their display texts so that we don't expose implementation details to our consumers.
Since MatOptionHarness#getText
returns a promise like every other harness method, we wrap the mapped promises in a Promise.all
call to resolve all of them at once as an array of text strings.
Notice how async-await
makes the individual steps in our method easy to follow by using a synchronous control flow style.
The component harness hierarchy
As you might have noticed in the previous chapter, component harnesses form a hierarchy that matches the DOM and the component tree closely.
This is illustrated in Figure 3. Our tests use FavoriteOceanCreatureHarness
that internally uses MatSelectHarness
which also gives access to its child harnesses, MatOptionHarness
.
If we were to look at the DOM rendered by our favorite ocean creature component, we would see a similar hierarchy.
Notice that the consumers of FavoriteOceanCreatureHarness
know nothing about <mat-select>
or MatSelectHarness
. We only expose information rather than implementation details. We do this so that our consumers aren't tightly bound to our component implementation which uses MatSelect
under the hood.
If we for some reason want our consumers to interact with the options in the dropdown list, we'll have to wrap MatOptionHarness
in our own FavoriteOceanCreatureOption
.
Supporting user interactions with component harness filters
Our third test case exercises the user's ability to pick a different favourite ocean creature and verify that it's display text is reflected in the content.
As seen in Listing 8, we allow our consumer to specify a text filter to match the display text of the option that they want to pick. In this case, our test case is picking the great white shark option. We consistently use async-await
for our component harness interactions.
Finally, we reuse the query method getFavoriteOceanCreature
to assert that the content reflects our pick.
To support this test case, we need to implement the pickOption
method which takes a component harness filter as an argument.
Listing 9 shows the relevant methods and properties of the favourite ocean creature harness that supports the test case we wrote in Listing 8.
pickOption
is a new method. It accepts a FavoriteOceanCreatureFilters
parameter that we will look at in a minute.
In the method body, we access the child MatSelectHarness
using the getDropDown
locator which we have used before.
We pass the text filter to the MatSelectHarness#clickOptions
method which clicks the first matching option for single value dropdowns.
Listing 10 shows a basic custom component harness filter. We create an interface that extends BaseHarnessFilters
from @angular/cdk/testing
. Previously we mentioned that the base harness filters has optional ancestor
and a selector
properties. We don't currently support them as we only pass our text
filter to the child select harness as seen in Listing 9.
It would make more sense not to extend the base harness filters until we implemented support for its properties or we could use Omit
like MatSelectHarness
does for option and option group harness filters.
For demonstration purposes, we extend the full base harness filter here which means that our consumers are able to specify selector
and ancestor
filters, even though they are not being used. We could implement the base filters using harness locators, but let's skip that to keep this example simple.
We have now implemented our first user interaction through a custom component harness with a custom component harness filter. Nice
Filtering exposed content
In our final test case we assert that when we pick a favourite ocean creature, it is used in a sentence to spell out My favorite ocean creature is <ocean creature display text>
.
The test case in Listing 11 first uses the familiar pickOption
to pick the octopus as our favourite ocean creature. When that is done, we query for the text content of the favourite ocean creature component and assert that it matches the expected format and includes Octopus
.
Listing 12 include the methods relevant to the sentence test case from Listing 11. We are already familiar with the pickOption
interaction method, the getDropDown
locator it uses and the filter it accepts.
Let's consider the getText
query method which takes no arguments. We start out by querying the host element's DOM for the current text content. First it accesses a TestElement
representing the host element by using the inherited ComponentHarness#host
method.
We then query the text content of the host element by calling and resolving the TestElement#text
method on our host
variable. Finally, we filter out the label of the favourite ocean creature picker which is an implementation detail and not of interest to this part of the testing API we expose through our custom component harness.
We also trim the text since HTML often includes additional whitespace around text content. By doing this in our component harness, we save multiple consumers from doing the same sanitising task which could otherwise lead to false positives when testing use cases involving our favourite ocean creature component.
The full test suite
Let's finish by taking a look at the full test suite.
For our unit and integration tests, we still configure an Angular testing module through the test bed, but only to be able to create a component fixture for a test host component. We pass the component fixture to the test bed harness environment to get a harness loader.
For this test suite, we only need to load a single component harness that we store a reference to in the shared harness
variable. We keep both the component fixture and the harness loader out of scope of the test cases.
Simple test cases
Looking at the test cases in Listing 13, we notice that they have very few test steps. There are only 1-2 lines of code in the arrange, act, and assert stages of each test case. This is thanks to the testing API we expose through our custom component harness.
No manual change detection or task queue emptying
If you have ever written component tests for Angular applications and UI libraries using the test bed, you will have noticed that we usually have to call ComponentFixture#detectChanges
, tick
inside an fakeAsync
or resolve ComponentFixture#whenStable
to wait for async tasks to complete and Angular's change detection cycle and rendering to end.
In our test cases that use a component harness, we don't have to call any of those methods and functions. The component harness environment takes care of this for us. The downside is that every method has to be asynchronous and return a promise, but this is nicely addressed by using async-await
in our component harness methods and consuming test cases.
End-to-end tests
Until now, we only showed unit tests consuming our component harness. One of the many benefits of using component harnesses is that they are reusable between unit tests, integration tests, and end-to-end tests.
Let's convert one of our tests cases to an end-to-end test.
The end-to-end test case in Listing 14 is an exact copy-paste from our unit test.
The setup is slightly different. Since the test runs against the full application in the browser, we're not configuring the testing Angular module with TestBed
.
We're using Protractor to control the browser and navigate to the URL path where our component is rendered. We see that we use ProtractorHarnessEnvironment
instead of TestbedHarnessEnvironment
to get a HarnessLoader
.
Those are really the only differences. The component harness is consumed in exactly the same way, as soon as we have an instance of it which we get from the harness loader.
Slight differences
I told you that component harnesses can be used both in unit tests, integration tests, and end-to-end tests. While that's true, if we run the test above, we'll stumble upon a couple of things.
Whitespace differences
The first one that unfortunately doesn't show up as a clear error in the test is that the text filter for the pickOption
method doesn't seem to work. Apparently, there are some whitespace differences between unit tests and end-to-end tests in our case.
Remember that the text filter option supports either a string
or a RegExp
? This is because the MatSelect#clickOptions
methods accepts both and now we're going to need the second option.
Because of the whitespace differences, we're going to coerce a string
text filter into a regular expression that allows whitespace before and after the text filter. This is done in the private coerceRegExp
method seen in Listing 15 which always returns a regular expression.
In the test case, we also use FavoriteOceanCreatureHarness#getText
which also reveals som whitespace differences between unit tests and end-to-end tests. We support these differences by replacing one or more newlines with a single space character.
Animations flakiness
The section "Waiting for asynchronous tasks" of the official component harnesses guide mentions that Angular animations might require several runs of change detection and NgZone
task intercepting before stabilizing.
In our unit tests, we imported the NoopAnimationsModule
to disable animations which are used by many Angular Material components.
In our end-to-end tests, the application uses real browser animations because our AppModule
imports the BrowserAnimationsModule
.
I've seen the test case above fail approximately every other run because of animations. The animations didn't always complete after clicking an option in the dropdown which happens before the DOM element that displays the selected value is re-rendered.
This is a case where we follow the instructions from the component harness guide mentioned above. After clicking an option, we call ComponentHarness#forceStabilize
as shown in Listing 16.
With those two additions to our component harness, this test case passes with exactly the test and component harness code in both unit tests and end-to-end tests.
Protractor caveat
An unfortunate caveat as of Angular CDK version 10.1 is that ProtractorHarnessEnvironment
does not implement waitForTasksOutsideAngular
yet.
This means that asynchronous tasks run outside NgZone
cannot be intercepted and awaited by the Protractor harness environment which could lead to false positives in our Protractor tests or force us to write additional code in the test cases themselves. Especially if we use non-Angular UI libraries.
Conclusion
I started out by saying that a component harness wraps a component or a directive. But in fact, component harnesses can be used to build a testing API around any pieces of DOM.
A component harness does not have to wrap only a single component or DOM element. As we discussed, a component harness can represent a hierarchy of component harnesses. A component harness hierarchy can consist of several component harness types, several component harness instances of the same type or a mix of both as we have seen.
In our example, we created a single component harness that interacted with all the different parts of the favorite ocean creature use case. We could have split it into multiple component harnesses. We could also have created a component harness that allowed consumers to interact with a complete page or an entire application.
By the way, how many components does the use case consist of? Did you notice that throughout this article, we never once saw an Angular component model or an Angular template? This speaks in favour of the validity of the test-as-a-user strategy which component harnesses help us follow.
Additional topics
I tried to create a case study at an intermediate level that taught you about writing your own component harness, using Angular Material's component harnesses, using child component harnesses as well as consuming your custom component harness in both unit tests and end-to-end tests.
Of course, there are many more topics to learn about dealing with component harnesses. Here are some of them:
- Writing custom locators
- Implementing the static
with
method for loading specific harnesses - Querying and interacting with the DOM through
TestElement
s - Locating overlays that are outside the applications DOM such as dropdown menus and modal dialogs
- Implementing a custom
HarnessEnvironment
and a matchingTestElement
for end-to-end testing frameworks other than Protractor
We also didn't cover how to test component harnesses. Should we test our own testing APIs? Of course! That's a lesson for another article. Until then, go explore the Angular Components source code to see component harness test suites in action.
Resources
Peer reviewers
Finally, a big thank you to my fellow writers whom helped review this article:
Posted on March 24, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.