Blog Barista: Jim Rasche | Aug 28, 2018 | Developer Tools | Brew time: 10 min

I recently became obsessed with speeding up my Angular test cases. Of course speed is already part of the definition of a unit test, but the faster they are, the more we can effectively use testing throughout the development process. With sufficiently fast test suites, developers can run tests continuously in the background, giving quicker feedback when a breaking change is introduced. Speedy unit tests also make it easier to integrate testing with CI and not irresponsibly bog down the whole process.

This post will share a couple strategies to speed up front-end Angular unit tests. The first technique prevents consecutive tests from running unnecessary setup code. The second technique aims to reduce the amount of setup code used overall.

1. Overwrite the resetTestingModule method of TestBed

After every test case completes, Angular’s core/testing package destroys both the TestBed’s module, created via TestBed.configureTestingModule(…), and the Zone the test ran in. It’s fine to discard the Zone, as the context the test ran in is no longer useful, but all that work the compiler did to build our component can definitely be reused.

Let’s think about what really changes from test case to test case. The compiler won’t change unless we choose to configure it within the test case, which seems like an edge case… we can re-use it. We’re probably not changing the module metadata we pass to configure the testing module, therefore the creation of our DynamicTestModule and the collection of dependent declarations, imports, providers, etc. will not change… we can re-use it. So, what must be discarded? Based off of a discussion I read on GitHub, the only part of TestBed that cannot be reused between test cases is the component fixture. This makes sense as we will likely modify the component during testing to verify its behavior and those modifications shouldn’t affect the next test.

Setting these two Jasmine methods will override the original resetTestingModule method and only reset the properties necessary to run the next test case cleanly. Overriding the TestBed’s resetTestingModule can accomplish everything we just discussed: preserve the compiler and DynamicTestModule, and destroy the fixtures after each test runs.

protected overWriteModuleReset(): void {
        const testBedApi: any = getTestBed();
        let originReset = TestBed.resetTestingModule;
        beforeAll(() => {
            TestBed.resetTestingModule();
            (TestBed.resetTestingModule as any) = () =>
            {
                testBedApi._activeFixtures.forEach((fixture: ComponentFixture<any>) => fixture.destroy());
                testBedApi._instantiated = false;
            };
        });
        afterAll(() => {
            TestBed.resetTestingModule = originReset;
            TestBed.resetTestingModule();
        });

Note: the benefit is obvious when we look at how not resetting TestBed._moduleFactory short-circuits the really hefty TestBed methods like compileComponents and _initIfNeeded.

The difference I’ve found by preventing full module reset is substantial: the 28 test cases ran ~4 times faster. However, note that the speed advantage gained from this technique scales with the number of test cases in a feature test. No gain will be recognized for a feature with only 1 test case, as the compiler and module must be created once regardless.

Normal test run

5.0742 seconds average == (5.281+5.061+5.083+4.616+5.67+4.461+5.082+5.281+5.122+5.085)/10 
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.281 secs / 5.251 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.061 secs / 5.033 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.083 secs / 5.061 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (4.616 secs / 4.597 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.67 secs / 5.648 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (4.461 secs / 4.438 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.082 secs / 5.056 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.281 secs / 5.253 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.122 secs / 5.097 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (5.085 secs / 5.06 secs)

With resetTestingModule modification

1.2723 seconds average == (1.387+1.282+1.202+1.282+1.127+1.268+1.264+1.167+1.192+1.552)/10
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.387 secs / 1.261 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.282 secs / 1.244 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.202 secs / 1.173 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.282 secs / 1.251 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.127 secs / 1.097 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.268 secs / 1.235 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.264 secs / 1.226 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.167 secs / 1.124 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.192 secs / 1.165 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 28 of 28 SUCCESS (1.552 secs / 1.495 secs)

2. Removing unnecessary shared modules

While going over some old test cases, I noticed that some imported a shared module called SharedModule. I had to wonder what effect all these unused files brought in by importing SharedModule were having on the speed of the test case. The short answer is, a significant effect. From this single experiment, the speed increased by ~35%.

This component had many more dependencies than the one from technique 1, so the process of isolating exactly which components, pipes, and services were needed took a while, maybe 30 iterations of running a single test case and getting a build error like:

Can't bind to ‘<directive input>’ since it isn't a known property of '<some component>’'

or

Failed: StaticInjectorError(DynamicTestModule)[<Some Service> -> <Depending Service>]

A sample of isolating as small a portion of code to use in a test case as possible:

1.)  Run a test

2.)  Get the following error:

Error: Template parse errors:
Can't bind to 'popover' since it isn't a known property of 'span'. ("               tabindex="0"
                        style="color: black;"
                        [ERROR ->][popover]="notApprovedTemplate"
                        container="body"
                        tr"): ng:///DynamicTestModule/UserListComponent.html@40:24

3.)  Identify what file the [popover] directive is in (in VS code, you can do this through left click -> Go to definition)

export declare class PopoverDirective implements OnInit, OnDestroy {
popover: string | TemplateRef<any>;….

4.)  Add this file as a declaration in:

    declarations: [
	…
        PopoverDirective,
    ],

I can see why SharedModule was used originally. This is the easy solution to know you have everything you need, no tracing down dependent grandchild components or services injected to your components service’s service. You could follow this technique to its logical conclusion where no modules are imported in any component, and you simply pull in all the components, pipes, and directives needed by the component under test and every component within the test components tree.

There are two reasons not to push it to this extreme:

1.)  When providers are intended to be used as singletons, they should be exported in a modules forRoot() method. In this case, they cannot be declared and must be imported, which imports all unnecessary code in that module.

2.)  Specifying every import makes a test case fragile to changes in code outside the class under test. The test case declarations will need to be updated anytime a component or service adds a dependency not already declared in the testing module.

In the struggle between the speed of specificity and the durability of generality, I lean towards specificity. Having to know precisely what your component needs makes us think more closely about our component interactions. This leads to a better understanding of our applications as a whole.

I used the process discussed in one of my previous blog posts to determine that removing the SharedModule alone took 91 out of 187 (~48%) components, directives, pipes, services, and templates out of the compilation process.

Using SharedModule

(3.156+3.486+3.083+2.953+3.466+3.188+3.377+3.635+3.202+3.541)/10 == 3.3087
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.156 secs / 3.148 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.486 secs / 3.465 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.083 secs / 3.337 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.953 secs / 2.944 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.466 secs / 3.457 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.188 secs / 3.165 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.377 secs / 3.344 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.635 secs / 3.604 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.202 secs / 3.179 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.541 secs / 3.529 secs)
    imports: [
        RouterTestingModule.withRoutes([
            { path: "login", component: HostComponent }
        ]),
        SharedModule,
        UiComponentModule,
        NgxKlaBsModalModule,
        ToastrModule.forRoot({}),
        HttpClientTestingModule,
        BrowserAnimationsModule,
    ],
    declarations: [
        HostComponent
    ],
    providers: [
        { provide: "BaseUrl", useValue: "test" },
        LoginService,

        CurrencyPipe,
    ]

Not using SharedModule

(2.373+2.055+2.6+3.019+2.602+2.317+2.295+2.243+2.356+2.643)/10 == 2.4503
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.373 secs / 2.364 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.055 secs / 2.048 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.6 secs / 2.59 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (3.019 secs / 2.679 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.602 secs / 2.567 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.317 secs / 2.303 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.295 secs / 2.286 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.243 secs / 2.234 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.356 secs / 2.347 secs)
Chrome 68.0.3440 (Mac OS X 10.13.6): Executed 1 of 1 SUCCESS (2.643 secs / 2.612 secs)
    imports: [
        RouterTestingModule.withRoutes([
            { path: "login", component: ContractExecutionStatusComponent }
        ]),
        UiComponentModule,
        NgxKlaBsModalModule,
        ToastrModule.forRoot({}),
        HttpClientTestingModule,
        BrowserAnimationsModule,
],
    declarations: [
        HostComponent,
DetailsButtonsComponent,
        EditDetailsComponent,
        LoadingSpinnerComponent,
ExtendedInputComponent,
        CharsLeftComponent,
        HelpBlockComponent,
],
    providers: [
        LoginService,
        { provide: "BaseUrl", useValue: "test" },
        UserPermissionService,
        CurrencyPipe,
        DecimalPipe,
        PercentPipe,
        FormHelperService,
ModalHelperService,
        ValidationService,
]
};

In summary, a focus on speed is important for testing. There are some tradeoffs that need to be weighed when we make changes to improve speed, but with a little cleverness we can improve performance measurably.

4 Comments

  1. Reading this article was an experience. I enjoyed all the information you provided and appreciated the work you did in getting it written. You really did a lot of research.

    • Thanks Ronald! It’s easy to get a little too engrossed in some of this stuff.

  2. About “Overwrite the resetTestingModule method of TestBed” – isn’t that handled by ng-bullet?

  3. Overwrite the resetTestingModule:
    Added what was given inside ‘overWriteModuleReset()’ into describe of few of my spec files and could see the difference in time taken to execute the test cases. How do i add this as a configuration so that it is applicable for all spec files in my application?

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