DEV Community

Cover image for Testing and faking Angular dependencies
Lars Gyrup Brink Nielsen for This is Angular

Posted on • Updated on

Testing and faking Angular dependencies

Let’s prepare our experimental gear. Cover photo by deepakrit on Pixabay.

Original publication date: 2019-04-29.

Dependency injection is a key feature of Angular. This flexible approach makes our declarables and class-based services easier to test in isolation.

Tree-shakable dependencies remove the layer of indirection that is Angular modules, but how do we test their tree-shakable providers? We’ll test value factories that depend on injection tokens for platform-specific APIs.

Some components have browser-specific features. Together, we’ll test a banner that notifies our user that we’re ending Internet Explorer 11 support. A proper test suite can give us enough confidence that we won’t even have to test the banner in Internet Explorer 11.

Just kidding! We have to be careful not to get overly confident about complex integration scenarios. We should always make sure to perform QA (Quality Assurance) tests in environments as close to production as possible. This means running the application in a *real* Internet Explorer 11 browser.

The Angular testing utilities enable us to fake dependencies for the purpose of testing. We’ll explore different options of configuring and resolving dependencies in an Angular testing environment using the Angular CLI’s testing framework of choice, Jasmine.

Through examples, we’ll explore component fixtures, component initialisation, custom expectations, emulated events. We’ll even create custom test harnesses for very thin but explicit test cases.

Faking dependency injection tokens used in token providers

In “Tree-shakable dependencies in Angular projects”, we created a dependency injection token that evaluates to 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',
  });
Enter fullscreen mode Exit fullscreen mode
// 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',
  });
Enter fullscreen mode Exit fullscreen mode
_The user agent token factory provider reads directly from the global navigator object._

To test the Internet Explorer 11 flag provider in isolation, we can replace the userAgentToken with a fake value. We’ll practice that technique later in this article.

We notice that the user agent string provider extracts the relevant information from the platform-specific Navigator API. For the sake of learning, let’s say that we’re going to need other information from the same global navigator object. Depending on the test runner we use, the Navigator API might not even be available in the testing environment.

To be able to create fake navigator configurations, we create a dependency injection token for the Navigator API. We can use these fake configurations to simulate user contexts 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',
  });
Enter fullscreen mode Exit fullscreen mode
// navigator.token.ts
import { InjectionToken } from '@angular/core';

export const navigatorToken: InjectionToken<Navigator> =
  new InjectionToken('Navigator API', {
    factory: (): Navigator => navigator,
    providedIn: 'root',
  });
Enter fullscreen mode Exit fullscreen mode
_The global navigator object is abstracted into a Navigator API token._

What we test and how we test it should be part of our testing strategy. In more integrated component tests, we should be able to rely on most of the providers created as part of our dependency injection tokens. We’ll explore this later when testing the Internet Explorer 11 banner component.

WHAT we test and HOW we test it should be part of our testing strategy.

For our first test, we’re going to provide a fake value for the Navigator API token which is used as a dependency in the factory provider for the user agent string token.

To replace a token provider for testing purposes, we add an overriding provider in the Angular testing module similar to how an Angular module’s own providers override those 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');
        }));
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
_Replacing a token dependency in a factory provider for the user agent string._

Note that while it’s the user agent token and its provider we’re testing, it’s the navigator token dependency we’re replacing with a fake value.

Resolving dependencies using the inject function

The Angular testing utilities give us more than one way to resolve a dependency. In this test, we use the [inject](https://angular.io/api/core/testing/inject) function from the @angular/core/testing package (*not* the one from @angular/core).

The inject function allows us to resolve multiple dependencies by listing their tokens in an array that we pass as an argument. Every dependency injection token is resolved and available to the test case function as a parameter.

I have created a StackBlitz project with all the tests from this article running in Jasmine. As seen in the test report, the test works. We have successfully faked the native Navigator API for the purpose of testing.

Gotchas when using the Angular testing function inject

When we are using the Angular testing module without declarables, we can usually override a provider several times even within the same test case. We’ll examine an example of that later in this article.

It’s worth noting that this is not the case when using the Angular testing function [inject](https://angular.io/api/core/testing/inject). It resolves dependencies just before the test case function body is executed.

We can replace the token provider in beforeAll and beforeEach hooks using the static methods TestBed.configureTestingModule and TestBed.overrideProvider. But we can’t vary the provider between test cases or replace it during a test case when we use the inject testing function to resolve dependencies.

Resolving dependency injection tokens using TestBed

A more flexibly way of resolving 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 a test case function or a test lifecycle hook.

Let’s look at another example of a native browser API that we abstract using a dependency injection token for the purpose of development and testing.

// 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',
  });
Enter fullscreen mode Exit fullscreen mode
// 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');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
_Replacing a token dependency in a factory provider for the Location API._

The factory in the token’s provider is extracted from the DOCUMENT token which is available from the @angular/common package and abstracts the global document object.

In this test suite, we configure the Angular testing module inside the test case. I think it better illustrates the token dependency that we want to exercise in this test.

We make the Angular dependency injection system resolve the Location API by using the static TestBed.get method. As demonstrated in the StackBlitz testing project, the document token is successfully faked and used to resolve 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 fake object by providing it for the DOCUMENT token in the Angular testing module. If we hadn’t done that, Angular would’ve provided the global document object.

Additionally, if we wanted to test different document configurations, we wouldn’t be able to do so, had we not created a testing provider for the document token.

In the case that we add a testing provider using TestBed.configureTestingModule, we can use the static method TestBed.overrideProvider to replace it with different fake values in various test cases. We’ll use this technique to create test harnesses when testing Internet Explorer 11 detection and the Internet Explorer 11 banner component.

Note that this is only possible because we don’t use declarables. As soon as we call TestBed.createComponent, the Angular testing platform dependencies are locked.

Testing value factories with dependencies

In the first section 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 the browser detection in the value factory, we gather a few user agent strings from real browsers and put them in an enum.

// 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',
}
Enter fullscreen mode Exit fullscreen mode
_User agent strings of common browsers._

In the Internet Explorer 11 detection test suite, we’ll test the isInternetExplorer11Token almost in isolation. But the real business logic value lies in its factory provider which depends on the user agent token.

The user agent token extracts its value from the Navigator API token, but that dependency has already been covered by the Navigator API test suite. We’ll pick the user agent token as the adequate place in the dependency chain to start faking 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}"`);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
_Internet Explorer 11 detection test suite._

Before specifying the test cases, we create a test setup function and reduce an array of the non-Internet Explorer user agent strings from our fake user agent strings.

The test setup function takes a user agent and uses it to fake the user agent token provider. We then return an object with a property isInternetExplorer11 having a value that is evaluated from the isInternetExplorer11Token through the TestBed.get method.

Let’s test the happy path first. We pass an Internet Explorer 11 user agent string and expect the token-under-test to evaluate to true through Angular’s dependency injection system. As seen in the StackBlitz testing project, the browser detection works as expected.

What happens when the user browses with Internet Explorer 10? Our test suite demonstrates that Internet Explorer 11 does not result in a false positive in this case.

In other words, the token-under-test evaluates to false when an Internet Explorer 10 user agent string is provided in the dependee token. If this is not the intended usage, we’d need to change the detection logic. Now that we’ve got a test, it’d be easy to demonstrate when that change would become successful.

The final test exercises the browser detection on non-Internet Explorer browsers defined by the FakeUserAgent enum. The test case loops through the user agent strings, fakes the user agent provider, evaluates the isInternetExplorer11Token and expect its value to be false. If that is not the case, a useful error message is displayed by the test runner.

Faking dependencies in component tests

Now that we are satisfied with our Internet Explorer 11 browser detection, creating and displaying a deprecation banner is straightforward.

<!-- 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>
Enter fullscreen mode Exit fullscreen mode
// 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