Testing Service in Angular
Overview
The large number of Angular services that are included in an Angular application greatly facilitates communication and contributes to a cleaner code base.
To utilize them as well, a service can inject other services. It's relatively simple to test a standalone service: after obtaining an instance from the injector, we begin to explore its public methods and properties.
Scope
In this article, we will be discussing testing services in Angular.
Introduction to Testing Services
We only care about evaluating a service's public API, which is the interface that components and other artifacts used to communicate. Testing private symbols is useless unless they have any visible negative impacts. For instance, a public method may call a private one that, as a byproduct, may set a public property.
Three different sorts of tests are available for a service:
- Checking synchronous operations, such as, a method that produces a straightforward array.
- Testing a method that returns an observable or an asynchronous operation.
- Testing dependencies in services, such as an HTTP request method.
Services with Dependencies
Services that Angular injects into the constructor frequently rely on other services. In many instances, you can manually generate and inject these dependencies when running the constructor for the service.
The MasterService is a simple example:
File: app/services/master.service.ts
MasterService delegates its only method, getValue, to the injected ValueService.
Here are several ways to test it.
File: app/services/master.service.spec.ts
describe('MasterService without Angular testing support', () => { let masterService: MasterService; it('#getValue should return real value from the real service', () => { masterService = new MasterService(new ValueService()); expect(masterService.getValue()).toBe('real value'); }); it('#getValue should return faked value from a fakeService', () => { masterService = new MasterService(new FakeValueService()); expect(masterService.getValue()).toBe('faked service value'); }); it('#getValue should return faked value from a fake object', () => { const fake = { getValue: () => 'fake value' }; masterService = new MasterService(fake as ValueService); expect(masterService.getValue()).toBe('fake value'); }); it('#getValue should return stubbed value from a spy', () => { // create `getValue` spy on an object representing the ValueService const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValue']); // set the value to return when the `getValue` spy is called. const stubValue = 'stub value'; valueServiceSpy.getValue.and.returnValue(stubValue); masterService = new MasterService(valueServiceSpy); expect(masterService.getValue()) .withContext('service returned stub value') .toBe(stubValue); expect(valueServiceSpy.getValue.calls.count()) .withContext('spy method was called once') .toBe(1); expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) .toBe(stubValue); }); });
In the first test, a ValueService is created using new and given to the constructor of the MasterService.
Since the majority of dependent services are challenging to produce and manage, injecting the real service rarely succeeds.
Replace it with a mock dependence, a dummy value, or a spy on the relevant service method.
These common testing methods work well for unit testing isolated services.
However, you nearly always use Angular dependency injection to inject services into application classes, and you should have tests that represent this usage pattern. Investigating how injected services behave is simple with Angular testing tools.
Testing services with the TestBed
Dependency injection (DI) in Angular is used by your application to build services. A dependent service is found or created by DI when a service has one. Additionally, DI detects or builds those dependencies if the dependent service has them.
When using the TestBed testing tool to offer and create services, you can let Angular DI handle service creation and constructor argument order. However, as a service tester, you must, at the very least, consider the first level of service dependencies.
Angular TestBed
The TestBed is the most significant Angular testing tool. To simulate an Angular @NgModule, the TestBed builds an Angular test module dynamically.
A metadata object with the majority of the properties of a @NgModule is passed to the TestBed.configureTestingModule() method.
An array of the services you'll test or simulate is set in the provider's metadata property when testing a service.
Then, call TestBed.inject() with the service class as the argument to inject it within a test.
Alternatively, if you prefer to inject the service as part of your setup, you can inject it inside the beforeEach().
Make the mock available in the provider's array when testing a service with a dependency.
The mock is a spy object in the example that follows.
let masterService: MasterService; let valueServiceSpy: jasmine.SpyObj<ValueService>; beforeEach(() => { const spy = jasmine.createSpyObj('ValueService', ['getValue']); TestBed.configureTestingModule({ // Provide both the service-to-test and its (spy) dependency providers: [ MasterService, { provide: ValueService, useValue: spy } ] }); // Inject both the service-to-test and its (spy) dependency masterService = TestBed.inject(MasterService); valueServiceSpy = TestBed.inject(ValueService) as jasmine.SpyObj<ValueService>; });
That spy is consumed by the test in the same manner as before.
it('#getValue should return stubbed value from a spy', () => { const stubValue = 'stub value'; valueServiceSpy.getValue.and.returnValue(stubValue); expect(masterService.getValue()) .withContext('service returned stub value') .toBe(stubValue); expect(valueServiceSpy.getValue.calls.count()) .withContext('spy method was called once') .toBe(1); expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) .toBe(stubValue); });
Testing without beforeEach()
The majority of test suites in this article use the TestBed to generate classes and inject services while calling beforeEach() to specify the prerequisites for each it() test.
Another testing method never uses beforeEach() and favors explicitly creating classes rather than using the TestBed.
Here is an example of how you might rewrite one of the MasterService tests in that manner.
Instead of using beforeEach, start by placing reusable, preparatory code in a setup method().
File: app/services/master.service.spec.ts (setup)
A test could reference variables like masterService in an object literal that the setup() function returns. Semi-global variables (such as let masterService: MasterService) are not defined in the describefunction's body ().
Each test starts by calling setup(), and then proceeds to assert expectations and change the test subject.
it('#getValue should return stubbed value from a spy', () => { const { masterService, stubValue, valueServiceSpy } = setup(); expect(masterService.getValue()) .withContext('service returned stub value') .toBe(stubValue); expect(valueServiceSpy.getValue.calls.count()) .withContext('spy method was called oonce .toBe(1); expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) .toBe(stubValue); });
The test extracts the setup variables it needs, by using destructuring assignment.
Many developers believe that this strategy is clearer and more straightforward than the conventional beforeEach() method.
Feel free to use this alternate method in your projects even if this testing guide follows the conventional way and the default CLI schematics produce test files with beforeEach() and TestBed.
Testing HTTP services
For XHR calls, data services that use HTTP to communicate with distant servers commonly inject and delegate to the Angular HttpClient service.
With an injected HttpClient spy, you can test a data service just as you would any other service with a dependence.
File: app/services/hero.service.spec.ts
let httpClientSpy: jasmine.SpyObj<HttpClient>; let heroService: HeroService; beforeEach(() => { // TODO: spy on other methods too httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); heroService = new HeroService(httpClientSpy); }); it('should return expected heroes (HttpClient called once)', (done: DoneFn) => { const expectedHeroes: Hero[] = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]; httpClientSpy.get.and.returnValue(asyncData(expectedHeroes)); heroService.getHeroes().subscribe({ next: heroes => { expect(heroes) .withContext('expected heroes') .toEqual(expectedHeroes); done(); }, error: done. fail }); expect(httpClientSpy.get.calls.count()) .withContext('one call') .toBe(1); }); it('should return an error when the server returns a 404', (done: DoneFn) => { const errorResponse = new HttpErrorResponse({ error: 'test 404 error', status: 404, statusText: 'Not Found' }); httpClientSpy.get.and.returnValue(asyncError(errorResponse)); heroService.getHeroes().subscribe({ next: heroes => done.fail('expected an error, not heroes'), error: error => { expect(error.message).toContain('test 404 error'); done(); } }); });
The methods of the HeroService yield Observables. To execute an observable and to assert if a method succeeds or fails, you must subscribe to it.
The success (next) and fail (error) callbacks are passed to the subscribe() method. To ensure that you catch failures, make sure to give both callbacks. An asynchronous uncaught observable error results from failing to perform this, which the test runner will probably blame on a separate test.
HttpClientTestingModule
Long-term exchanges between a data service and an HTTP client can be challenging for spies to mimic.
These testing cases may be easier to handle with the HttpClientTestingModule.
Import the HttpClientTestingModule, the mimicking controller HttpTestingController, and the necessary symbols needed for your tests before starting to test calls to HttpClient.
File: app/services/http-client.service.spec.ts
Then proceed with setting up the service-under-test after adding the HttpClientTestingModule to the TestBed.
File: app/services/http-client.service.spec.ts (setup)
describe('HttpClient testing', () => { let httpClient: HttpClient; let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ] }); // Inject the http service and test controller for each test httpClient = TestBed.inject(HttpClient); httpTestingController = TestBed.inject(HttpTestingController); }); /// Tests begin /// });
Requests made during your tests now go to the testing backend rather than the main backend.
In order for the HttpClient service and the mocking controller to be used during the tests, this setup is additionally called TestBed.inject().
Now you can create a test that generates a mock answer and anticipates the occurrence of a GET Request.
File: app/services/http-client.service.spec.ts (setup) (HttpClient.get)
it('can test HttpClient.get', () => { const testData: Data = {name: 'Test Data'}; // Make an HTTP GET request httpClient.get<Data>(testUrl) .subscribe(data => // When the observable resolves, the result should match the test data expect(data).toEqual(testData) ); // The following `expectOne()` will match the request's URL. // If no requests or multiple requests matched that URL // `expectOne()` would throw. const req = httpTestingController.expectOne('/data'); // Assert that the request is a GET. expect(req.request.method).toEqual('GET'); // Respond with mock data, causing Observable to resolve. // Subscribe callback asserts that the correct data was returned. req.flush(testData); // Finally, assert that there are no outstanding requests. httpTestingController.verify(); });
The final step, which checks to see if any requests are still pending, is sufficiently frequent to be moved into an afterEach() step:
Custom request expectations
You can implement your own matching function if matching by URL is insufficient. You may, for instance, search for an outgoing request with an authorization header:
The test fails if 0 or 2+ requests fulfill this criterion, similar to the preceding expectOne().
Handling more than one request
Use the match() API as opposed to the expectOne method if you need to reply to duplicate requests in your test(). While returning an array of requests that match, it accepts the same inputs. You are in charge of flushing and verifying these requests because once they are returned, they are no longer eligible for matching in the future.
Conclusion
- Angular application has numerous services that each need to be tested separately.
- Three different sorts of tests are available for a service:
- Testing synchronous operations
- Testing a method that returns an observable
- Testing dependencies in services