Angular
Component Test Harnesses FTW!
Alisa Duncan
- Senior Developer Advocate Okta
- Angular GDE
- K-Drama fan
What are we talking about?
Tests UI interactivity within an Angular application using an unit testing library, but concepts apply to all forms of testing
A test harness encapsulates implementation details of HTML fragments into an API for the purposes of testing
How test harnesses help you
- Ignore inner workings of the encapsulated component
- Easier to read and write tests
- Less test maintenance
Consumers of Angular Material components have a head start!
Consumers of Angular Material components have a head start!
All Angular Material components have test harnesses starting in v12
The new MDC changes in v15 means your old tests might need updates
Let's see test harnesses in action
The component template
| <mat-form-field> |
| <mat-label>Email</mat-label> |
| <input matInput type="email" [(ngModel)]="email"> |
| </mat-form-field> |
| |
| <mat-slide-toggle [(ngModel)]="isSubscribed"> |
| Subscribe me! |
| </mat-slide-toggle> |
| <mat-form-field> |
| <mat-label>Email</mat-label> |
| <input matInput type="email" [(ngModel)]="email"> |
| </mat-form-field> |
| |
| <mat-slide-toggle [(ngModel)]="isSubscribed"> |
| Subscribe me! |
| </mat-slide-toggle> |
| <mat-form-field> |
| <mat-label>Email</mat-label> |
| <input matInput type="email" [(ngModel)]="email"> |
| </mat-form-field> |
| |
| <mat-slide-toggle [(ngModel)]="isSubscribed"> |
| Subscribe me! |
| </mat-slide-toggle> |
Interact with input element
| it('no test harness', async () => { |
| }); |
Interact with input element
| it('no test harness', async () => { |
| const input = fixture.debugElement.query(By.css('input')); |
| expect(input).not.toBeNull(); |
| input.nativeElement.value = 'email@email.email'; |
| input.nativeElement.dispatchEvent(new Event('input')); |
| fixture.detectChanges(); |
| await fixture.whenStable(); |
| }); |
| it('no test harness', async () => { |
| const input = fixture.debugElement.query(By.css('input')); |
| expect(input).not.toBeNull(); |
| input.nativeElement.value = 'email@email.email'; |
| input.nativeElement.dispatchEvent(new Event('input')); |
| fixture.detectChanges(); |
| await fixture.whenStable(); |
| }); |
| it('no test harness', async () => { |
| const input = fixture.debugElement.query(By.css('input')); |
| expect(input).not.toBeNull(); |
| input.nativeElement.value = 'email@email.email'; |
| input.nativeElement.dispatchEvent(new Event('input')); |
| fixture.detectChanges(); |
| await fixture.whenStable(); |
| }); |
Working with the input element harness
| it('yay test harness!', async () => { |
| }); |
Working with the input element harness
| it('yay test harness!', async () => { |
| const emailInputEl = await loader.getHarness(MatInputHarness); |
| await emailInputEl.setValue('email@email.email'); |
| }); |
| it('yay test harness!', async () => { |
| const emailInputEl = await loader.getHarness(MatInputHarness); |
| await emailInputEl.setValue('email@email.email'); |
| }); |
Identifying selectors can be painful
Identifying selectors can be painful
| it('interact with slide toggle', () => { |
| const slideToggleEl |
| = fixture.debugElement.query(By.css('mat-slide-toggle')); |
| }); |
Identifying selectors can be painful
| it('interact with slide toggle', () => { |
| const slideToggleEl |
| = fixture.debugElement.query(By.css('mat-slide-toggle')); |
| |
| const slideToggleBtn |
| = slideToggleEl.nativeElement.querySelector('button'); |
| }); |
Identifying selectors can be painful
| it('interact with slide toggle', () => { |
| const slideToggleEl |
| = fixture.debugElement.query(By.css('mat-slide-toggle')); |
| |
| const slideToggleBtn |
| = slideToggleEl.nativeElement.querySelector('button'); |
| |
| const slideToggleCheck |
| = slideToggleEl.nativeElement.querySelector('input[type="checkbox"]'); |
| }); |
Identifying selectors is a piece of 🍰
| it('interact with slide toggle test harness', async () => { |
| const slideToggleHarness |
| = await loader.getHarness(MatSlideToggleHarness); |
| await slideToggleHarness.toggle(); |
| }); |
When using test harnesses
- Tests are easier to read and write
- Tests have more stability and resiliency
- Allows you to focus on testing the behaviors that matter
The CDK testing API
The Angular CDK testing library supports testing interactions with components
Setting up the TestBed
| import { HarnessLoader } from '@angular/cdk/testing'; |
| import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; |
| |
| describe('KDrama Component Test Harness', () => { |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({...}); |
| fixture = TestBed.createComponent(MyKDramaComponent); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
| import { HarnessLoader } from '@angular/cdk/testing'; |
| import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; |
| |
| describe('KDrama Component Test Harness', () => { |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({...}); |
| fixture = TestBed.createComponent(MyKDramaComponent); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
| import { HarnessLoader } from '@angular/cdk/testing'; |
| import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; |
| |
| describe('KDrama Component Test Harness', () => { |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({...}); |
| fixture = TestBed.createComponent(MyKDramaComponent); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
The TestbedHarnessEnvironment
Supports unit testing using Karma
The HarnessLoader
| export interface HarnessLoader { |
| getChildLoader(selector: string): Promise<HarnessLoader>; |
| getAllChildLoaders(selector: string): Promise<HarnessLoader[]>; |
| getHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T>; |
| getHarnessOrNull<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T | null>; |
| getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]>; |
| hasHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<boolean>; |
| } |
Source from cdk/testing/component-harness.ts
Getting harnesses in practice
| const input: MatInputHarness |
| = await loader.getHarness(MatInputHarness); |
| |
| const input: MatInputHarness|null |
| = await loader.getHarnessOrNull(MatInputHarness); |
| |
| const inputs: MatInputHarness[] |
| = await loader.getAllHarnesses(MatInputHarness); |
| |
| const childLoader: HarnessLoader |
| = await loader.getChildLoader('.my-selector'); |
| const childInput: MatInputHarness |
| = await childLoader.getHarness(MatInputHarness); |
| const input: MatInputHarness |
| = await loader.getHarness(MatInputHarness); |
| |
| const input: MatInputHarness|null |
| = await loader.getHarnessOrNull(MatInputHarness); |
| |
| const inputs: MatInputHarness[] |
| = await loader.getAllHarnesses(MatInputHarness); |
| |
| const childLoader: HarnessLoader |
| = await loader.getChildLoader('.my-selector'); |
| const childInput: MatInputHarness |
| = await childLoader.getHarness(MatInputHarness); |
| const input: MatInputHarness |
| = await loader.getHarness(MatInputHarness); |
| |
| const input: MatInputHarness|null |
| = await loader.getHarnessOrNull(MatInputHarness); |
| |
| const inputs: MatInputHarness[] |
| = await loader.getAllHarnesses(MatInputHarness); |
| |
| const childLoader: HarnessLoader |
| = await loader.getChildLoader('.my-selector'); |
| const childInput: MatInputHarness |
| = await childLoader.getHarness(MatInputHarness); |
| const input: MatInputHarness |
| = await loader.getHarness(MatInputHarness); |
| |
| const input: MatInputHarness|null |
| = await loader.getHarnessOrNull(MatInputHarness); |
| |
| const inputs: MatInputHarness[] |
| = await loader.getAllHarnesses(MatInputHarness); |
| |
| const childLoader: HarnessLoader |
| = await loader.getChildLoader('.my-selector'); |
| const childInput: MatInputHarness |
| = await childLoader.getHarness(MatInputHarness); |
Filtering for a harness
loader.getHarness(MatInputHarness.with({options}));
Your options depend on the harness you are using
The InputHarnessFilters
interface
export interface InputHarnessFilters extends BaseHarnessFilters {
value?: string | RegExp;
placeholder?: string | RegExp;
}
Source from material/input/testing/input-harness-filters.ts
Filter MatInputHarness
by value
| const inputs: MatInputHarness[] = await loader.getAllHarnesses( |
| MatInputHarness.with({ |
| value: 'only cool comments, please!' |
| }) |
| ); |
| const inputs: MatInputHarness[] = await loader.getAllHarnesses( |
| MatInputHarness.with({ |
| value: 'only cool comments, please!' |
| }) |
| ); |
Once you have a harness, it is up to the API of that harness on interactions you can take
Check out the documentation!
An example component test harness
export class MatInputHarness extends ComponentHarness {
async isDisabled(): Promise<boolean> {}
async isRequired(): Promise<boolean> {}
async getValue(): Promise<string> {}
async getName(): Promise<string> {}
async setValue(newValue: string): Promise<void> {}
}
Not quite accurate since MatInput
extends from
MatFormFieldControlHarness
, but simplifying for this presentation
The ComponentHarness
base class
export abstract class ComponentHarness {
async host(): Promise<TestElement> {
return this.locatorFactory.rootElement;
}
}
Source from cdk/testing/component-harness.ts
The TestElement
export interface TestElement {
blur(): Promise<void>;
clear(): Promise<void>;
click(relativeX: number, relativeY: number, modifiers?: ModifierKeys): Promise<void>;
rightClick(relativeX: number, relativeY: number, modifiers?: ModifierKeys): Promise<void>;
focus(): Promise<void>;
getCssValue(property: string): Promise<string>;
hover(): Promise<void>;
getAttribute(name: string): Promise<string | null>;
hasClass(name: string): Promise<boolean>;
getDimensions(): Promise<ElementDimensions>;
getProperty<T = any>(name: string): Promise<T>;
setInputValue(value: string): Promise<void>;
}
Source from cdk/testing/test-element.ts
Notice everything is async
You're now winning at writing tests with Material component test harnesses! Let's win some more!
Write custom component test harnesses for
Maximum Winning!
Custom test harness minimal example
Custom test harness minimal example
Shared component code snippet
| @Component({ |
| selector: 'app-add-comment', |
| template: ` |
| <input type="text" |
| [(ngModel)]="commentInput" |
| placeholder="Add a comment" |
| /> |
| ` |
| }) |
| export class AddCommentComponent { } |
| @Component({ |
| selector: 'app-add-comment', |
| template: ` |
| <input type="text" |
| [(ngModel)]="commentInput" |
| placeholder="Add a comment" |
| /> |
| ` |
| }) |
| export class AddCommentComponent { } |
| @Component({ |
| selector: 'app-add-comment', |
| template: ` |
| <input type="text" |
| [(ngModel)]="commentInput" |
| placeholder="Add a comment" |
| /> |
| ` |
| }) |
| export class AddCommentComponent { } |
Define the filters interface to support querying
| import { BaseHarnessFilters } from '@angular/cdk/testing'; |
| |
| export interface AddCommentHarnessFilters extends BaseHarnessFilters { |
| } |
Define the filters interface to support querying
| import { BaseHarnessFilters } from '@angular/cdk/testing'; |
| |
| export interface AddCommentHarnessFilters extends BaseHarnessFilters { |
| comment?: string; |
| } |
Implement your component test harness
| import { ComponentHarness } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| } |
Implement your component test harness
| import { ComponentHarness } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| } |
Implement your component test harness
| import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| private inputEl: AsyncFactoryFn<TestElement> |
| = this.locatorFor('input'); |
| } |
Implement your component test harness
| import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| private inputEl: AsyncFactoryFn<TestElement> = this.locatorFor('input'); |
| |
| public async getComment(): Promise<string> { |
| } |
| } |
Implement your component test harness
| import { AsyncFactoryFn, ComponentHarness, TestElement } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| private inputEl: AsyncFactoryFn<TestElement> = this.locatorFor('input'); |
| |
| public async getComment(): Promise<string> { |
| const input = await this.inputEl(); |
| return await input.getProperty<string>('value'); |
| } |
| } |
| import { AsyncFactoryFn, ComponentHarness, TestElement } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| private inputEl: AsyncFactoryFn<TestElement> = this.locatorFor('input'); |
| |
| public async getComment(): Promise<string> { |
| const input = await this.inputEl(); |
| return await input.getProperty<string>('value'); |
| } |
| } |
Implement your component test harness
| import { AsyncFactoryFn, ComponentHarness, TestElement } from '@angular/cdk/testing'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| private inputEl: AsyncFactoryFn<TestElement> = this.locatorFor('input'); |
| |
| public async getComment(): Promise<string> { |
| const input = await this.inputEl(); |
| return await input.getProperty<string>('value'); |
| } |
| |
| public async setComment(comment: string): Promise<void> { |
| if (comment.trim() === '') throw Error('Comment is invalid'); |
| const input = await this._commentInput(); |
| await input.clear(); |
| await input.sendKeys(comment); |
| await input.setInputValue(comment); |
| } |
| } |
Implement your component test harness
| import { AsyncFactoryFn, ComponentHarness, HarnessPredicate, TestElement } from '@angular/cdk/testing'; |
| import { AddCommentHarnessFilters } from './add-comment-harness.filters'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| static with(options: AddCommentHarnessFilters): HarnessPredicate<AddCommentHarness> { |
| return new HarnessPredicate(AddCommentHarness, options) |
| .addOption('comment', options.comment, |
| async (harness, comment) => |
| HarnessPredicate.stringMatches(harness.getComment(), comment); |
| } |
| |
| |
| } |
| import { AsyncFactoryFn, ComponentHarness, HarnessPredicate, TestElement } from '@angular/cdk/testing'; |
| import { AddCommentHarnessFilters } from './add-comment-harness.filters'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| static with(options: AddCommentHarnessFilters): HarnessPredicate<AddCommentHarness> { |
| return new HarnessPredicate(AddCommentHarness, options) |
| .addOption('comment', options.comment, |
| async (harness, comment) => |
| HarnessPredicate.stringMatches(harness.getComment(), comment); |
| } |
| |
| |
| } |
| import { AsyncFactoryFn, ComponentHarness, HarnessPredicate, TestElement } from '@angular/cdk/testing'; |
| import { AddCommentHarnessFilters } from './add-comment-harness.filters'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| static with(options: AddCommentHarnessFilters): HarnessPredicate<AddCommentHarness> { |
| return new HarnessPredicate(AddCommentHarness, options) |
| .addOption('comment', options.comment, |
| async (harness, comment) => |
| HarnessPredicate.stringMatches(harness.getComment(), comment); |
| } |
| |
| |
| } |
| import { AsyncFactoryFn, ComponentHarness, HarnessPredicate, TestElement } from '@angular/cdk/testing'; |
| import { AddCommentHarnessFilters } from './add-comment-harness.filters'; |
| |
| export class AddCommentHarness extends ComponentHarness { |
| static hostSelector = 'app-add-comment'; |
| |
| static with(options: AddCommentHarnessFilters): HarnessPredicate<AddCommentHarness> { |
| return new HarnessPredicate(AddCommentHarness, options) |
| .addOption('comment', options.comment, |
| async (harness, comment) => |
| HarnessPredicate.stringMatches(harness.getComment(), comment); |
| } |
| |
| |
| } |
Test your component test harness
- Treat it like publishing an API
- Test your harness using a test host
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
| describe('AddComment Harness', () => { |
| let fixture: ComponentFixture<AddCommentHarnessTest>; |
| let component: AddCommentHarnessTest; |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({ |
| declarations: [AddCommentHarnessTest, AddCommentComponent], |
| imports: [ FormsModule ] |
| }); |
| |
| fixture = TestBed.createComponent(AddCommentHarnessTest); |
| component = fixture.componentInstance; |
| fixture.detectChanges(); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
| |
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
| describe('AddComment Harness', () => { |
| let fixture: ComponentFixture<AddCommentHarnessTest>; |
| let component: AddCommentHarnessTest; |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({ |
| declarations: [AddCommentHarnessTest, AddCommentComponent], |
| imports: [ FormsModule ] |
| }); |
| |
| fixture = TestBed.createComponent(AddCommentHarnessTest); |
| component = fixture.componentInstance; |
| fixture.detectChanges(); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
| |
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
| describe('AddComment Harness', () => { |
| let fixture: ComponentFixture<AddCommentHarnessTest>; |
| let component: AddCommentHarnessTest; |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({ |
| declarations: [AddCommentHarnessTest, AddCommentComponent], |
| imports: [ FormsModule ] |
| }); |
| |
| fixture = TestBed.createComponent(AddCommentHarnessTest); |
| component = fixture.componentInstance; |
| fixture.detectChanges(); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
| |
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
| describe('AddComment Harness', () => { |
| let fixture: ComponentFixture<AddCommentHarnessTest>; |
| let component: AddCommentHarnessTest; |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({ |
| declarations: [AddCommentHarnessTest, AddCommentComponent], |
| imports: [ FormsModule ] |
| }); |
| |
| fixture = TestBed.createComponent(AddCommentHarnessTest); |
| component = fixture.componentInstance; |
| fixture.detectChanges(); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| }); |
| |
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
| describe('AddComment Harness', () => { |
| let fixture: ComponentFixture<AddCommentHarnessTest>; |
| let component: AddCommentHarnessTest; |
| let loader: HarnessLoader; |
| |
| beforeEach(() => { |
| TestBed.configureTestingModule({ |
| declarations: [AddCommentHarnessTest, AddCommentComponent], |
| imports: [ FormsModule ] |
| }); |
| |
| fixture = TestBed.createComponent(AddCommentHarnessTest); |
| component = fixture.componentInstance; |
| fixture.detectChanges(); |
| loader = TestbedHarnessEnvironment.loader(fixture); |
| }); |
| |
| it('should find a comment by value', async () => { |
| const expected = 'MAX WIN'; |
| const el = await loader.getHarness(AddCommentHarness); |
| await el.setComment(expected); |
| |
| await expectAsync( |
| loader.getHarness(AddCommentHarness.with({comment: expected})) |
| ).toBeResolved(); |
| }); |
| }); |
| |
| @Component({ |
| template: ` |
| <app-add-comment (comment)="onCommented($event)"></app-add-comment> |
| ` |
| }) |
| class AddCommentHarnessTest { |
| public addedComment: string | undefined; |
| |
| public onCommented(comment: string): void { |
| this.addedComment = comment; |
| } |
| } |
ComponentHarness
locator methods scale with your component complexity
When writing components, small is winning
Recipe for winning
- Test harnesses keep UI testing cleaner and
easier to maintain
- Use
harnesses when testing with Material components
- Write test harnesses for shared components
- Test your test harness - treat it like an API!