Testing Angular Pipes, Directives and Services with HttpTestingController [2026]
Pipes, directives and services are the parts of an Angular app that get reused the most, which makes them the parts where a missing test costs the most. They're also the parts where tests are cheapest to write, as long as you pick the right technique: a pure pipe is a function, a directive needs a host to live on, and a service that talks to a backend needs a fake backend you can control precisely.
This is lesson 11.4 of the Angular Tutorial. It follows lesson 11.3 on component tests with TestBed, and it refers back to pipes, directives and HttpClient from earlier modules. You'll learn how to unit test pure pipes, attribute and structural directives through small host components, services that use inject(), and HTTP code with provideHttpClientTesting() and HttpTestingController.
Matching the technique to the building block #
| Building block | Technique | Needs TestBed? |
|---|---|---|
| Pure pipe with no dependencies | Call transform() on a new instance |
No |
Pipe or service that uses inject() |
Create it inside TestBed.runInInjectionContext() or via TestBed.inject() |
Yes |
| Attribute directive | Render a tiny host component that uses it; assert on the host element | Yes |
| Structural directive | Host component with the directive on an <ng-template> or with * syntax; assert on what's rendered |
Yes |
| HTTP service | provideHttpClient() + provideHttpClientTesting(), drive requests with HttpTestingController |
Yes |
The pattern is the same throughout: test the public behaviour (what transform returns, what the host element looks like, what request goes out) rather than internals.
Pure pipes: test them as functions #
A pure pipe without dependencies is a class with one method. You don't need Angular to test it:
@Pipe({ name: 'initials' })
export class InitialsPipe implements PipeTransform {
transform(name: string | null | undefined, max = 2): string {
if (!name) return '';
return name
.trim()
.split(/\s+/)
.slice(0, max)
.map(part => part[0]!.toUpperCase())
.join('');
}
}
import { describe, it, expect } from 'vitest';
describe('InitialsPipe', () => {
const pipe = new InitialsPipe();
it.each([
['Sam Rivera', 2, 'SR'],
[' maria de la cruz ', 2, 'MD'],
['maria de la cruz', 3, 'MDL'],
['', 2, ''],
[null, 2, ''],
])('initials(%j, %i) = %j', (input, max, expected) => {
expect(pipe.transform(input, max)).toBe(expected);
});
});
it.each is a good fit for pipes: one row per edge case keeps the test short and makes gaps obvious. Cover null and undefined explicitly, since template bindings pass them as soon as data is still loading.
If the pipe calls inject(), new throws because there's no injection context. Take a pipe that reads LOCALE_ID:
@Pipe({ name: 'localNumber' })
export class LocalNumberPipe implements PipeTransform {
private locale = inject(LOCALE_ID);
transform(value: number): string {
return new Intl.NumberFormat(this.locale).format(value);
}
}
Create it inside an injection context instead:
it('formats with the configured locale', () => {
TestBed.configureTestingModule({ providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }] });
const pipe = TestBed.runInInjectionContext(() => new LocalNumberPipe());
expect(pipe.transform(1500.5)).toBe('1.500,5');
});
Add one template-level test only if the pipe's behaviour depends on how it's used in a template, for example its interaction with async. Otherwise the function tests are enough.
Attribute directives: test through a host component #
A directive has no template of its own; its behaviour only exists on a host element. So create a small component in the spec that uses it the way your app does:
@Directive({
selector: '[appHighlight]',
host: { '[style.backgroundColor]': 'active() ? color() : null', '(mouseenter)': 'active.set(true)', '(mouseleave)': 'active.set(false)' },
})
export class Highlight {
color = input('yellow', { alias: 'appHighlight' });
protected active = signal(false);
}
@Component({
imports: [Highlight],
template: `
<p appHighlight="lightblue">Custom</p>
<p appHighlight>Default</p>
<p>Plain</p>
`,
})
class Host {}
describe('Highlight', () => {
let fixture: ComponentFixture<Host>;
let highlighted: DebugElement[];
beforeEach(async () => {
fixture = TestBed.createComponent(Host);
await fixture.whenStable();
highlighted = fixture.debugElement.queryAll(By.directive(Highlight));
});
it('applies to exactly the elements that use it', () => {
expect(highlighted.length).toBe(2);
});
it('uses the bound colour on hover', async () => {
const p = highlighted[0];
p.triggerEventHandler('mouseenter');
await fixture.whenStable();
expect(p.nativeElement.style.backgroundColor).toBe('lightblue');
p.triggerEventHandler('mouseleave');
await fixture.whenStable();
expect(p.nativeElement.style.backgroundColor).toBe('');
});
it('falls back to the default colour', async () => {
highlighted[1].triggerEventHandler('mouseenter');
await fixture.whenStable();
expect(highlighted[1].nativeElement.style.backgroundColor).toBe('yellow');
});
});
This is where DebugElement earns its place:
| API | Returns | Use it for |
|---|---|---|
debugElement.query(By.css('p')) |
First matching DebugElement |
Finding an element by selector |
debugElement.queryAll(By.directive(Highlight)) |
Every element where the directive is applied | Asserting where a directive matched |
de.injector.get(Highlight) |
The directive instance on that element | Reading directive state when the DOM can't show it |
de.triggerEventHandler('mouseenter', evt) |
Calls the bound listener | Firing Angular-bound events without a real DOM event |
de.nativeElement |
The underlying DOM element | Asserting on attributes, styles, text |
triggerEventHandler invokes the listeners Angular registered (template and host listeners). It doesn't dispatch a real DOM event, so it won't trigger native behaviour such as a form submit or a CSS :hover. When you need real browser behaviour, dispatch an event on nativeElement instead.
Structural directives: assert on what gets rendered #
A structural directive decides whether, or how many times, a template is stamped out. Test the rendered result for each input value:
@Directive({ selector: '[appIfRole]' })
export class IfRole {
private tpl = inject<TemplateRef<unknown>>(TemplateRef);
private vcr = inject(ViewContainerRef);
private auth = inject(AuthStore);
role = input.required<string>({ alias: 'appIfRole' });
constructor() {
effect(() => {
this.vcr.clear();
if (this.auth.roles().includes(this.role())) this.vcr.createEmbeddedView(this.tpl);
});
}
}
@Component({
imports: [IfRole],
template: `<button *appIfRole="'admin'" class="delete">Delete</button>`,
})
class Host {}
it('renders only for users with the role and reacts to changes', async () => {
const roles = signal<string[]>(['viewer']);
TestBed.configureTestingModule({ providers: [{ provide: AuthStore, useValue: { roles } }] });
const fixture = TestBed.createComponent(Host);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.delete')).toBeNull();
roles.set(['viewer', 'admin']);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.delete')).not.toBeNull();
});
Faking AuthStore with an object holding a real signal is a useful trick: you control the state directly, and the directive's effect reacts exactly as it would to the real store.
Services that use inject() #
A service that uses inject() can't be created with new outside an injection context, and that's fine: let TestBed build it, and replace its dependencies with providers:
@Injectable({ providedIn: 'root' })
export class Checkout {
private cart = inject(CartStore);
private payments = inject(PaymentGateway);
async pay() {
if (this.cart.isEmpty()) throw new Error('Cart is empty');
return this.payments.charge(this.cart.total());
}
}
describe('Checkout', () => {
const charge = vi.fn(async (amount: number) => ({ ok: true, amount }));
beforeEach(() => {
charge.mockClear();
TestBed.configureTestingModule({
providers: [
{ provide: PaymentGateway, useValue: { charge } },
{ provide: CartStore, useValue: { isEmpty: signal(false), total: signal(42) } },
],
});
});
it('charges the cart total', async () => {
await expect(TestBed.inject(Checkout).pay()).resolves.toEqual({ ok: true, amount: 42 });
expect(charge).toHaveBeenCalledOnce();
expect(charge).toHaveBeenCalledWith(42);
});
});
Fake only the boundaries (payment gateway, storage, the clock), and keep the real versions of simple collaborators when they're cheap. A test full of mocks verifies your mocks, not your code. TestBed resets between tests automatically, so each test gets fresh instances.
HTTP services with HttpTestingController #
For code that uses HttpClient, Angular replaces the real backend with a testing backend you control:
@Injectable({ providedIn: 'root' })
export class ProductsApi {
private http = inject(HttpClient);
search(q: string) {
return this.http.get<Product[]>('/api/products', { params: { q } });
}
}
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { firstValueFrom } from 'rxjs';
describe('ProductsApi', () => {
let http: HttpTestingController;
let api: ProductsApi;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()], // order matters
});
http = TestBed.inject(HttpTestingController);
api = TestBed.inject(ProductsApi);
});
afterEach(() => http.verify()); // fails if any request was not handled
it('sends the query and returns the results', async () => {
const result = firstValueFrom(api.search('lamp'));
const req = http.expectOne(r => r.url === '/api/products');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('q')).toBe('lamp');
req.flush([{ id: 'p1', name: 'Desk lamp' }]);
expect(await result).toEqual([{ id: 'p1', name: 'Desk lamp' }]);
});
it('surfaces server errors', async () => {
const result = firstValueFrom(api.search('x'));
http.expectOne(r => r.url === '/api/products').flush('Boom', { status: 500, statusText: 'Server Error' });
await expect(result).rejects.toMatchObject({ status: 500 });
});
});
The key points:
- Provide
provideHttpClient()beforeprovideHttpClientTesting(). The testing provider replaces the backend that the first one sets up; in the other order, tests break. - Include your interceptors with
provideHttpClient(withInterceptors([...]))when you want them under test, exactly as inapp.config.ts. expectOnefails if zero or several requests match; usematch()when several are expected andexpectNone()to prove a request did not happen (useful for caching, see lesson 10.4).flush(body, { status, statusText })simulates server responses, andreq.error(new ProgressEvent('error'))simulates a network failure.verify()inafterEachcatches unexpected extra requests, such as a duplicated call or a retry you didn't intend.
Because the request is only sent when someone subscribes, start the subscription (here with firstValueFrom) before calling expectOne.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
NG0203 when calling new MyPipe() |
Pipe uses inject() |
Create it inside TestBed.runInInjectionContext() |
expectOne finds no request |
Observable never subscribed | Subscribe (or firstValueFrom) before expectOne |
Real HTTP requests or NullInjectorError: HttpClient |
Testing providers missing or in the wrong order | provideHttpClient() then provideHttpClientTesting() |
| Directive test passes but app breaks | Directive used differently in the host than in the app | Mirror real usage in the host template, including aliases |
triggerEventHandler doesn't submit the form |
It calls Angular listeners, not native behaviour | Dispatch a real event on nativeElement |
verify() fails in an unrelated test |
A request from a previous test was left open | Handle every request in its own test; keep verify() in afterEach |
What's next #
Lesson 11.5 closes Module 11 by stepping outside unit tests: Playwright, Cypress and WebdriverIO for end-to-end testing, how ng e2e sets each one up, which tests belong in E2E and which belong in unit or component tests, and how to keep E2E suites fast and reliable in CI.
Try it yourself #
Pick one custom pipe and one HTTP service in your app. Give the pipe an it.each table that includes null, an empty string and one unusual input. For the service, write a test that flushes a 500 error and checks that the error reaches the caller in the shape your components expect.
search_documentationHttpClient observables are cold: api.search('lamp') only builds the request, and nothing is sent until something subscribes. Your test never subscribes, so HttpTestingController has nothing to match. Start the request first, for example const result = firstValueFrom(api.search('lamp')), then call expectOne, flush() the response, and await result. Also check the matcher: a string passed to expectOne('/api/products') is compared with the full URL including query params (urlWithParams), so a function matcher such as r => r.url === '/api/products' plus a separate assertion on req.request.params.get('q') is more robust. Keep http.verify() in afterEach so a stray request fails loudly.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.
1 comment



Pingback: Angular Component Testing: TestBed and Harnesses [2026]