Testing and faking Angular dependencies — Testing article by Lars Gyrup Brink Nielsen on Angular In Depth
On this page · 12 sections
Let's assemble our testing toolkit. Cover photo by deepakrit on Pixabay.
Original publication date: 2019-04-29.
Angular's dependency injection sits at the heart of the framework. This flexible mechanism allows our components, directives, and services to be tested in splendid isolation.
Tree-shakable dependencies bypass the indirection of Angular modules entirely, but what's the testing story for their providers? We'll examine value factories that rely on injection tokens for platform-specific APIs.
Certain components carry browser-specific behavior. Together, we'll test a banner that informs users about the end of Internet Explorer 11 support. A solid test suite can give us peace of mind without ever booting up Internet Explorer 11.
Just kidding! We must resist the urge to overtrust our tests when it comes to complex integration scenarios. Always run QA (Quality Assurance) checks in environments that mirror production as closely as possible. That means launching the application in an actual, *real* Internet Explorer 11 browser.
The Angular testing utilities let us substitute fakes for dependencies during tests. We'll walk through various strategies for configuring and resolving dependencies in an Angular test environment, using Jasmine—the Angular CLI's default testing framework.
Through practical examples, we'll delve into component fixtures, component initialization, custom matchers, and simulated events. We'll also build custom test harnesses that keep test cases lean and explicit.
// is-internet-explorer-11.token.tsimport{inject,InjectionToken}from'@angular/core';import{userAgentToken}from'./user-agent.token';exportconstisInternetExplorer11Token:InjectionToken<boolean>=newInjectionToken('Internet Explorer 11 flag',{factory:():boolean=>/Trident\/7\.0.+rv:11\.0/.test(inject(userAgentToken)),providedIn:'root',});
_The user agent token factory provider reads directly from the global navigator object._
To test the Internet Explorer 11 flag provider on its own, we can swap the userAgentToken with a fabricated value. We'll put that approach into practice further down.
Observe that the user agent string provider pulls the necessary data from the platform-specific Navigator API. For the purpose of learning, let's assume we'll require additional details from the same global navigator object. Depending on the test runner in use, the Navigator API may not even exist in the test environment.
To enable crafting synthetic navigator setups, we establish a dependency injection token for the Navigator API. These synthetic setups allow us to simulate various user contexts during both development and testing.
_The global navigator object is abstracted into a Navigator API token._
What gets tested and how it gets tested should be deliberate choices within our testing strategy. In more integrated component tests, we ought to lean on most of the providers registered through our dependency injection tokens. We'll dig into that when we test the Internet Explorer 11 banner component.
WHAT we test and HOW we test it should be part of our testing strategy.
For our initial test, we'll supply a synthetic value for the Navigator API token, which serves as a dependency in the factory provider for the user agent string token.
To override a token provider for testing, we register a replacing provider in the Angular testing module—similar to how an Angular module's providers supersede those from an imported Angular module.
// navigator-api.spec.tsimport{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');}));});});});
_Replacing a token dependency in a factory provider for the user agent string._
Keep in mind that although we're testing the user agent token and its provider, it's the navigator token dependency that gets swapped with a synthetic value.
Pulling dependencies through the inject function
The Angular testing utilities offer several paths to resolve a dependency. In this test, we employ the [inject](https://angular.io/api/core/testing/inject) function from the @angular/core/testing package (*not* the one exported by @angular/core).
The inject function lets us resolve multiple dependencies by listing their tokens in an array passed as an argument. Each dependency injection token gets resolved and becomes available to the test case function as a parameter.
I've assembled a StackBlitz project running all the tests from this article in Jasmine. As the test report confirms, the test passes. We've effectively faked the native Navigator API for testing purposes.
Pitfalls with the Angular testing function inject
When the Angular testing module has no declarables, we typically can override a provider multiple times, even within the same test case. We'll see an illustration of that later.
That flexibility disappears when we reach for the Angular testing function [inject](https://angular.io/api/core/testing/inject). It resolves dependencies right before the test case function body runs.
We can swap the token provider in beforeAll and beforeEach hooks via the static methods TestBed.configureTestingModule and TestBed.overrideProvider. But when the inject testing function handles dependency resolution, we can't alter the provider between test cases or swap it mid-test-case.
Resolving tokens through TestBed
A more adaptable way to resolve Angular dependencies in tests without declarables is the static method TestBed.get. We hand it the dependency injection token we want, and we can call it from anywhere—inside a test case function or within a lifecycle hook.
Let's examine another native browser API that gets abstracted behind a dependency injection token for development and testing convenience.
// location-api.spec.tsimport{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',},},},],});constlocation:Location=TestBed.get(locationToken);expect(location.href).toBe('Fake URL');});});});
_Replacing a token dependency in a factory provider for the Location API._
The factory in the token's provider is carved out of the DOCUMENT token, which comes from the @angular/common package and abstracts the global document object.
In this test suite, we configure the Angular testing module within the test case itself. I find this better illustrates the token dependency we want to exercise in that test.
We let the Angular dependency injection system resolve the Location API via the static TestBed.get method. As shown in the StackBlitz testing project, the document token gets successfully faked and then used to resolve the token-under-test with its genuine factory provider.
Pitfalls when resolving dependencies via TestBed
In the previous test, we replaced the document with a fabricated object by registering it for the DOCUMENT token in the Angular testing module. Had we skipped that, Angular would have supplied the global document object.
Moreover, had we not created a testing provider for the document token, exploring different document configurations would have been off the table.
When a testing provider gets added via TestBed.configureTestingModule, the static method TestBed.overrideProvider allows us to swap in different fake values across various test cases. We'll leverage this approach to build test harnesses when testing Internet Explorer 11 detection and the Internet Explorer 11 banner component.
This technique works solely because we avoid declarables. The moment TestBed.createComponent is called, the Angular testing platform dependencies become locked in.
Testing value factories with dependencies
Earlier in this article, we introduced a token whose provider includes a value factory. That factory examines the user agent string to determine whether the browser in use is Internet Explorer 11.
For testing this browser detection logic, we collect user agent strings from actual browsers and organize them into an enum.
// fake-user-agent.tsexportenumFakeUserAgent{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',}
_User agent strings of common browsers._
In the test suite dedicated to Internet Explorer 11 detection, the isInternetExplorer11Token is tested nearly in isolation. The meaningful logic, however, lives in its factory provider, which relies on the user agent token.
The user agent token pulls its value from the Navigator API token — a dependency already validated by its own test suite. We choose the user agent token as the starting point in the dependency chain for introducing fakes.
// internet-explorer-11-detection.spec.tsimport{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',()=>{functionsetup({userAgent}:{userAgent:string}){TestBed.overrideProvider(userAgentToken,{useValue:userAgent});return{isInternetExplorer11:TestBed.get(isInternetExplorer11Token),};}constnonInternetExplorerUserAgents: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}"`);});});});
_Internet Explorer 11 detection test suite._
Before writing the test cases, we define a setup helper and construct an array containing all non-Internet Explorer user agent strings from our fake set.
The setup function accepts a user agent and uses it to override the user agent token provider. It returns an object featuring a property isInternetExplorer11 whose value is derived from the isInternetExplorer11Token via the TestBed.get method.
We begin with the positive scenario: passing an Internet Explorer 11 user agent string and expecting the token under test to resolve to true through Angular's dependency injection. As demonstrated in the StackBlitz testing project, the detection functions correctly.
What about a visitor using Internet Explorer 10? The test suite confirms that no false positive occurs for Internet Explorer 11 in that scenario.
Stated differently, the token under test yields false when an Internet Explorer 10 user agent string populates the dependee token. Should that behavior be undesirable, the detection logic would require modification. With a test in place, demonstrating a successful change becomes trivial.
The last test covers browser detection across non-Internet Explorer browsers enumerated in FakeUserAgent. This test iterates through the user agent strings, fakes the user agent provider, evaluates the isInternetExplorer11Token, and expects a false result. If any case fails, the test runner provides a descriptive error message.
Faking dependencies in component tests
With browser detection for Internet Explorer 11 validated, building and displaying a deprecation notice becomes simple.
<!-- 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>
Users are given the ability to dismiss the banner. It appears when the user agent (the browser) identifies as Internet Explorer 11 and the user has not previously dismissed it by clicking the banner's button.
Dismissable Internet Explorer 11 deprecation banner.
The dismissed status amounts to local UI state held in a private component field, which the computed property isBannerVisible consults.
The banner component depends on a single thing—the isInternetExplorer11Token, evaluated to a Boolean. This Boolean is injected into the banner component's constructor thanks to the Inject decorator.
Testing the banner component
An obvious route for testing the banner component is faking the isInternetExplorer11Token, given it is a direct dependency. That said, integration tests spanning multiple modules inspire greater confidence in our components.
We opt instead to fake the userAgentToken by supplying a value from the FakeUserAgent enum. Prior tests confirm this dependency chain operates correctly.
Three behaviors deserve verification:
The banner shows when the user agent is Internet Explorer 11
Clicking the banner button dismisses it
The banner stays hidden for any browser other than Internet Explorer 11
To keep tests succinct, a test harness facilitates:
Faking the user agent
Assessing banner visibility
Simulating a dismiss button click
This is how the test cases are intended to look:
// internet-explorer-11-banner.component.spec.tsdescribe('Internet Explorer 11',()=>{it('displays a banner',()=>{const{expectBannerToBeDisplayed}=setup({userAgent:FakeUserAgent.InternetExplorer11,});expectBannerToBeDisplayed();});it('the banner is dismissable',()=>{const{clickDismissButton,expectBannerToBeHidden}=setup({userAgent:FakeUserAgent.InternetExplorer11});clickDismissButton();expectBannerToBeHidden();});});describe('Other browsers',()=>{it('hides the banner',()=>{const{expectBannerToBeHidden}=setup({userAgent:FakeUserAgent.Chrome,});expectBannerToBeHidden();});});
_Test cases for the Internet Explorer 11 deprecation banner component._
The harness comes from our custom setup function; we examine its implementation shortly.
Notably, we limit ourselves to testing Internet Explorer 11 and a single alternative browser. Detection across all supported browsers was already handled in the earlier section, "Testing value factories with dependencies."
Now, let's inspect how the harness gets built.
// internet-explorer-11-banner.component.spec.tsfunctionsetup({userAgent}:{userAgent:string}){TestBed.overrideProvider(userAgentToken,{useValue:userAgent});constfixture=TestBed.createComponent(InternetExplorer11BannerComponent);fixture.detectChanges();constreadBannerText=()=>(fixture.nativeElementasHTMLElement).textContent.trim();return{clickDismissButton(){constbuttonDebug=fixture.debugElement.query(By.css('button'));buttonDebug.triggerEventHandler('click',{});fixture.detectChanges();},expectBannerToBeDisplayed(){expect(readBannerText().toLowerCase()).toContain('please upgrade','Expected banner to be displayed');},expectBannerToBeHidden(){expect(readBannerText()).toBe('','Expected banner to be hidden');},};}
_Test harness for the Internet Explorer 11 deprecation banner component._
For those acquainted with Angular's testing utilities, this should feel familiar.
We override the user agent token with the supplied parameter. Next, we create a component fixture for the banner and trigger change detection for initialization.
Finally, expectations for banner visibility and a click simulation function are assembled and returned as methods on the harness object.
Creating a component fixture without explicit testing module configuration might raise questions. The key is ensuring the testing module is already configured before setup executes — accomplished through the beforeEach hook.
// is-internet-explorer-11.token.tsimport{inject,InjectionToken}from'@angular/core';import{userAgentToken}from'./user-agent.token';exportconstisInternetExplorer11Token:InjectionToken<boolean>=newInjectionToken('Internet Explorer 11 flag',{factory:():boolean=>/Trident\/7\.0.+rv:11\.0/.test(inject(userAgentToken)),providedIn:'root',});
// internet-explorer-11-banner.component.tsimport{async,TestBed}from'@angular/core/testing';import{By}from'@angular/platform-browser';import{InternetExplorer11BannerComponent,}from'./internet-explorer-11-banner.component';import{InternetExplorerModule}from'./internet-explorer.module';import{FakeUserAgent}from'./fake-user-agent';import{userAgentToken}from'./user-agent.token';describe(InternetExplorer11BannerComponent.name,()=>{functionsetup({userAgent}:{userAgent:string}){TestBed.overrideProvider(userAgentToken,{useValue:userAgent});constfixture=TestBed.createComponent(InternetExplorer11BannerComponent);fixture.detectChanges();constreadBannerText=()=>(fixture.nativeElementasHTMLElement).textContent.trim();return{clickDismissButton(){constbuttonDebug=fixture.debugElement.query(By.css('button'));buttonDebug.triggerEventHandler('click',{});fixture.detectChanges();},expectBannerToBeDisplayed(){expect(readBannerText().toLowerCase()).toContain('please upgrade','Expected banner to be displayed');},expectBannerToBeHidden(){expect(readBannerText()).toBe('','Expected banner to be hidden');},};}beforeEach(async(()=>{TestBed.configureTestingModule({imports:[InternetExplorerModule],providers:[{provide:userAgentToken,useValue:'No user agent'},],}).compileComponents();}));describe('Internet Explorer 11',()=>{it('displays a banner',()=>{const{expectBannerToBeDisplayed}=setup({userAgent:FakeUserAgent.InternetExplorer11,});expectBannerToBeDisplayed();});it('the banner is dismissable',()=>{const{clickDismissButton,expectBannerToBeHidden}=setup({userAgent:FakeUserAgent.InternetExplorer11});clickDismissButton();expectBannerToBeHidden();});});describe('Other browsers',()=>{it('hides the banner',()=>{const{expectBannerToBeHidden}=setup({userAgent:FakeUserAgent.Chrome,});expectBannerToBeHidden();});});});
_Test suite for the Internet Explorer 11 deprecation banner component._
Assembled together, we arrive at straightforward test cases with well-defined setup, exercise, and verification stages.
At this juncture, we might wonder whether testing in a real Internet Explorer 11 browser is still necessary, given the confidence our tests provide.
Summary
This article walked through testing and faking tree-shakable dependencies in an Angular project, including value factories that depend on platform-specific APIs.
Along the way, we examined pitfalls associated with using the inject test function for dependency resolution. Through TestBed, we resolved dependency injection tokens and highlighted considerations for that approach.
The Internet Explorer 11 deprecation banner underwent extensive testing, reducing the need to verify it in an actual browser. While we faked its dependencies in the component test suite, real-browser testing remains advisable for intricate integration scenarios.
The application demonstrating dependency faking in Angular lives in a StackBlitz project.
The corresponding test suite, which tests and fakes Angular dependencies, is available in a separate StackBlitz project.
Microsoft's Modern.IE site provides free resources for generating browser snapshots with Internet Explorer, along with free virtual machine images running Internet Explorer on Windows 7 or 8.1.
Discover how to provide tree-shakable dependencies and other intricate Angular dependency injection configurations in "Tree-shakable dependencies in Angular projects", the article on which our application is based.
Reviewers
These members of the Angular community graciously reviewed this article: