Tidy up your tests

use component harnesses to write tests that bring you joy

Alisa Duncan

Alisa Duncan

Alisa Duncan

Senior Developer Advocate Okta | Angular GDE | Co-organizer AngularKC | Core Team ngGirls

Loves learning about, discussing, and writing automated tests

Enjoys reading books and drinking wine

@AlisaDuncan

Tests that don't bring us joy

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?

Tidy your tests and
focus on meaningful tests
with test harnesses

What are component test harnesses?

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

Why is this useful?

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

Let's write a test

A tidy list todo app

Consider the tests you'd write for an app like this. We'll look at a task view.

The code we'll test


						

Example test


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();
});
						

Example test

 
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();
});  
							

Example test


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();
});  
						

The CDK testing API

Get a HarnessLoader for your environment


								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);
									});
								});
							

Get harnesses


								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);
							

Filter for specific harnesses


								const btns: MatButtonHarness[] = await loader.getAllHarnesses(
									MatButtonHarness.with({text: 'delete'})
								);
							

Access the host element


								const btn: MatButtonHarness = await loader.getHarness(MatButtonHarness);
								const btnHost: TestElement = await btn.host();
								await btnHost.hover();						
							

Optimize multiple actions


								const checkbox: MatCheckboxHarness = await loader.getHarness(MatCheckboxHarness);
								const [checked, label] = await parallel(() => [
									checkbox.isChecked(),
									checkbox.getLabelText()
								]);					
							

Material component harnesses

Each component has an unique API based on the component's functionality

Material component API documentation includes test harness documentation

Implementing a test harness for a custom component

A tidy list todo app

Take a look at the sentiment rating. We'll write a test harness for the component.

The component we'll write a harness for


						

Define the filters interface to support querying


import { BaseHarnessFilters } from '@angular/cdk/testing';

export interface SentimentRatingHarnessFilters extends BaseHarnessFilters {
	rate?: number;
}						
						

Implement your component test harness


import { ComponentHarness } from '@angular/cdk/testing';

export class SentimentRatingHarness extends ComponentHarness {

}				
						

Implement your component test harness


import { ComponentHarness } from '@angular/cdk/testing';

export class SentimentRatingHarness extends ComponentHarness {
	static hostSelector = 'app-sentiment-rating';
}				
						

Implement your component test harness


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> {
	
	}
}				
						

Implement your component test harness


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> {
	
	}
}
						

Implement your component test harness


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();
	}
}				
						

Implement your component test harness


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();
	}
}				
						

Test your component test harness

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

Tidy tests that bring you joy

Are you inspired to to tidy up your tests?

Want to learn more?

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

@AlisaDuncan

@AlisaDuncan Tidy up your tests