Do your component tests feel "cluttered"?
Is it difficult to read and understand your component unit tests at a glance?
Do you want tests to focus on testing behavior without having to deal with querying the DOM?
Do your component tests feel "cluttered"?
Is it difficult to read and understand your component unit tests at a glance?
Do you want tests to focus on testing behavior without having to deal with querying the DOM?
Test harnesses are a set of APIs in Angular CDK that support testing interactions with components
All Angular Material components have test harnesses built-in in v12
Tests that are easier to read
Tests that use an API to interact with a component
Allows you and your team to write tests that focus on behavior
Consider the tests you'd write for an app like this. We'll look at a task view.
it('should apply completed class to match task completion', () => {
const matCb = fixture.debugElement.query(By.css('mat-checkbox'));
expect(matCb).toBeTruthy();
const cbEl = matCb.query(By.css('input'));
expect(cbEl).toBeTruthy();
expect(cbEl.nativeElement.checked).toBe(false);
expect(matCb.nativeElement.classList).not.toContain('tidy-task-completed');
const cbClickEl =
fixture.debugElement.query(By.css('.mat-checkbox-inner-container'));
cbClickEl.nativeElement.click();
fixture.detectChanges();
expect(cbEl.nativeElement.checked).toBe(true);
expect(matCb.nativeElement.classList).toContain('tidy-task-completed');
expect(component.tidyTask.completed).toBeTrue();
});
it('should apply completed class to match task completion', () => {
const matCb = fixture.debugElement.query(By.css('mat-checkbox'));
expect(matCb).toBeTruthy();
const cbEl = matCb.query(By.css('input'));
expect(cbEl).toBeTruthy();
expect(cbEl.nativeElement.checked).toBe(false);
expect(matCb.nativeElement.classList).not.toContain('tidy-task-completed');
const cbClickEl =
fixture.debugElement.query(By.css('.mat-checkbox-inner-container'));
cbClickEl.nativeElement.click();
fixture.detectChanges();
expect(cbEl.nativeElement.checked).toBe(true);
expect(matCb.nativeElement.classList).toContain('tidy-task-completed');
expect(component.tidyTask.completed).toBeTrue();
});
it('should apply completed class to match task completion', async () => {
const cb = await loader.getHarness(MatCheckboxHarness);
expect(await cb.isChecked()).toBeFalse();
const cbHost = await cb.host();
expect(await cbHost.hasClass('tidy-task-completed')).not.toBeTrue();
await cb.toggle();
expect(await cb.isChecked()).toBeTrue();
expect(await cbHost.hasClass('tidy-task-completed')).toBeTrue();
});
it('should apply completed class to match task completion', async () => {
const cb = await loader.getHarness(MatCheckboxHarness);
expect(await cb.isChecked()).toBeFalse();
const cbHost = await cb.host();
expect(await cbHost.hasClass('tidy-task-completed')).not.toBeTrue();
await cb.toggle();
expect(await cb.isChecked()).toBeTrue();
expect(await cbHost.hasClass('tidy-task-completed')).toBeTrue();
});
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
describe('Tidy Test', () => {
let loader: HarnessLoader;
beforeEach(() => {
TestBed.configureTestingModule({...});
fixture = TestBed.createComponent(TidyTestComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
});
});
const btn: MatButtonHarness = await loader.getHarness(MatButtonHarness);
const btns: MatButtonHarness[] = await loader.getAllHarnesses(MatButtonHarness);
const childLoader: HarnessLoader = await loader.getChildLoader('.my-selector');
const childBtn: MatButtonHarness = await childLoader.getHarness(MatButtonHarness);
const btns: MatButtonHarness[] = await loader.getAllHarnesses(
MatButtonHarness.with({text: 'delete'})
);
const btn: MatButtonHarness = await loader.getHarness(MatButtonHarness);
const btnHost: TestElement = await btn.host();
await btnHost.hover();
const checkbox: MatCheckboxHarness = await loader.getHarness(MatCheckboxHarness);
const [checked, label] = await parallel(() => [
checkbox.isChecked(),
checkbox.getLabelText()
]);
Each component has an unique API based on the component's functionality
Material component API documentation includes test harness documentation
Take a look at the sentiment rating. We'll write a test harness for the component.
import { BaseHarnessFilters } from '@angular/cdk/testing';
export interface SentimentRatingHarnessFilters extends BaseHarnessFilters {
rate?: number;
}
import { ComponentHarness } from '@angular/cdk/testing';
export class SentimentRatingHarness extends ComponentHarness {
}
import { ComponentHarness } from '@angular/cdk/testing';
export class SentimentRatingHarness extends ComponentHarness {
static hostSelector = 'app-sentiment-rating';
}
import { AsyncFactoryFn, ComponentHarness, TestElement } from '@angular/cdk/testing';
export class SentimentRatingHarness extends ComponentHarness {
static hostSelector = 'app-sentiment-rating';
private _rateButtons: AsyncFactoryFn<TestElement[]> = this.locatorForAll('button');
public async getRate(): Promise<number> {
}
public async setRate(rate: number): Promise<void> {
}
}
import { AsyncFactoryFn, ComponentHarness, parallel, TestElement } from '@angular/cdk/testing';
export class SentimentRatingHarness extends ComponentHarness {
static hostSelector = 'app-sentiment-rating';
private _rateButtons: AsyncFactoryFn<TestElement[]> = this.locatorForAll('button');
public async getRate(): Promise<number> {
const btns = await this._rateButtons();
return (await parallel(() => btns.map(b => b.text()))).reduce((acc, curr) => curr === 'favorite' ? ++acc : acc, 0);
}
public async setRate(rate: number): Promise<void> {
}
}
import { AsyncFactoryFn, ComponentHarness, HarnessPredicate, parallel, TestElement } from '@angular/cdk/testing';
export class SentimentRatingHarness extends ComponentHarness {
static hostSelector = 'app-sentiment-rating';
private _rateButtons: AsyncFactoryFn<TestElement[]> = this.locatorForAll('button');
public async getRate(): Promise<number> {
const btns = await this._rateButtons();
return (await parallel(() => btns.map(b => b.text()))).reduce((acc, curr) => curr === 'favorite' ? ++acc: acc, 0);
}
public async setRate(rate: number): Promise<void> {
if (rate <= 0) throw Error('Rate is invalid');
const btns = await this._rateButtons();
if (btns.length < rate) throw Error('Rate exceeds supported rate options');
return (await btns[rate - 1]).click();
}
}
import { AsyncFactoryFn, ComponentHarness, HarnessPredicate, parallel, TestElement } from '@angular/cdk/testing';
import { SentimentRatingHarnessFilters } from './sentiment-rating-harness-filters';
export class SentimentRatingHarness extends ComponentHarness {
static hostSelector = 'app-sentiment-rating';
static with(options: SentimentRatingHarnessFilters): HarnessPredicate<SentimentRatingHarness> {
return new HarnessPredicate(SentimentRatingHarness, options)
.addOption('rate', options.rate,
async (harness, rate) => await harness.getRate() === rate
);
}
private _rateButtons: AsyncFactoryFn<TestElement[]> = this.locatorForAll('button');
public async getRate(): Promise<number> {
const btns = await this._rateButtons();
return (await parallel(() => btns.map(b => b.text()))).reduce((acc, curr) => curr === 'favorite' ? ++acc: acc, 0);
}
public async setRate(rate: number): Promise<void> {
if (rate <= 0) throw Error('Rate is invalid');
const btns = await this._rateButtons();
if (btns.length < rate) throw Error('Rate exceeds supported rate options');
return (await btns[rate - 1]).click();
}
}
Don't forget to test your harness - treat it like publishing an API
Create a test host for your component and write tests utilizing your component test harness
Are you inspired to to tidy up your tests?
Slides alisaduncan.github.io/component-harness
Tidy Task Todo App https://github.com/alisaduncan/component-harness-code
Using Angular Material component harnesses guide on material.angular.io
Test harness API docs on material.angular.io