How To Write Unit Tests For Angular Attribute Directives
Quick answer
Test an Angular attribute directive through a small host component, not by constructing the directive alone. Import the standalone directive and host into TestBed, query applied elements with By.directive, change bound inputs through the host or DOM, run change detection, and assert both the directive instance and the user-visible result.
Attribute directives do not own a template, so the most useful unit test gives them one. A small host component lets Angular exercise selector matching, input binding, dependency injection, lifecycle hooks, and the resulting DOM behavior in the same test.
Testing only new HighlightDirective(...) is faster but weaker: it can miss a
misspelled selector, a broken input alias, a missing standalone import, or a
change-detection problem. Use an isolated class test for complex pure logic;
use a host-component test for the directive contract.
Example directive under test
This standalone directive accepts an optional color. When the binding is empty, it uses a visible default.
import { Directive, ElementRef, Input, OnChanges, inject } from "@angular/core"
@Directive({
selector: "[highlight]",
standalone: true,
})
export class HighlightDirective implements OnChanges {
private readonly element = inject(ElementRef<HTMLElement>)
readonly defaultColor = "rgb(211, 211, 211)"
@Input("highlight") color = ""
ngOnChanges(): void {
this.element.nativeElement.style.backgroundColor =
this.color || this.defaultColor
}
}The directive deliberately puts the result in an inline style. That gives the test a deterministic assertion without depending on a browser stylesheet or layout engine.
Build a focused host component
The host covers four cases in one template: an explicit input, a missing input, a live input binding, and an element without the directive.
import { Component } from "@angular/core"
@Component({
standalone: true,
imports: [HighlightDirective],
template: `
<h2 highlight="yellow">Explicit color</h2>
<h2 highlight>Default color</h2>
<input #box [highlight]="box.value" value="cyan" />
<h2 data-testid="bare-heading">No directive</h2>
`,
})
class TestHostComponent {}The bare heading is important. It proves the behavior comes from the directive rather than a broad CSS rule or test setup that affects every heading.
Configure TestBed for standalone Angular
Standalone declarations belong in imports. Putting them in declarations
causes the common error that a standalone component or directive cannot be
declared in an NgModule.
import { ComponentFixture, TestBed } from "@angular/core/testing"
import { DebugElement } from "@angular/core"
import { By } from "@angular/platform-browser"
describe("HighlightDirective", () => {
let fixture: ComponentFixture<TestHostComponent>
let highlighted: DebugElement[]
beforeEach(() => {
fixture = TestBed.configureTestingModule({
imports: [TestHostComponent],
}).createComponent(TestHostComponent)
fixture.detectChanges()
highlighted = fixture.debugElement.queryAll(
By.directive(HighlightDirective)
)
})
// Tests follow below.
})By.directive(HighlightDirective) is stronger than a loose CSS selector. It
finds elements whose Angular injector contains that directive instance.
Assert explicit and default behavior
Check the rendered result and, where useful, the directive instance that owns the default.
it("applies the directive only where selected", () => {
expect(highlighted.length).toBe(3)
const bareHeading = fixture.debugElement.query(
By.css('[data-testid="bare-heading"]')
).nativeElement as HTMLHeadingElement
expect(bareHeading.style.backgroundColor).toBe("")
})
it("uses the explicit input color", () => {
const heading = highlighted[0].nativeElement as HTMLHeadingElement
expect(heading.style.backgroundColor).toBe("yellow")
})
it("falls back to the directive default", () => {
const directive = highlighted[1].injector.get(HighlightDirective)
const heading = highlighted[1].nativeElement as HTMLHeadingElement
expect(heading.style.backgroundColor).toBe(directive.defaultColor)
})If a directive adds classes or ARIA attributes instead of inline styles, assert those user-visible outcomes. Avoid asserting a private field when the DOM can prove the behavior more directly.
Test a bound input change
Changing the input element's JavaScript value does not automatically notify Angular. Dispatch the same event a user interaction would produce, then run change detection.
it("updates when the bound input value changes", () => {
const inputDebugElement = highlighted[2]
const input = inputDebugElement.nativeElement as HTMLInputElement
expect(input.style.backgroundColor).toBe("cyan")
input.value = "green"
input.dispatchEvent(new Event("input"))
fixture.detectChanges()
expect(input.style.backgroundColor).toBe("green")
})If your directive uses a host-component property rather than an input element,
set that property on fixture.componentInstance and call
fixture.detectChanges() before the assertion.
Failure cases worth adding
- Empty,
null, or invalid input if the directive accepts external data. - Repeated input changes to catch stale state in
ngOnChangesor an effect. - Cleanup in
ngOnDestroywhen the directive registers listeners, observers, timers, or subscriptions. - Keyboard and focus behavior for interactive directives.
- Server-rendering safety when the directive touches
window,document, or a browser-only API. - A control element without the directive to detect selector leakage.
Do not use getComputedStyle unless the behavior genuinely depends on a loaded
stylesheet and the test environment provides one. For most directive tests,
classes, attributes, properties, and inline styles are more stable contracts.
When a class-only test is appropriate
A class-only test is useful when the directive contains substantial pure logic, such as parsing a configuration or mapping an input to a state. Extract that logic into a plain function and test it without Angular. Keep at least one host test to prove the selector, bindings, and DOM integration still work together.
Angular's official guide uses the same host-component pattern, TestBed, and
By.directive queries: Testing attribute directives.
Related: continue with the Angular unit testing with Jasmine and Karma guide, then use the Angular code coverage guide to enforce meaningful branch and line thresholds in CI.
Key takeaways
- •A host component exercises selector matching, input binding, dependency injection, lifecycle hooks, and DOM effects together.
- •Use By.directive to find elements that actually have the directive; keep a bare control element to catch accidental over-application.
- •For standalone Angular code, put the directive and host component in TestBed imports rather than declarations.
- •Drive input changes through bindings or DOM events, then call fixture.detectChanges before asserting the result.
- •Assert behavior users observe as well as directive internals; implementation-only tests can pass while the template integration is broken.