Testing and faking Angular dependencies
Dependency injection is a key feature of Angular. This flexible approach makes it easier to isolate and test our declarable and class based services.
The rockable tree dependency removes the indirect layer, the Angular module, but how do we test their rockable tree provider? We will test the value factory of the injection token that depends on the specific platform API.
Some components have browser specific functionality. We will test together to inform users that we will terminate the banners supported by Internet Explorer 11. A suitable test suite can give us enough confidence that we don't even have to test banners in Internet Explorer 11.
We must be careful not to be overconfident in complex integration scenarios. We should always ensure that QA (quality assurance) testing is performed in an environment as close to production as possible. This means running the application in a real Internet Explorer 11 browser.
The Angular test utility enables us to forge dependencies for testing. We will use Jasmine, the test framework of Angular CLI, to explore different options for configuring and resolving dependencies in the Angular test environment.
Through examples, we will explore component fixtures, component initialization, custom expectations, and simulated events. We will even create custom test tools for very concise but explicit test cases.
Faking dependency injection tokens used in token providers
Look at an example.
We created a dependency injection token that is evaluated as a flag indicating whether the current browser is Internet Explorer 11.
// user-agent.token.ts import { InjectionToken } from '@angular/core'; export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string', { factory: (): string => navigator.userAgent, providedIn: 'root', });
// is-internet-explorer-11.token.ts import { inject, InjectionToken } from '@angular/core'; import { userAgentToken } from './user-agent.token'; export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', { factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(inject(userAgentToken)), providedIn: 'root', });
To test the Internet Explorer 11 flag provider separately, we can replace the userAgentToken with a false value.
We note that the user agent string provider extracts relevant information from the platform specific Navigator API. To learn, suppose we will need additional information from the same global Navigator object. Depending on the test runner we use, the Navigator API may not even be available in the test environment.
In order to create a fake navigator configuration, we created a dependency injection token for the navigator API. We can use these bogus configurations to simulate user context during development and testing.
// user-agent.token.ts import { inject, InjectionToken } from '@angular/core'; import { navigatorToken } from './navigator.token'; export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string', { factory: (): string => inject(navigatorToken).userAgent, providedIn: 'root', });
// navigator.token.ts import { InjectionToken } from '@angular/core'; export const navigatorToken: InjectionToken<Navigator> = new InjectionToken('Navigator API', { factory: (): Navigator => navigator, providedIn: 'root', });
For our first test, we will provide a false value for the Navigator API token, which is used as a dependency on the user agent string token in the factory provider.
In order to replace the token provider for testing purposes, we added an override provider in the Angular test module, which is similar to how the provider of the Angular module itself overrides the provider of the imported Angular module.
// navigator-api.spec.ts import { inject, TestBed } from '@angular/core/testing'; import { navigatorToken } from './navigator.token'; import { userAgentToken } from './user-agent.token'; describe('Navigator API', () => { describe('User agent string', () => { describe('Provider', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ { provide: navigatorToken, useValue: { userAgent: 'Fake browser', }, }, ], }); }); it( 'extracts the user agent string from the Navigator API token', inject([userAgentToken], (userAgent: string) => { expect(userAgent).toBe('Fake browser'); })); }); }); });
Note that although we are testing the user agent token and its provider, we are replacing the navigator token dependency with a false value.
Resolving dependencies using the inject function
The Angular test utility provides us with more than one way to resolve dependencies. In this test, we use the in the @ angular/core/testing package inject Function (* is not the one in @ angular/core).
The injection function allows us to resolve multiple dependencies by listing their tags in the array we pass as parameters. Each dependency injection token is parsed and provided as a parameter to the test case function.
example: https://stackblitz.com/edit/testing-and-faking-angular-dependencies?file=src%2Fapp%2Finternet-explorer%2Finternet-explorer-11-banner.component.spec.ts
Gotchas when using the Angular testing function inject
When we use an undeclared Angular test module, we can usually override the provider multiple times even in the same test case We will study an example later in this article.
It is worth noting that when using Angular test function inject This is not the case. It resolves dependencies before executing the test case function body.
We can use the static method testbed Configuretesting module and testbed Overrideprovider replaces the token provider in the beforeAll and beforeEach hooks. However, when we use injection testing to solve dependencies, we cannot change the provider between test cases or replace it during test cases.
A more flexible way to resolve Angular dependencies in tests without declarables is to use the static method testbed get. We simply pass the dependency injection token we want to resolve from anywhere in the test case function or test lifecycle hook.
Let's look at another example of a native browser API, which we abstract using dependency injection tokens for development and testing.
Location depends on Document:
// location.token.ts import { DOCUMENT } from '@angular/common'; import { inject, InjectionToken } from '@angular/core'; export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', { factory: (): Location => inject(DOCUMENT).location, providedIn: 'root', });
// location-api.spec.ts import { DOCUMENT } from '@angular/common'; import { TestBed } from '@angular/core/testing'; import { locationToken } from './location.token'; describe('Location API', () => { describe('Provider', () => { it('extracts the location from the DOCUMENT token', () => { TestBed.configureTestingModule({ providers: [ { provide: DOCUMENT, useValue: { location: { href: 'Fake URL', }, }, }, ], }); const location: Location = TestBed.get(locationToken); expect(location.href).toBe('Fake URL'); }); }); });
We use static testbed The get method enables the Angular dependency injection system to resolve the Location API. As demonstrated in the StackBlitz test project, the document token was successfully forged and used to parse the token under test using its real factory provider.
Gotchas when resolving dependencies using TestBed
In the previous test, we replaced the DOCUMENT with a dummy object by providing the DOCUMENT token in the Angular test module. If we don't, Angular will provide global DOCUMENT objects.
In addition, if we want to test different document configurations, we will not be able to do so if we do not create a test provider for the document token.
Before we use testbed When the configuretesting module adds a test provider, we can use the static method testbed Overrideprovider replaces it with different false values in various test cases. When testing Internet Explorer 11 detection and Internet Explorer 11 banner components, we will use this technology to create a test tool.
Please note that this is the only possible because we do not use declarable. Once we call testbed Createcomponent, the dependency of the Angular test platform is locked.
Testing value factories with dependencies
In the first part of this article, we introduced a token with a value factory in its provider. The value factory evaluates whether the user agent string represents an Internet Explorer 11 browser.
To test browser detection in the value factory, we collected some user agent strings from real browsers and put them in an enumeration.
// fake-user-agent.ts export enum FakeUserAgent { Chrome = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36', InternetExplorer10 = 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729)', InternetExplorer11 = 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; rv:11.0) like Gecko', Firefox = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:65.0) Gecko/20100101 Firefox/65.0', }
In the Internet Explorer 11 detection test suite, we will test isInternetExplorer11Token almost in isolation. But the real business logic value lies in its factory provider, which relies on user agent tokens.
The user agent token extracts its value from the Navigator API token, but the dependency is covered by the Navigator API test suite. We will select the user agent token as the appropriate place in the dependency chain to start forging dependencies.
// internet-explorer-11-detection.spec.ts import { TestBed } from '@angular/core/testing'; import { isInternetExplorer11Token } from './is-internet-explorer-11.token'; import { FakeUserAgent } from './fake-user-agent'; import { userAgentToken } from './user-agent.token'; describe('Internet Explorer 11 detection', () => { function setup({ userAgent }: { userAgent: string }) { TestBed.overrideProvider(userAgentToken, { useValue: userAgent }); return { isInternetExplorer11: TestBed.get(isInternetExplorer11Token), }; } const nonInternetExplorerUserAgents: ReadonlyArray<string> = Object.entries(FakeUserAgent) .filter(([browser]) => !browser.toLowerCase().includes('internetexplorer')) .map(([_browser, userAgent]) => userAgent); it('accepts an Internet Explorer 11 user agent', () => { const { isInternetExplorer11 } = setup({ userAgent: FakeUserAgent.InternetExplorer11, }); expect(isInternetExplorer11).toBe(true); }); it('rejects an Internet Explorer 10 user agent', () => { const { isInternetExplorer11 } = setup({ userAgent: FakeUserAgent.InternetExplorer10, }); expect(isInternetExplorer11).toBe(false); }); it('rejects other user agents', () => { nonInternetExplorerUserAgents.forEach(userAgent => { const { isInternetExplorer11 } = setup({ userAgent }); expect(isInternetExplorer11).toBe( false, `Expected to reject user agent: "${userAgent}"`); }); }); });
Before specifying the test case, we created a test setup function and reduced a set of non Internet Explorer user agent strings from our fake user agent strings.
The test setup function takes the user agent and uses it to forge the user agent token provider. Then we return an object with the property isInternetExplorer11, which has passed testbed The get method evaluates the value from isInternetExplorer11Token.
Let's test the happiness path first. We pass the Internet Explorer 11 user agent string and expect the tested token to be evaluated as true through Angular's dependency injection system. As seen in the StackBlitz test project, browser detection works as expected.
What happens when a user uses Internet Explorer 10 to browse? Our test suite shows that Internet Explorer 11 does not cause false positives in this case.
In other words, when the Internet Explorer 10 user agent string is provided in the dependency token, the tested token is evaluated as false. If this is not the intended use, we need to change the detection logic. Now that we've tested it, it's easy to prove when the change will succeed.
The final test performs browser detection on non Internet Explorer browsers defined by the FakeUserAgent enumeration. The test case traverses the user agent string, forges the user agent provider, evaluates the isInternetExplorer11Token and expects its value to be false. If this is not the case, the test runner displays a useful error message.
Faking dependencies in component tests
Now that we are satisfied with Internet Explorer 11 browser detection, it is easy to create and display discarded banners.
<!-- internet-explorer-11-banner.component.html --> <aside *ngIf="isBannerVisible"> Sorry, we will not continue to support Internet Explorer 11.<br /> Please upgrade to Microsoft Edge.<br /> <button (click)="onDismiss()"> Dismiss </button> </aside>
// internet-explorer-11-banner.component.ts import { Component, Inject } from '@angular/core'; import { isInternetExplorer11Token } from './is-internet-explorer-11.token'; @Component({ selector: 'internet-explorer-11-banner', templateUrl: './internet-explorer-11-banner.component.html', }) export class InternetExplorer11BannerComponent { private isDismissed = false; get isBannerVisible() { return this.isInternetExplorer11 && !this.isDismissed; } constructor( @Inject(isInternetExplorer11Token) private isInternetExplorer11: boolean, ) {} onDismiss() { this.isDismissed = true; } }
The release state is only stored in the private component property as the local UI state, which is used by the calculated property isBannerVisible.
The banner component has a dependency, isinternet Explorer 11token, which is evaluated as a Boolean value. Due to the Inject decorator, this Boolean value is injected through the banner component constructor.
Summary
In this article, we demonstrated how to test and forge tree shakable dependencies in the Angular project. We also tested value factories that rely on platform specific API s.
In the process, we investigated the problem of using injection testing to solve dependencies. Using TestBed, we solved the dependency injection token and explored the pitfalls of this approach.
We tested the Internet Explorer 11 deprecation banner in many ways, so that it hardly needs to be tested in an actual browser. We forged its dependencies in its component test suite, but as we discussed, we should always test it in real browser targets in complex integration scenarios.