Blog Barista: Jim Rasche | Aug 27, 2018 | Developer Tools | Brew time: 7 min

Have you ever been plagued by StaticInjectorError while working on your Angular front-end unit tests? Problems like these kept cropping up for me, so I spent a day looking into how TestBed takes all that metadata we pass into configureTestingModule to build an application around our testable code. I’ll run through some behind-the-scenes operations performed during test case setup and then summarize some of the takeaways.

Firstly, when analyzing third-party libraries like Angular/core/testing, I always find it helpful to download the entire project. This allows us to use an IDE and makes finding files and navigating to dependencies faster. To look through Angular code, you can download the Angular project from https://github.com/angular/angular.

So what happens when we pass our module metadata into configureTestingModule? In short, this just sets the TestBed’s internal declarations, providers, imports, etc. to whatever you specified in the metadata.

// Module
const specModule = {
    imports: [
        RouterTestingModule.withRoutes(routes),
        HttpClientTestingModule,
],
    declarations: [TestComponent, TestPipe, TestDirective ],
    providers: [provide: "BaseUrl", useValue: "test/" }]
};

After being configured, most interactions with the TestBed, including requesting compileComponents and getting an injected provider from TestBed, will create both the compiler and DynamicTestModule that encapsulates the component under test.

The JIT compiler, not the AOT compiler, is used if the TestBed is initialized with Angular’s default pattern below.

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);

You can verify this by following the breadcrumbs from

platformBrowserDynamicTesting

platformCoreDynamicTesting

platformCoreDynamic  → JitCompilerFactory

JIT is the obvious choice here as we have access to the compiler in the browser and the uncompiled versions of files for debugging.

Let’s tweak the compiler to see what is happening under the hood. An easy way to modify node_modules files is by editing them in the application’s node_modules folder. After refreshing, the changes will be available in the browser. npm install will wipe out these changes, but it’s quicker than the alternative of downloading the module’s source, making your changes, compiling, altering your package.json to use your local module, then reinstalling your project’s node modules. Either of these methods can be used to add logging to third-party libraries for temporary testing, and that’s what we’ll do here to help illustrate how the compiler uses our module metadata to create our test classes.

ng new project will create a new Angular application from the default Angular project template, with a single test case app.component.spec.ts. The appComponent is very basic; its template uses no Angular directives, and the component doesn’t even have a constructor.

First, add logging to compiler.js so we can track what’s happening behind the scenes. Searching the source in the Chrome dev console will show us that the compiler file used is @angular/compiler/fesm5/compiler.js. Add the lines with arrows to this file in your project, under the node_modules folder:

    JitCompiler.prototype._loadModules = function (mainModule, isSync) {
        var _this = this;
        var loading = [];
        var mainNgModule = this._metadataResolver.getNgModuleMetadata(mainModule);
        // Note: for runtime compilation, we want to transitively compile all modules,
        // so we also need to load the declared directives / pipes for all nested modules.
        this._filterJitIdentifiers(mainNgModule.transitiveModule.modules).forEach(function (nestedNgModule) {
            --> console.log(nestedNgModule.name);
            // getNgModuleMetadata only returns null if the value passed in is not an NgModule
            var /** @type {?} */ moduleMeta = /** @type {?} */ ((_this._metadataResolver.getNgModuleMetadata(nestedNgModule)));
            _this._filterJitIdentifiers(moduleMeta.declaredDirectives).forEach(function (ref) {
                --> console.log("    " + ref.name)
                var /** @type {?} */ promise = _this._metadataResolver.loadDirectiveMetadata(moduleMeta.type.reference, ref, isSync);
                if (promise) {
                    loading.push(promise);
                }
            });
            _this._filterJitIdentifiers(moduleMeta.declaredPipes)
                .forEach(function (ref) {
                    --> console.log("    " + ref.name)
                    return _this._metadataResolver.getOrLoadPipeMetadata(ref);
                });
        });
        --> console.log("providers");
        mainNgModule.providers.forEach(prov => console.log("   " + (prov.token.identifier.reference.name || prov.token.identifier.reference._desc)))
        return SyncAsync.all(loading);
    };

Run ng test and we can see all the imports pulled in:

RootScopeModule
CommonModule
    NgClass
    NgComponentOutlet
    NgForOf
    NgIf
    NgTemplateOutlet
    NgStyle
    NgSwitch
    NgSwitchCase
    NgSwitchDefault
    NgPlural
    NgPluralCase
    AsyncPipe
    UpperCasePipe
    LowerCasePipe
    JsonPipe
    SlicePipe
    DecimalPipe
    PercentPipe
    TitleCasePipe
    CurrencyPipe
    DatePipe
    I18nPluralPipe
    I18nSelectPipe
    KeyValuePipe
ApplicationModule
BrowserModule
BrowserTestingModule
BrowserDynamicTestingModule
DynamicTestModule
    AppComponent
Providers
    TestBed

We can see a lot of modules are being loaded that our test component metadata didn’t specify. These are default modules needed to build a component, compile templates, etc.. The only part we supplied, AppComponent, is nicely packaged in the DynamicTestModule.

Let’s add a little complexity here to demonstrate a potential issue. An import that was giving me grief was angular2-hotkeys. Let’s install it and see why.

npm install angular2-hotkeys –save

Update the app.component.ts to use it:

import { Component } from '@angular/core';
import { HotkeysService } from "angular2-hotkeys";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
  
  constructor(public hotkeysService: HotkeysService) {}
}

Run ng test again and we get StaticInjectorError:

Failed: StaticInjectorError(DynamicTestModule)[AppComponent -> HotkeysService]:

This makes sense. The compiler doesn’t know where to find the HotKeyService dependency and therefore can’t inject it. So let’s add the HotKeyModule to our test metadata and see what happens:

    TestBed.configureTestingModule({
      imports: [HotkeyModule],
      declarations: [
        AppComponent
      ],
    }).compileComponents();

We get the same error. The console output from the compiler shows the HotKeyModule is available, so what’s the problem?

RootScopeModule
.
.
.
BrowserDynamicTestingModule
HotkeyModule
    HotkeysDirective
    CheatSheetComponent
DynamicTestModule
    AppComponent
Providers
    TestBed

Seeing all the declarations, imports, and providers the compiler is building helps us understand that something is still wrong with our metadata setup. The HotKeyService is not among the providers we send to the compiler. A little investigation into how angular2-hotkeys is exporting its HotkeyModule reveals the issue: the providers are separated out from the HotKeyModule export via the static forRoot method.

@NgModule({
    imports : [CommonModule],
    exports : [HotkeysDirective, CheatSheetComponent],
    declarations : [HotkeysDirective, CheatSheetComponent]
})
export class HotkeyModule {
    static forRoot(options: IHotkeyOptions = {}): ModuleWithProviders {
        return {
            ngModule : HotkeyModule,
            providers : [
                HotkeysService,
                {provide : HotkeyOptions, useValue : options}
            ]
        };
    }
}

Since our test case is a standalone application, we need to call HotKeyModule.forRoot() when we import this module, as there is no parent module like AppModule or CoreModule to do this for us.

    TestBed.configureTestingModule({
      imports: [HotkeyModule.forRoot()],
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));
RootScopeModule
.
.
.
BrowserDynamicTestingModule
HotkeyModule
    HotkeysDirective
    CheatSheetComponent
DynamicTestModule
    AppComponent
Providers
    HotkeysService
    HotkeyOptions
    TestBed

Now we have the HotKeysService available for Angular to inject into the appComponent, and the test case passes.

 

Takeaways

  • When you’re having problems using a third-party library, delve into the details of how it works by searching code and making changes to the source to log output and test how it handles data.
  • forRoot is an Angular convention used to enforce singleton usage within an application. We will need to invoke it in test cases that use providers declared in forRoot.
  • Compilers are usually a part of web development that one need not dig into. However, it is important to understand their inner workings for cases like this.

0 Comments

Other recent posts:

Team Building in a Remote Environment

Team Building in a Remote Environment

Blog Barista: Dana Graham | June 15th, 2022 | Culture | Brew time: 5 min
Let me start by saying I don’t care for the term “work family.” I have a family I love, and they have absolutely nothing to do with my career. I want my work life to be its own entity. I like boundaries (and the George Costanza Worlds Theory). Certainly, I want to enjoy and trust my coworkers, and I want to feel supported and cared for…

read more

Pin It on Pinterest