Writing a harness
A harness for a part of your own — extending ComponentHarness, finding its host and its insides by their ARIA, composing the library's harnesses inside it, and waiting on states.
The library's harnesses cover the library's parts. The panels, forms and pages your product builds
around them need harnesses of their own, and they are written the same way. The structure follows
Angular CDK's Creating harnesses
guide, which @kanzo-tech/testing is modelled on.
Extending ComponentHarness
A harness is a class that extends ComponentHarness and says, in a static by, how its host is
found. Everything else is async methods that operate the host and resolve when the state they caused
is there.
import { ComponentHarness, type By } from "@kanzo-tech/testing";
export class RulesPanelHarness extends ComponentHarness {
static readonly by: By = { role: "complementary", name: "Rules panel" };
async rules(): Promise<string[]> {
const rows = await this.host.find({ role: "listitem" });
return Promise.all(rows.map((row) => row.text()));
}
}this.host is a TestElement, the element the environment found. env.getHarness(RulesPanelHarness)
waits for the host to be in the document and builds the harness over it, in Playwright and in jsdom
alike.
Finding what is inside the host
this.host.find(by) answers the descendants that match now. this.locatorFor(by, what) is CDK's
locator: it returns the function that finds the first one, waiting for it and failing with what
when it never comes, so a harness declares its parts once, as fields, and calls them where it needs
them. The function takes another element to search instead of the host. A By is the same in both
places:
{ role, name }— a role and its accessible name, which is what a screen reader announces;{ role, has }— an element of that role holding a match of its own, an article holding its title;{ text }— the words a reader sees;{ css }— a selector, for adata-*attribute, only where nothing accessible says it.
Filtering with with
When a page has several of a part, a static with narrows the query, and returns a HarnessQuery:
import { ComponentHarness, type By, type HarnessQuery } from "@kanzo-tech/testing";
export class PanelHarness extends ComponentHarness {
static readonly by: By = { role: "complementary" };
static with(options: { name: string | RegExp }): HarnessQuery<PanelHarness> {
return { type: PanelHarness, by: { role: "complementary", name: options.name } };
}
}
const orders = await env.getHarness(PanelHarness.with({ name: "Orders panel" }));Waiting on a state
A method that changes something waits for the change, with the environment's until(probe, what):
it resolves with the first truthy answer of probe, asked again until the environment's timeout, and
rejects with what. It is the only way a harness waits. A fixed time is either too short on a slow
runner or wasted on a fast one, and it says nothing when it fails.
private readonly nameField = this.locatorFor({ role: "textbox", name: "Rule name" }, "the panel has no rule name field");
private readonly addButton = this.locatorFor({ role: "button", name: "Add rule" }, "the panel has no Add rule");
async addRule(name: string): Promise<void> {
await (await this.nameField()).fill(name);
await (await this.addButton()).click();
await this.env.until(
async () => (await this.host.find({ role: "listitem", has: { text: name } })).length > 0,
`the rule ${name} was not added`,
);
}Wait on what the reader would see: a row that appears, a button that becomes pressed, an
aria-busy that goes. A part that finishes work no reader can perceive should say so — aria-busy
is what the library's own parts use.
Composing the library's harnesses
A harness for a host's panel returns the library's harnesses for the library's parts in it, rather
than reaching into their markup. this.locatorFor takes a harness query as well as a By, and
its function then resolves with that harness, inside the host:
import { ComponentHarness, FindingsBadgeHarness, type By } from "@kanzo-tech/testing";
export class RulesPanelHarness extends ComponentHarness {
static readonly by: By = { role: "complementary", name: "Rules panel" };
/** What the rules found, as the library's own harness drives it. */
readonly findings = this.locatorFor(FindingsBadgeHarness);
}
const panel = await env.getHarness(RulesPanelHarness);
const finding = await (await panel.findings()).item("No date on the writ");
expect(await finding.severity()).toBe("violation");When the library changes a part's markup, its harness changes with it, and your panel's harness does not move.
ARIA first, in your own parts
A harness can only be as stable as what it finds. The rule the library follows applies to a host's parts too, in Testing Library's order:
- A role and an accessible name. An
asidewith anaria-labelis acomplementaryregion a harness and a screen reader both find by name. A button named by its icon alone has no name; give it anaria-label. - Text, for what has no role: a row's words, a heading's.
- A
data-*attribute last, where a state is real and nothing accessible describes it. The library has one,GraphCanvas'sdata-frame, and the testing rule says why.
When a harness of yours needs { css }, it is usually the part that needs a name.
Testing
Component harnesses for a page built with Kanzo UI — a class per component that finds it by its ARIA and drives it, in Playwright or in Testing Library, in Angular CDK's shape. A sibling of @kanzo-tech/ui that imports none of it.
Organising an end-to-end suite
How a host's Playwright suite is layered over the library's harnesses — specs over page objects over harnesses — and the rules that keep it from flaking, after Playwright's own best practices.