Angular Material 3 Component Testing with Harnesses
Angular Material's Component Test Harnesses provide a stable, semantic API for testing Material components without relying on CSS classes or internal DOM structure. Since Material 3 (M3) changed class names and element structure significantly from M2, harnesses are the only reliable way to test Material components across versions.
Key Takeaways
Harnesses abstract the DOM. You interact with MatButtonHarness, MatSelectHarness, etc. instead of querying .mat-button or [mat-button] — which changed in M3.
HarnessLoader is the entry point. Get it with TestbedHarnessEnvironment.loader(fixture). Then use loader.getHarness(MatButtonHarness) to get typed, stable access to any Material component.
Harness methods are async. await harness.click(), await harness.getValue() etc. return Promises. Always await them.
Use MatSelectHarness for dropdowns, not <select>. Material's select renders a custom overlay — you can't interact with native <option> elements. The harness handles overlay open/close.
Dialog testing requires MatDialogHarness. Dialogs render in a CDK overlay portal outside the fixture. Use DocumentRootHarnessEnvironment.loader(document) to reach them.
Why Harnesses Over CSS Selectors
Angular Material 3 changed the internal structure of many components. Buttons that were <button class="mat-button"> in M2 became <button mat-button> with different shadow DOM internals in M3. CSS-based tests broke.
Harnesses solve this:
// FRAGILE — breaks between M2 and M3
const button = fixture.nativeElement.querySelector('.mat-mdc-button');
// STABLE — works across M2 and M3
const button = await loader.getHarness(MatButtonHarness);
await button.click();Setup
npm install @angular/material @angular/cdkimport { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { HarnessLoader } from '@angular/cdk/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatTableHarness } from '@angular/material/table/testing';Testing MatButton
// submit-form.component.ts
@Component({
selector: 'app-submit-form',
standalone: true,
imports: [MatButtonModule, ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<button mat-raised-button type="submit" [disabled]="form.invalid">
Submit
</button>
<button mat-stroked-button type="button" (click)="cancel()">
Cancel
</button>
</form>
`
})
export class SubmitFormComponent {
form = new FormGroup({ name: new FormControl('', Validators.required) });
submitted = false;
cancelled = false;
submit() { this.submitted = true; }
cancel() { this.cancelled = true; }
}import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { HarnessLoader } from '@angular/cdk/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('SubmitFormComponent', () => {
let fixture: ComponentFixture<SubmitFormComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SubmitFormComponent, NoopAnimationsModule]
}).compileComponents();
fixture = TestBed.createComponent(SubmitFormComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
it('submit button is disabled when form is invalid', async () => {
const submitBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Submit' }));
expect(await submitBtn.isDisabled()).toBeTrue();
});
it('submit button is enabled when form is valid', async () => {
fixture.componentInstance.form.setValue({ name: 'Alice' });
fixture.detectChanges();
const submitBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Submit' }));
expect(await submitBtn.isDisabled()).toBeFalse();
});
it('clicking cancel triggers cancel()', async () => {
const cancelBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Cancel' }));
await cancelBtn.click();
expect(fixture.componentInstance.cancelled).toBeTrue();
});
});Testing MatInput and MatFormField
// login.component.ts
@Component({
selector: 'app-login',
standalone: true,
imports: [MatInputModule, MatFormFieldModule, ReactiveFormsModule],
template: `
<form [formGroup]="form">
<mat-form-field>
<mat-label>Email</mat-label>
<input matInput formControlName="email" type="email" data-testid="email-input" />
<mat-error *ngIf="form.get('email')?.hasError('required')">Email is required</mat-error>
</mat-form-field>
</form>
`
})
export class LoginComponent {
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email])
});
}import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormFieldHarness } from '@angular/material/form-field/testing';
describe('LoginComponent', () => {
let fixture: ComponentFixture<LoginComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent, NoopAnimationsModule]
}).compileComponents();
fixture = TestBed.createComponent(LoginComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
});
it('accepts email input', async () => {
const input = await loader.getHarness(MatInputHarness);
await input.setValue('test@example.com');
expect(await input.getValue()).toBe('test@example.com');
expect(fixture.componentInstance.form.get('email')!.value).toBe('test@example.com');
});
it('shows error when email is empty and touched', async () => {
const formField = await loader.getHarness(MatFormFieldHarness);
const input = await loader.getHarness(MatInputHarness);
await input.focus();
await input.blur(); // triggers touched state
fixture.detectChanges();
const errors = await formField.getTextErrors();
expect(errors).toContain('Email is required');
});
});Testing MatSelect
// filter.component.ts
@Component({
selector: 'app-filter',
standalone: true,
imports: [MatSelectModule, MatFormFieldModule, ReactiveFormsModule],
template: `
<mat-form-field>
<mat-label>Status</mat-label>
<mat-select formControl="status">
<mat-option value="all">All</mat-option>
<mat-option value="active">Active</mat-option>
<mat-option value="inactive">Inactive</mat-option>
</mat-select>
</mat-form-field>
`
})
export class FilterComponent {
status = new FormControl('all');
}import { MatSelectHarness } from '@angular/material/select/testing';
describe('FilterComponent', () => {
it('selects a status option', async () => {
await TestBed.configureTestingModule({
imports: [FilterComponent, NoopAnimationsModule, ReactiveFormsModule]
}).compileComponents();
const fixture = TestBed.createComponent(FilterComponent);
const loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
const select = await loader.getHarness(MatSelectHarness);
// Open the dropdown
await select.open();
expect(await select.isOpen()).toBeTrue();
// Get all options
const options = await select.getOptions();
expect(options.length).toBe(3);
// Select "Active"
await select.clickOptions({ text: 'Active' });
expect(await select.getValueText()).toBe('Active');
expect(fixture.componentInstance.status.value).toBe('active');
});
});Testing MatDialog
Dialogs render outside the component fixture in a CDK overlay. Use DocumentRootHarnessEnvironment:
// confirm-dialog.component.ts
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [MatDialogModule, MatButtonModule],
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>{{ data.message }}</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="false">Cancel</button>
<button mat-raised-button [mat-dialog-close]="true" color="warn">Confirm</button>
</mat-dialog-actions>
`
})
export class ConfirmDialogComponent {
constructor(public data: { title: string; message: string }) {}
}import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { HarnessLoader } from '@angular/cdk/testing';
import { DocumentRootHarnessEnvironment } from '@angular/cdk/testing/testbed';
describe('MatDialog', () => {
let dialog: MatDialog;
let rootLoader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MatDialogModule, NoopAnimationsModule, ConfirmDialogComponent]
}).compileComponents();
dialog = TestBed.inject(MatDialog);
// Dialog overlays are in the document root, not the fixture
rootLoader = DocumentRootHarnessEnvironment.loader(document.body);
});
it('opens dialog and confirms', async () => {
let result: boolean | undefined;
dialog.open(ConfirmDialogComponent, {
data: { title: 'Delete?', message: 'This action is irreversible.' }
}).afterClosed().subscribe(r => result = r);
const dialogHarness = await rootLoader.getHarness(MatDialogHarness);
expect(await dialogHarness.getTitleText()).toBe('Delete?');
const confirmBtn = await dialogHarness.getHarness(
MatButtonHarness.with({ text: 'Confirm' })
);
await confirmBtn.click();
expect(result).toBeTrue();
});
it('opens dialog and cancels', async () => {
let result: boolean | undefined;
dialog.open(ConfirmDialogComponent, {
data: { title: 'Delete?', message: 'Are you sure?' }
}).afterClosed().subscribe(r => result = r);
const dialogHarness = await rootLoader.getHarness(MatDialogHarness);
const cancelBtn = await dialogHarness.getHarness(
MatButtonHarness.with({ text: 'Cancel' })
);
await cancelBtn.click();
expect(result).toBeFalse();
});
});Testing MatTable
// users-table.component.ts
@Component({
selector: 'app-users-table',
standalone: true,
imports: [MatTableModule],
template: `
<table mat-table [dataSource]="users">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let user">{{ user.name }}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>Email</th>
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
`
})
export class UsersTableComponent {
users = [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' }
];
displayedColumns = ['name', 'email'];
}import { MatTableHarness } from '@angular/material/table/testing';
describe('UsersTableComponent', () => {
it('renders correct rows and cells', async () => {
await TestBed.configureTestingModule({
imports: [UsersTableComponent, NoopAnimationsModule]
}).compileComponents();
const fixture = TestBed.createComponent(UsersTableComponent);
const loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
const table = await loader.getHarness(MatTableHarness);
const rows = await table.getRows();
expect(rows.length).toBe(2);
const cells = await rows[0].getCells();
const cellTexts = await Promise.all(cells.map(c => c.getText()));
expect(cellTexts).toEqual(['Alice', 'alice@example.com']);
});
it('has correct header cells', async () => {
await TestBed.configureTestingModule({
imports: [UsersTableComponent, NoopAnimationsModule]
}).compileComponents();
const fixture = TestBed.createComponent(UsersTableComponent);
const loader = TestbedHarnessEnvironment.loader(fixture);
fixture.detectChanges();
const table = await loader.getHarness(MatTableHarness);
const headerRows = await table.getHeaderRows();
const headerCells = await headerRows[0].getCells();
const headers = await Promise.all(headerCells.map(c => c.getText()));
expect(headers).toEqual(['Name', 'Email']);
});
});Creating Custom Harnesses
For your own components that others will test:
// badge.harness.ts
import { ComponentHarness } from '@angular/cdk/testing';
export class BadgeHarness extends ComponentHarness {
static hostSelector = 'app-badge';
private getLabel = this.locatorFor('[data-testid="badge-label"]');
private getCount = this.locatorFor('[data-testid="badge-count"]');
async getLabel(): Promise<string> {
return (await this.getLabel()).text();
}
async getCount(): Promise<number> {
const text = await (await this.getCount()).text();
return parseInt(text, 10);
}
async isVisible(): Promise<boolean> {
const count = await this.getCount();
return count > 0;
}
}// Using your custom harness
const badge = await loader.getHarness(BadgeHarness);
expect(await badge.getCount()).toBe(3);
expect(await badge.isVisible()).toBeTrue();Common Pitfalls
Missing NoopAnimationsModule Material components use animations. In tests, import NoopAnimationsModule instead of BrowserAnimationsModule to disable them — otherwise tests time out waiting for animation completion.
Using fixture loader for dialogs Dialogs render in a CDK overlay outside the component fixture. Always use DocumentRootHarnessEnvironment.loader(document.body) for dialogs, snackbars, and menus.
Forgetting to await harness calls All harness interactions return Promises. Missing await causes the test to assert before the interaction completes.
Querying Material CSS classes directly M3 class names are different from M2. Use harnesses or data-testid attributes on your own elements, not Material's internal classes.
HelpMeTest for Material Components
Harnesses work well for unit tests, but don't catch:
- Overlay positioning bugs (dialog off-screen)
- Accessibility issues with focus management
- Material theme rendering
- Mobile touch interactions with dropdowns
HelpMeTest tests Material components in a real browser, catching visual and interaction bugs that harnesses can't reach:
When the user opens the status dropdown
Then all three options are visible
And selecting "Active" filters the table to show only active users
And the dropdown closes after selectionSummary
Angular Material 3 testing with harnesses:
- Import
TestbedHarnessEnvironment.loader(fixture)for component-scoped tests - Use
DocumentRootHarnessEnvironment.loader(document.body)for dialogs and overlays - Always
awaitharness interactions - Import
NoopAnimationsModulein every Material test - Use
MatButtonHarness.with({ text: '...' })to select specific buttons MatSelectHarnesshandles the M3 dropdown overlay for you — no native<select>interaction needed- Create custom harnesses with
ComponentHarnessfor your own components