24

I have some unit tests using Angular TestBed. Even if the tests are very simple, they run extremely slow (on avarage 1 test assetion per second).
Even after re-reading Angular documentation, I could not find the reason of such a bad perfomance.

Isolated tests, not using TestBed, run in a fraction of second.

UnitTest

import { Component } from "@angular/core";
import { ComponentFixture, TestBed, async } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { DebugElement } from "@angular/core";
import { DynamicFormDropdownComponent } from "./dynamicFormDropdown.component";
import { NgbModule } from "@ng-bootstrap/ng-bootstrap";
import { FormsModule } from "@angular/forms";
import { DropdownQuestion } from "../../element/question/questionDropdown";
import { TranslateService } from "@ngx-translate/core";
import { TranslatePipeMock } from "../../../../tests-container/translate-pipe-mock";

describe("Component: dynamic drop down", () => {

    let component: DynamicFormDropdownComponent;
    let fixture: ComponentFixture<DynamicFormDropdownComponent>;
    let expectedInputQuestion: DropdownQuestion;
    const emptySelectedObj = { key: "", value: ""};

    const expectedOptions = {
        key: "testDropDown",
        value: "",
        label: "testLabel",
        disabled: false,
        selectedObj: { key: "", value: ""},
        options: [
            { key: "key_1", value: "value_1" },
            { key: "key_2", value: "value_2" },
            { key: "key_3", value: "value_3" },
        ],
    };

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            imports: [NgbModule.forRoot(), FormsModule],
            declarations: [DynamicFormDropdownComponent, TranslatePipeMock],
            providers: [TranslateService],
        })
            .compileComponents();
    }));

    beforeEach(() => {
        fixture = TestBed.createComponent(DynamicFormDropdownComponent);

        component = fixture.componentInstance;

        expectedInputQuestion = new DropdownQuestion(expectedOptions);
        component.question = expectedInputQuestion;
    });

    it("should have a defined component", () => {
        expect(component).toBeDefined();
    });

    it("Must have options collapsed by default", () => {
        expect(component.optionsOpen).toBeFalsy();
    });

    it("Must toggle the optionsOpen variable calling openChange() method", () => {
        component.optionsOpen = false;
        expect(component.optionsOpen).toBeFalsy();
        component.openChange();
        expect(component.optionsOpen).toBeTruthy();
    });

    it("Must have options available once initialized", () => {
        expect(component.question.options.length).toEqual(expectedInputQuestion.options.length);
    });

    it("On option button click, the relative value must be set", () => {
        spyOn(component, "propagateChange");

        const expectedItem = expectedInputQuestion.options[0];
        fixture.detectChanges();
        const actionButtons = fixture.debugElement.queryAll(By.css(".dropdown-item"));
        actionButtons[0].nativeElement.click();
        expect(component.question.selectedObj).toEqual(expectedItem);
        expect(component.propagateChange).toHaveBeenCalledWith(expectedItem.key);
    });

    it("writeValue should set the selectedObj once called (pass string)", () => {
        expect(component.question.selectedObj).toEqual(emptySelectedObj);
        const expectedItem = component.question.options[0];
        component.writeValue(expectedItem.key);
        expect(component.question.selectedObj).toEqual(expectedItem);
    });

    it("writeValue should set the selectedObj once called (pass object)", () => {
        expect(component.question.selectedObj).toEqual(emptySelectedObj);
        const expectedItem = component.question.options[0];
        component.writeValue(expectedItem);
        expect(component.question.selectedObj).toEqual(expectedItem);
    });
});

Target Component (with template)

import { Component, Input, OnInit, ViewChild, ElementRef, forwardRef } from "@angular/core";
import { FormGroup, ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { DropdownQuestion } from "../../element/question/questionDropdown";

@Component({
    selector: "df-dropdown",
    templateUrl: "./dynamicFormDropdown.component.html",
    styleUrls: ["./dynamicFormDropdown.styles.scss"],
    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: forwardRef(() => DynamicFormDropdownComponent),
            multi: true,
        },
    ],
})
export class DynamicFormDropdownComponent implements ControlValueAccessor {
    @Input()
    public question: DropdownQuestion;

    public optionsOpen: boolean = false;

    public selectItem(key: string, value: string): void {
        this.question.selectedObj = { key, value };
        this.propagateChange(this.question.selectedObj.key);
    }

    public writeValue(object: any): void {
        if (object) {
            if (typeof object === "string") {
                this.question.selectedObj = this.question.options.find((item) => item.key === object) || { key: "", value: "" };
            } else {
                this.question.selectedObj = object;
            }
        }
    }

    public registerOnChange(fn: any) {
        this.propagateChange = fn;
    }

    public propagateChange = (_: any) => { };

    public registerOnTouched() {
    }

    public openChange() {
        if (!this.question.disabled) {
            this.optionsOpen = !this.optionsOpen;
        }
    }

    private toggle(dd: any) {
        if (!this.question.disabled) {
            dd.toggle();
        }
    }
}

-----------------------------------------------------------------------

<div>
    <div (openChange)="openChange();" #dropDown="ngbDropdown" ngbDropdown class="wrapper" [ngClass]="{'disabled-item': question.disabled}">
        <input type="text" 
                [disabled]="question.disabled" 
                [name]="controlName" 
                class="select btn btn-outline-primary" 
                [ngModel]="question.selectedObj.value | translate"
                [title]="question.selectedObj.value"
                readonly ngbDropdownToggle #selectDiv/>
        <i (click)="toggle(dropDown);" [ngClass]="optionsOpen ? 'arrow-down' : 'arrow-up'" class="rchicons rch-003-button-icon-referenzen-pfeil-akkordon"></i>
        <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="option-wrapper">
            <button *ngFor="let opt of question.options; trackBy: opt?.key" (click)="selectItem(opt.key, opt.value); dropDown.close();"
                class="dropdown-item option" [disabled]="question.disabled">{{opt.value | translate}}</button>
        </div>
    </div>
</div>

Karma config

var webpackConfig = require('./webpack/webpack.dev.js');

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    plugins: [
      require('karma-webpack'),
      require('karma-jasmine'),
      require('karma-phantomjs-launcher'),
      require('karma-sourcemap-loader'),
      require('karma-tfs-reporter'),
      require('karma-junit-reporter'),
    ],

    files: [
      './app/polyfills.ts',
      './tests-container/test-bundle.spec.ts',
    ],
    exclude: [],
    preprocessors: {
      './app/polyfills.ts': ['webpack', 'sourcemap'],
      './tests-container/test-bundle.spec.ts': ['webpack', 'sourcemap'],
      './app/**/!(*.spec.*).(ts|js)': ['sourcemap'],
    },
    webpack: {
      entry: './tests-container/test-bundle.spec.ts',
      devtool: 'inline-source-map',
      module: webpackConfig.module,
      resolve: webpackConfig.resolve
    },
    mime: {
      'text/x-typescript': ['ts', 'tsx']
    },

    reporters: ['progress', 'junit', 'tfs'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['PhantomJS'],
    singleRun: false,
    concurrency: Infinity
  })
}
Francesco
  • 7,749
  • 6
  • 51
  • 88
  • dont run `fixture.detectChanges()` inside `beforeEach` – Aravind Dec 07 '17 at 09:31
  • I replaced the test in the question with another one. There I use fixture.detectChanges() only when I need to check/test the changed values, but the test take 15 seconds to run (on avarage 2 seconds for It section). Could it be the Karma setup/build the bottleneck? – Francesco Dec 07 '17 at 10:14
  • not exactly, it might be because your component takes that much time to initialize – Aravind Dec 07 '17 at 10:17
  • It could be, however also other components, even simpler than the one above in the question, take the same amount of time to run. Because of this I am thinking it has to do with the infrastructure of the tests, rather than the underlying components. – Francesco Dec 07 '17 at 11:28
  • Which browser do you use when running Angular tests? – Justinas Jakavonis Jan 21 '19 at 17:28
  • Headless Chrome – Francesco Jan 25 '19 at 08:10

8 Answers8

25

It turned out the problem is with Angular, as addressed on Github

Below a workaround from the Github discussion that dropped the time for running the tests from more than 40 seconds to just 1 second (!) in our project.

const oldResetTestingModule = TestBed.resetTestingModule;

beforeAll((done) => (async () => {
  TestBed.resetTestingModule();
  TestBed.configureTestingModule({
    // ...
  });

  function HttpLoaderFactory(http: Http) {
    return new TranslateHttpLoader(http, "/api/translations/", "");
  }

  await TestBed.compileComponents();

  // prevent Angular from resetting testing module
  TestBed.resetTestingModule = () => TestBed;
})()
  .then(done)
  .catch(done.fail));
Francesco
  • 7,749
  • 6
  • 51
  • 88
  • Sounds good to me. But how/when do you reset to the old `resetTestingModule` function? Does it work in all cases? - just found the solution regarding reset to old: https://stackoverflow.com/a/54351795/2176962 – hgoebl May 27 '19 at 15:50
  • @hgoebl in all our cases this solution was working, however this can differ depending on the specific scenario. The approach proposed by Granfaloon below seems to be a good way to cover potential cases of tests conflict. – Francesco May 28 '19 at 09:50
7
describe('Test name', () => {
    configureTestSuite();

    beforeAll(done => (async () => {
       TestBed.configureTestingModule({
            imports: [HttpClientTestingModule, NgReduxTestingModule],
            providers: []
       });
       await TestBed.compileComponents();

    })().then(done).catch(done.fail));

    it(‘your test', (done: DoneFn) => {

    });
});

Create new file:

    import { getTestBed, TestBed, ComponentFixture } from '@angular/core/testing';
    import { } from 'jasmine';

    export const configureTestSuite = () => {
       const testBedApi: any = getTestBed();
       const originReset = TestBed.resetTestingModule;

       beforeAll(() => {
         TestBed.resetTestingModule();
         TestBed.resetTestingModule = () => TestBed;
       });

       afterEach(() => {
         testBedApi._activeFixtures.forEach((fixture: ComponentFixture<any>) => fixture.destroy());
         testBedApi._instantiated = false;
       });

       afterAll(() => {
          TestBed.resetTestingModule = originReset;
          TestBed.resetTestingModule();
       });
    };
Yoav Schniederman
  • 4,419
  • 2
  • 21
  • 31
6

Francesco's answer above is great, but it requires this code at the end. Otherwise other test suites will fail.

    afterAll(() => {
        TestBed.resetTestingModule = oldResetTestingModule;
        TestBed.resetTestingModule();
    });
Granfaloon
  • 61
  • 1
  • 2
6

You may want to try out ng-bullet. It greatly increases execution speed of Angular unit tests. It's also suggested to be used in the official angular repo issue regarding Test Bed unit tests performance: https://github.com/angular/angular/issues/12409#issuecomment-425635583

The point is to replace the original beforeEach in the header of each test file

beforeEach(async(() => {
        // a really simplified example of TestBed configuration
        TestBed.configureTestingModule({
            declarations: [ /*list of components goes here*/ ],
            imports: [ /* list of providers goes here*/ ]
        })
        .compileComponents();
  }));

with configureTestSuite:

import { configureTestSuite } from 'ng-bullet';
...
configureTestSuite(() => {
    TestBed.configureTestingModule({
        declarations: [ /*list of components goes here*/ ],
        imports: [ /* list of providers goes here*/ ]
    })
});
Rado Koňuch
  • 255
  • 2
  • 7
  • 16
1

October 2020 Update

Upgrading angular app to Angular 9 has a Massive test run time improvement,


and if you want to stay on the current version the below package helped me to improve test performance:

Ng-bullet link

Ng-Bullet is a library which enhances your unit testing experience with Angular TestBed, greatly increasing execution speed of your tests.

What it will do is it will not create the test suite all the time and will use the previously created suite and by using this I have seen the 300% improved test runs then before.

Ref

Bhavin
  • 190
  • 3
  • 8
  • 2
    Thanks to Ivy - Angular Unit Tests are much faster out of the box since the components are not re-compiled between tests, making it the test run much faster. – Francesco Oct 22 '20 at 06:37
0

I made a little function you can use to speed things up. Its effect is similar to ng-bullet mentioned in other answers, but still cleans up services between tests so that they cannot leak state. The function is precompileForTests, available in n-ng-dev-utils.

Use it like this (from its docs):

// let's assume `AppModule` declares or imports a `HelloWorldComponent`
precompileForTests([AppModule]);

// Everything below here is the same as normal. Just add the line above.

describe("AppComponent", () => {
  it("says hello", async () => {
    TestBed.configureTestingModule({ declarations: [HelloWorldComponent] });
    await TestBed.compileComponents(); // <- this line is faster
    const fixture = TestBed.createComponent(HelloWorldComponent);
    expect(fixture.nativeElement.textContent).toContain("Hello, world!");
  });
});
Eric Simonton
  • 4,747
  • 2
  • 29
  • 45
  • I don't understand this first line precompileForTests... Why does it have an AppModule? – Danny Hoeve Jul 09 '19 at 09:12
  • You should specify your main module there, that has everything in your app. Then it will precompile all of it for all your tests up front, instead of recompiling before each test. – Eric Simonton Jul 09 '19 at 21:41
  • I really don't understand this, because your tests should be isolated from any real life implementation, right? – Danny Hoeve Jul 10 '19 at 14:50
  • That's a matter of taste, but regardless this does not add/remove any isolation from the tests themselves. It will behave just as without the `precomileForTests()` call, except that when angular would normally compile `HelloWorldComponent` it won't have to, because it was already precompiled. – Eric Simonton Jul 10 '19 at 16:13
0

Yoav Schniederman answer was helpful for me. To add on we need to clean <style> in our <head> tag as they are also responsible for memory leak.Cleaning all styles in afterAll() also improved performance to a good extend.

Please read original post for reference

Kinley Christian
  • 133
  • 2
  • 12
  • 1
    Please add code and data as text ([using code formatting](//stackoverflow.com/editing-help#code)), not images. Images: A) don't allow us to copy-&-paste the code/errors/data for testing; B) don't permit searching based on the code/error/data contents; and [many more reasons](//meta.stackoverflow.com/a/285557). Images should only be used, in addition to text in code format, if having the image adds something significant that is not conveyed by just the text code/error/data. – Suraj Rao Sep 17 '20 at 12:09
0

In my specific case it was delaying because we were importing our styles.scss (which also was importing other huge styles) in our components styles 'component.component.scss', this generates recursive styles for every component template.

To avoid this, only import scss variables, mixins and similar stuff in your components.

hakai
  • 1