原文 : Testing and faking Angular dependencies

Dependency injection is a key Angular feature. This flexible approach makes it easier to isolate tests for our declarable and class-based services.

Tree shaker dependencies remove the indirection layer, the Angular module, but how do we test their tree shaker provider? We will test the value factory of the injected token that depends on the platform specific API.

Some components have browser-specific capabilities. We’ll be testing together a banner that notifies users that we’re terminating Internet Explorer 11 support. A proper test suite can give us enough confidence that we don’t even have to test the banner in Internet Explorer 11.

We must be careful not to be too confident about 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 the real Internet Explorer 11 browser.

The Angular test utility lets you fake dependencies to test. We’ll use the Angular CLI test framework Jasmine to explore different options for configuring and resolving dependencies in an Angular test environment.

Using the example, we will explore component fixtures, component initialization, custom expectations, and simulation events. We even create custom test tools for very thin but clear test cases.

Faking dependency injection tokens used in token providers

Let’s do an example.

We create 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 notice that the user agent string provider extracts relevant information from the platform-specific Navigator API. To learn, assume that 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.

To be able to create a fake navigator configuration, we created a dependency injection token for the navigator API. We can use these bogus configurations during development and testing to simulate the user context.

// 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 dummy value for the Navigator API token that is used as a dependency for the user agent string token in the factory provider.

To replace the token provider for testing purposes, we added an coverage provider to the Angular test module, similar to how an Angular module’s own provider overrides the provider of an 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 while 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 more than one way to resolve dependencies. In this test, we use the inject function in the @angular/core/testing package (* 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/t…

Gotchas when using the Angular testing function inject

When we use undeclared Angular test modules, we can often override the provider more than once, even in the same test case. We’ll look at an example later in this article.

It’s worth noting that this is not the case when using Angular to test the inject feature. It resolves dependencies before executing the body of test case functions.

We can use the static methods TestBed. ConfigureTestingModule and TestBed overrideProvider replace beforeAll and beforeEach token providers in the hook. But when we use injection test functionality to resolve 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 DI 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 that we abstract with 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 have the Angular dependency injection system resolve the Location API by using the static testBed. get method. 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 our previous tests, we replaced the DOCUMENT with a fake object by providing a DOCUMENT for the DOCUMENT token in the Angular test module. If we don’t, Angular provides the global document object.

In addition, if we want to test different document configurations, we will not be able to do so if we have not created a Test Provider for the document token.

In TestBed. We use configureTestingModule add test under the condition of the provider, we can use the static methods TestBed. OverrideProvider in various test cases will be replaced by false value. We will use this technique to create a test tool when testing Internet Explorer 11 detection and Internet Explorer 11 banner components.

Notice that this is only possible because we don’t use Declarable. As soon as we call testBed. createComponent, the Angular test platform dependency 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) InternetExplorer10 = 'Mozilla / 5.0 (compatible; MSIE 10.0; Windows NT 10.0; WOW64; Trident / 7.0; . NET4.0 C; . NET4.0 E; The.net CLR 2.0.50727; The.net CLR 3.0.30729; NET CLR 3.5.30729)', InternetExplorer11 = 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident / 7.0; . NET4.0 C; . NET4.0 E; The.net CLR 2.0.50727; The.net CLR 3.0.30729; The.net CLR 3.5.30729; Rv :11.0) like Gecko', Firefox = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; The rv: 65.0) Gecko / 20100101 Firefox / 65.0 '},

In Internet Explorer 11 detection test suite, we will almost isInternetExplorer11Token isolation test. But the real value of the business logic 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 the dependency.

// 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 setting function and reduced a set of non-Internet Explorer user agent strings from our fake user agent string.

The test setup function takes the user agent and uses it to forge the user agent token provider. Then we’ll return a have properties isInternetExplorer11 object, the object is by TestBed. The get method from isInternetExplorer11Token assessment value.

Let’s test the happiness path first. We pass the Internet Explorer 11 user agent string and expect the token under test to be evaluated as true by Angular’s dependency injection system. As seen in the StackBlitz test project, browser detection works as expected.

What happens when users browse with Internet Explorer 10? Our test suite shows that Internet Explorer 11 does not cause false positives in this case.

In other words, when relying on the Internet Explorer 10 user agent string provided in the token, the token under test evaluates to false. If this is not the intended use, we need to change the detection logic. Now that we’ve done the testing, it’s easy to prove when the change will be successful.

The final test performs browser detection on non-Internet Explorer browsers defined by the FakeUserAgent enumeration. Test cases to traverse the user agent string, forge the user agent to provide program, assess isInternetExplorer11Token and expect its value to 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, creating and displaying deprecated banners is 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>
// 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 demerit state is simply stored as local UI state in a private component property, which is used by the computed property isBannerVisible.

Banner components have a dependency – isInternetExplorer11Token, it is evaluated as a Boolean value. Because of the Inject decorator, this Boolean value is injected through the banner component constructor.

Summary

In this article, we demonstrated how to test and fake tree-shakable dependencies in an Angular project. We also tested a value factory that depended on a platform-specific API.

In doing so, we investigated problems with dependency resolution using injection testing capabilities. Using TestBed, we solved dependency injection tokens and explored the pitfalls of this approach.

We tested Internet Explorer 11 deprecated banners in so many ways that it was almost impossible to test them in a real browser. We faked its dependencies in its component test suite, but as we discussed, we should always test it against real browser targets in complex integration scenarios.