When an Angular directive test no longer needs a host component

To test an Angular directive, you've usually needed a component that exists only for the test. It has a one-line template that puts the directive on a button, a few fields to feed its inputs, and nothing else. Angular 22.2 added TestBed.createDirective(), which puts the directive on a test element for you and hands back a fixture. I tried it on a copy-to-clipboard directive and wrote the same tests both ways.
I'll use it for new tests of directives that only work on their own element. I'll keep the host component when the test is really about the template around the directive, and I won't convert old tests. It didn't make my tests any shorter, and the reason is the most useful thing I found.
The directive and the old test
The directive goes on a button[appCopy]. It takes the text to copy as a required input, plus an optional label and a reset delay. On click it writes to the clipboard and shows a "Copied" state through a class, a data-state attribute and the aria-label. It also emits a copied output and goes back to idle after the delay. The clipboard sits behind an injection token, so the tests pass in a fake.
Here's the host component for the old style of test. Notice that every input needs its own signal field, only so a test can change it:
@Component({
imports: [CopyButton],
template: `
<button [appCopy]="text()" [label]="label()" [resetAfter]="resetAfter()"
(copied)="copiedText.push($event)">Copy</button>`,
})
class TestHost {
text = signal('npm i');
label = signal('Copy to clipboard');
resetAfter = signal(2000);
copiedText: string[] = [];
}
Five tests use it through a small setup() function. It creates the component, waits for it to settle and finds the button.
The same tests with createDirective
With createDirective, the inputs move into each test as bindings. inputBinding sets an input from a function or a signal, and outputBinding listens to an output. This is the first test, the one that clicks and checks the Copied state:
const copiedText: string[] = [];
const fixture = TestBed.createDirective(CopyButton, {
bindings: [
inputBinding('appCopy', () => 'npm i'),
outputBinding<string>('copied', (text) => copiedText.push(text)),
],
});
await fixture.whenStable();
const button = fixture.nativeElement as HTMLButtonElement;
button.click();
await fixture.whenStable();
expect(button.dataset['state']).toBe('copied');
expect(copiedText).toEqual(['npm i']);
The fixture has no setInput. To change an input halfway through a test, I passed a signal to inputBinding and called set() on it. After one more whenStable(), the directive saw the new value and copied it.
Two things caught me out. My first version clicked straight after createDirective() and failed with NG0950: Input "appCopy" is required but no value is available yet. The bindings are applied at the first change detection, not when the fixture is created, so the test has to call whenStable() or detectChanges() before it clicks. The host-component tests never showed this because setup() already waited.
The second was the tag, which Angular took from the button part of the selector without me asking. A directive with an attribute-only selector, like [appTooltip], throws "Cannot determine tag name" until you pass a tagName option.
I also read the source to see what it builds. It still makes a host component, an internal one with an empty template, and attaches your directive to its element. So the button you get is bare: no appCopy attribute, no text inside, and document.body as its parent.
Where the lines went
I expected the new file to be a lot shorter. Counting the lines that aren't blank or comments, the host-component file has 72 and the createDirective file has 73, for the same five tests.
The host component and setup() are gone, but every test now spells out its own bindings. I still like it better. Each test shows the exact inputs it uses, right where it uses them, and you don't scroll up to a template to find out what the button was given. And a new test that varies an input doesn't mean adding another field to a shared component.
What you give up is the template type check. In a host template, a typo like [resetAftr] stops the build with NG8002. inputBinding('resetAftr', ...) compiles and fails only when the test runs, with NG0315. The value isn't checked either, because inputBinding takes a plain string and a function that returns unknown. I bound the text 'soon' to the reset delay, a number input. It compiled, it ran, and the directive got a string.
Where you still want a host component
Some directives can't be tested this way at all. Structural directives are out: one that injects TemplateRef throws NG0201, since there's no <ng-template> to sit on.
The more interesting case is a directive that works with another one. I added an appCopyGroup directive for a wrapper element: when one button inside it copies, the others go back to idle. The button injects the group as optional, so under createDirective it just gets null. You can give it a fake with TestBed.overrideProvider and check that the button calls it, but that only tests the button's half of the deal.
To check that clicking the second button resets the first, I needed a template with both buttons in one group:
@Component({
imports: [CopyButton, CopyGroup],
template: `
<div appCopyGroup>
<button appCopy="npm i">Install</button>
<button appCopy="npm test">Test</button>
</div>`,
})
class TwoButtons {}
That test is about how the directive sits in a template, and a host component is the right tool for it. The same goes for a directive that reads projected content or its own children, since the element createDirective builds starts out empty.
What I'd do with existing tests
I wouldn't rewrite host-component tests that already work. You won't save lines, and you swap an error the build would have caught for one you only see when the test runs. When I'm changing a directive's spec anyway, I'd move its self-contained tests over and leave the template tests alone.
I wouldn't use a createDirective test for anything that needs a parent, projected content or child elements. Those tests end up checking a fake or an empty element, which tells you little about the real page.
The API has been stable since 22.2, so I wouldn't hold back on a shared test helper built on it. The one I'd write takes the directive's inputs as a typed object and turns them into bindings, to get the name and type check back. I haven't built it yet, and it's the first thing I'd add before using createDirective across a whole codebase.