The service layer of most Angular applications is HTTP-based. For testing such services, doing real HTTP calls in a test scenario would prove to be tricky, slow, and hard to maintain.
Going that route would involve spinning a real HTTP test server, which would immediately turn service layer tests into full-blown integration tests, rather than the simple unit tests they should be.
Mocking HTTP requests by patching browser APIs directly (e.g. overriding
XMLHttpRequest or fetch) is another way of doing it, but it is fragile and Angular already gives you a much better solution out of the box.
To solve this problem, Angular provides a built-in, batteries-included solution for testing HTTP services — HttpTestingController.
In this guide we will walk you through everything you need to know to use this test utility to write clean, reliable, and fast unit tests for all your HTTP-based services.
Notice that all code will be using the new Vitest syntax, and not Jasmine.
Table of Contents
This post will cover the following topics:
- What is Angular HTTP Mocking and why it matters
- Example of an Angular HTTP Service to Test
- Setting Up HTTP Testing with provideHttpClientTesting
- Using verify() to Avoid Unexpected HTTP Requests
- Your First Angular HTTP Mock Test
- Asserting HTTP Requests with predicates instead of Urls
- Testing Error Handling in HTTP Scenarios (regular vs network errors)
- Mocking HTTP PUT and Other Mutating Requests
- Conclusions and Key Takeaways
So without further ado, let's get started with our Angular HTTP Mocking deep dive.
What is Angular HTTP Mocking and why it matters
When you write a unit test for an Angular HTTP-based service, you don't want any real network traffic at all.
Instead, you want to:
- Control the HTTP response — return manually from inside the test whatever data the test needs
- Control timing — resolve responses synchronously from within the test programmatically
- Assert the request itself — verify that your service constructed the HTTP request correctly: correct URL, method, headers and body
- Avoid unexpected requests — confirm no unexpected HTTP requests slipped through
Angular's HttpTestingController gives you all of that and more.
It works by replacing the real HTTP backend (the one that actually talks to the network) with an in-memory "backend" that intercepts requests and lets you respond to them manually from within your test.
The key insight is: HttpTestingController does not mock HttpClient, at all!
Instead, it replaces the transport layer underneath it.
Your service code runs exactly as it would in production — it calls HttpClient methods, uses HttpParams, processes responses — but no network requests are made.
Notice the following caveat:
You can only benefit from
HttpTestingControllerif you build your services aroundHttpClient.
That alone is already a good reason to use HttpClient instead of something like fetch().
Also worth mentioning:
Worried that
HttpClientis Observable-based and you don't want to use Observables? We will show how to handle that too.
Example of an Angular HTTP Service to Test
Here is the service about which we will build our test suite around.
It covers some of the most common HTTP patterns you'll ever encounter in practice:
- a GET by id
- a PUT (save)
- and a GET with multiple query parameters
Here is the Angular HTTP service that we will be testing:
@Injectable({
providedIn: 'root'
})
export class CoursesService {
private http = inject(HttpClient);
async findCourseById(
courseId: number): Promise<Course> {
return firstValueFrom(
this.http.get<Course>(
`/api/courses/${courseId}`)
);
}
async saveCourse(courseId: number,
changes: Partial<Course>): Promise<Course> {
return firstValueFrom(
this.http.put<Course>(
`/api/courses/${courseId}`,
changes)
);
}
async findLessons(
courseId: number,
filter = '',
sortOrder = 'asc',
pageNumber = 0,
pageSize = 3
): Promise<Lesson[]> {
const params = new HttpParams()
.set('courseId', courseId.toString())
.set('filter', filter)
.set('sortOrder', sortOrder)
.set('pageNumber', pageNumber.toString())
.set('pageSize', pageSize.toString());
const res = await firstValueFrom(
this.http.get<{
payload: Lesson[]
}>(`/api/lessons`, { params })
);
return res.payload;
}
}
A few things to note about this HTTP service before we start testing it:
- it uses
HttpClient, which is the pre-requisite for usingHttpTestingController HttpClientis Observable-based, but we want our service layer to be Promise-based and compatible with the async/await syntax which makes async development super convenient, almost synchronous-like.- To convert Observables to Promises, we are using
firstValueFrom()which is a standard Angular RxJs interop utility - The use of
HttpClientis well justified even if we don't use Observables, for two main reasons: the ability to easily mock Http requests in tests and the ability to use Angular Http interceptors - everything else in this article regarding the use of
HttpTestingControlleris independent of the API of the service layer: Promises or Observables both work well, as long as our service internally usesHttpClient
Setting Up HTTP Testing with provideHttpClientTesting
To test the CoursesService, let's go ahead and let's set up a test suite for it:
describe('CoursesService', () => {
let service: CoursesService;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
});
service = TestBed.inject(CoursesService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTesting.verify();
});
});
Let's break down what we just did here:
- we are setting up a mini Angular runtime environment before each test, that only contains an instance of
CoursesServiceand it's direct dependencies - notice that we are providing an actual instance of
HttpClientviaprovideHttpClient(). We are not mocking it. - But then on top of it, we are then calling
provideHttpClientTesting(). What this does that it replaces the defaultXMLHttpRequesttransport layer used byHttpClientwith a mock-based implementation
So that's it for our test suite setup.
But what about that funny-looking verify() call, what's up with that?
Using verify() to Avoid Unexpected HTTP Requests
Regarding the afterEach block in our test suite:
afterEach(() => {
httpTesting.verify();
});
This call to verify() checks that no HTTP requests were made during the test that went totally unhandled and unaccounted for in our tests.
As you will see, during our HTTP tests one of the main things that we will do is to use HttpTestingController to assert that certain HTTP requests were made, with a given url, body, params, headers, etc.
If at the end of the test, certain requests remain unchecked, that could be the sign of a bug.
Consider this scenario: a service method accidentally calls two endpoints instead of one (due to a bug).
Without verify(), your test passes as long as the request you care about is found. But that extra unintended request would still be made.
So the bug would not get caught.
With verify() on the other hand, the extra accidental request gets caught and the test will fail.
Always put
verify()inafterEach, not inside individual tests. This way you can't accidentally forget it on a single test.
Your First Angular HTTP Mock Test
Let's start by testing findCourseById, which is a plain HTTP GET request.
Assume the following mock data is used:
const MOCK_COURSE: Course = {
id: 12,
title: 'Angular For Beginners',
description: 'Learn Angular from scratch.',
iconUrl: 'https://example.com/icon.png',
category: 'BEGINNER',
lessonsCount: 10
};
Here is the complete test with some comments, and then we'll break it down line by line:
it('should retrieve a course by id', async () => {
// 1. Trigger the HTTP request
// do NOT "await" yet, as that would hang
// and timeout the test
const coursePromise = service.findCourseById(12);
// 2. Assert that exactly one request
// was made to this exact URL
const req = httpTesting.expectOne('/api/courses/12');
// 3. Assert request method
expect(req.request.method).toBe('GET');
// 4. Trigger response to GET with mock data
req.flush(MOCK_COURSE);
// 5. Only now await the resolved value
const course = await coursePromise;
expect(course.id).toBe(12);
expect(course.title).toBe('Angular For Beginners');
});
Let's now break this down line by line:
Step 1 — launch the request without await. service.findCourseById(12) triggers the HTTP call and returns a pending Promise.
The request is now sitting in the testing controller's queue, waiting to be validated and flushed.
If you await here, the test would hang forever, as the returned Promise can only resolve after we provide a mock value for the HTTP calls.
And we can only do that if the code execution continues after calling the service, as we will see.
Step 2 — expectOne locates the pending request by URL and confirms that exactly one such request exists, with that specific url.
If zero or more than one request matches, the test throws immediately. So this instruction also acts as a test assertion.
Step 3 — req.request lets you inspect the outgoing request before providing a response. Here we assert it is indeed a GET.
Step 4 — flush provides the response body. The testing backend resolves the Observable, firstValueFrom resolves the Promise.
Step 5 - is now safe to await because the response has already been provided. The Promise is already resolved, so await returns immediately with the course value.
If we had called await on that promise at any moment before flush, the test would have hung.
Asserting HTTP Requests with predicates instead of Urls
As an alternative, expectOne we also take a filter function to match a request in a more flexible way, instead of just a plain string.
Let's test the findLessons() method, and use expectOne with a filter function:
it('should find lessons by query', async () => {
const promise = service.findLessons(
12, 'filter-text', 'desc', 2, 10);
const req = httpTesting.expectOne(
req => req.url === '/api/lessons');
const params = req.request.params;
expect(params.get('courseId')).toBe('12');
expect(params.get('filter')).toBe('filter-text');
expect(params.get('sortOrder')).toBe('desc');
expect(params.get('pageNumber')).toBe('2');
expect(params.get('pageSize')).toBe('10');
const mockLessons = {
payload: [{
id: 12,
description: "Lesson 1"
}]
};
req.flush(mockLessons);
const result = await promise;
expect(result).toBe(mockLessons.payload);
})
This filter function comes in especially handy in situations such as this one, when the full url is complex due to the presence of query parameters.
Notice that besides filtering on the url, we can also filter on the request headers and body as well.
Let's now talk about testing different types of error scenarios.
Angular Testing In Depth Course
If you are enjoying the teaching style in this article, check out the free sample video lessons available as part of the Angular Testing In Depth (Signals Edition) course:
And now, back to the article.
Testing Error Handling in HTTP Scenarios
We can also use the HttpTestingController to simulate error responses, by explicitly setting an error status code.
Here is how we can simulate a typical 404 Not Found scenario:
it('should reject if the server returns 404', async () => {
const coursePromise = service.findCourseById(999);
const req = httpTesting.expectOne(
'/api/courses/999');
req.flush('Course not found', {
status: 404,
statusText: 'Not Found'
});
return expect(coursePromise).rejects.toThrow();
});
Notice that this last test simulates a scenario when the server actually provided a response.
It's also possible to simulate other more critical scenarios like network failure:
it('should handle network error ', async () => {
const coursePromise = service.findCourseById(1);
const req = httpTesting.expectOne('/api/courses/1');
req.error(new ProgressEvent('network error'));
return expect(coursePromise).rejects.toThrow();
})
Notice that req.error() takes a ProgressEvent — the same low-level browser event that XMLHttpRequest fires when a connection drops, DNS fails, or a CORS preflight is rejected.
There is no HTTP response involved at all: the request simply never completes.
Mocking HTTP PUT and Other Mutating Requests
For testing PUT requests, the exact same logic applies as before.
The only difference is the method of the HTTP request, but the overall test strategy is the same.
For completeness, here is how to test the saveCourse() method:
describe('saveCourse', () => {
it('should save the course changes', async () => {
const changes: Partial<Course> = {
title: 'Angular Advanced Course'
};
const savePromise = service.saveCourse(12, changes);
const req = httpTesting.expectOne(
'/api/courses/12');
expect(req.request.method).toBe('PUT');
expect(req.request.body).toEqual(changes);
const updatedCourse: Course = {
id: 12,
title: 'Angular Advanced Course',
description: 'Advanced Angular patterns.',
iconUrl: 'https://example.com/icon.png',
category: 'ADVANCED',
lessonsCount: 20
};
req.flush(updatedCourse);
const saved = await savePromise;
expect(saved.title).toBe('Angular Advanced Course');
});
});
And with this, we have completed testing all the methods of CoursesService.
Let's now wrap everything up, and highlight some of the key concepts we have learned.
Conclusions and Key Takeaways
HttpTestingController gives you a clean, Angular-native way to test HTTP services without spinning up a real server or patching browser globals.
It makes it even more compelling to keep using the Angular HttpClient service in your services, even if you are not using Observable-based service layers.
Here is what to remember:
The golden rule: never await a service call before calling flush. The correct sequence is: call → expectOne → flush → await.
Always call verify() in afterEach. It is your safety net against unintended requests that would otherwise go unnoticed.
Use a filter function with expectOne when needed. there are sometimes more convenient ways to locate a request that don't involve knowing the full URL used.
flush simulates any HTTP response — success, but also error responses like 4xx, 5xx. Use error() for network-level failures.
With these patterns in place, your service layer tests will be fast, deterministic, maintainable and genuinely useful.
I hope you found this article helpful. Subscribe to my newsletter if you want me to send you my next article to your mailbox: