Faster Ionic Tests Without TestBed

September 15th 2017 Ionic 2/3 Unit Testing Angular

Although the official Ionic templates aren't preconfigured for unit testing, there is no lack of guidance in this field:

With all these materials available, it's not all that difficult to get started with unit testing in your own project. However, as the number of tests in the project will start to increase, it will soon become obvious that the test are quite slow. That might be ok, if we only wanted to run the tests as a part of the build process on the build server. It will quickly make continuous running of tests during development inconvenient, though.

With a minor change to one of the unit testing starter projects, e.g. Clickers by Stephen Hazleton, we can output the time it takes for each test to run:

  • Install the spec reporter plugin for Karma:

      npm install karma-spec-reporter --save-dev
    
  • Register the plugin in karma.conf.js:

      plugins: [
        require('karma-jasmine'),
        require('karma-chrome-launcher'),
        require('karma-jasmine-html-reporter'),
        require('karma-junit-reporter'),
        require('karma-spec-reporter'),
        require('karma-coverage-istanbul-reporter'),
        require('@angular/cli/plugins/karma')
      ],
    
  • Add spec configuration to karma.conf.js:

      specReporter: {
        showSpecTiming: true
      },
    

This makes it easy to identify, which tests are slow:

ClickerButton
  √ initialises (449ms)
  √ displays the clicker name and count (365ms)
  √ does a click (364ms)

ClickerForm
  √ initialises (331ms)
  √ passes new clicker through to service (307ms)
  √ doesn't try to add a clicker with no name (356ms)

ClickerList
  √ initialises (376ms)

Pages: Page2
  √ should create page2 (441ms)
  √ should fire the simple alert (430ms)
  √ should fire the more advanced alert (397ms)

All of the above tests are taking between 300 ms and 500 ms, while all the others take 20 ms at most. This is a reason enough for a closer look. As it turns out, they all have a common denominator: they are using Angular's TestBed to configure the testing module and instantiate the component under test:

public static configureIonicTestingModule(components: Array<any>): typeof TestBed {
  return TestBed.configureTestingModule({
    declarations: [
      ...components,
    ],
    providers: [
      App, Form, Keyboard, DomController, MenuController, NavController,
      {provide: Platform, useFactory: () => PlatformMock.instance()},
      {provide: Config, useFactory: () => ConfigMock.instance()},
      {provide: ClickersService, useClass: ClickersServiceMock},
    ],
    imports: [
      FormsModule,
      IonicModule,
      ReactiveFormsModule,
    ],
  });
}

public static beforeEachCompiler(components: Array<any>):
  Promise<{fixture: any, instance: any}> {
    return TestUtils.configureIonicTestingModule(components)
      .compileComponents().then(() => {
        let fixture: any = TestBed.createComponent(components[0]);
        return {
          fixture: fixture,
          instance: fixture.debugElement.componentInstance,
        };
      });
}

There's an open issue about TestBed performance with many suggestions on how to speed up the tests. Some of them appear promising, but I decided to approach the problem from a different angle.

Many tests only require the TestBed to instantiate the component, providing it with all the dependencies. It's not all that difficult to create the component by calling its constructor and manually passing in the dependencies, especially when we are using mocks for most of them, avoiding the transitive dependencies.

In simple cases, such as clickerList.spec.ts we only need to change the original test setup and cleanup:

beforeEach(async(() =>
  TestUtils.beforeEachCompiler([ClickerList, ClickerForm, ClickerButton])
    .then(compiled => {
      fixture = compiled.fixture;
      instance = compiled.instance;
      fixture.detectChanges();
})));

afterEach(() => {
  fixture.destroy();
});

With an even simpler implementation:

beforeEach(() => {
  let navController: NavController = NavControllerMock.instance();
  let clickersService: ClickersService = new ClickersServiceMock() as any;
  instance = new ClickerList(navController, clickersService);
});

The sample test will still pass, but will require much less time:

ClickerList
  √ initialises (0ms)

The tests in page2.spec.ts will require some additional work. The original setup code:

beforeEach(async(() => {

 TestBed.configureTestingModule({
    declarations: [Page2],
    providers: [
      App, DomController, Form, Keyboard, MenuController, NavController,
      {provide: Config, useFactory: () => ConfigMock.instance()},
      {provide: Platform, useFactory: () => PlatformMock.instance()},
      {provide: AlertController, useFactory: () => AlertControllerMock.instance()},
    ],
    imports: [
      FormsModule,
      IonicModule,
      ReactiveFormsModule,
    ],
  })
  .compileComponents().then(() => {
    fixture = TestBed.createComponent(Page2);
    instance = fixture;
    fixture.detectChanges();
    fixture.componentInstance.onGainChange();

    alertSpy = fixture.componentInstance.alertController;
    alertControllerSpy = fixture.componentInstance.alertController.create();
  });
}));

afterEach(() => {
  fixture.destroy();
});

Will undergo a similar change:

beforeEach(() => {

  let alertController: AlertController = AlertControllerMock.instance();
  instance = new Page2(alertController);

  instance.onGainChange();

  alertSpy = instance.alertController;
  alertControllerSpy = instance.alertController.create();

});

Notice how I replaced all references to fixture.componentInstance with instance and completely skipped the call to fixture.detectChange. The same change needs to be done in all the tests. For example, the following test:

it('should fire the more advanced alert', fakeAsync(() => {

  alertSpy.create.calls.reset();
  alertControllerSpy.present.calls.reset();

  fixture.componentInstance.okEd = false;

  expect(fixture.componentInstance.okEd).toBeFalsy();

  fixture.componentInstance.showMoreAdvancedAlert();
  tick();

  fixture.componentInstance.OK();

  expect(fixture.componentInstance.okEd).toBeTruthy();

}));

Will become:

it('should fire the more advanced alert', fakeAsync(() => {

  alertSpy.create.calls.reset();
  alertControllerSpy.present.calls.reset();

  instance.okEd = false;

  expect(instance.okEd).toBeFalsy();

  instance.showMoreAdvancedAlert();
  tick();

  instance.OK();

  expect(instance.okEd).toBeTruthy();

}));

You can check all the modified tests in my fork of the Clicker starter project.

And the timings? Still fast:

Pages: Page2
  √ should create page2 (0ms)
  √ should fire the simple alert (1ms)
  √ should fire the more advanced alert (0ms)

Unfortunately, not all tests can implemented without the TestBed. If they require interaction with DOM, then simply instantiating the class page will not be enough. That's the case with all the tests for ClickerButton and ClickerForm in this starter project. Here's one typical example:

it('does a click', () => {
  fixture.detectChanges();
  spyOn(instance['clickerService'], 'doClick');
  TestUtils.eventFire(fixture.nativeElement.querySelectorAll('button')[0], 'click');
  expect(instance['clickerService'].doClick).toHaveBeenCalled();
});

Even if we still need to use TestBed for some of the tests, we can save a lot of time by converting all the others. We managed to shorten the total test time for more than 1.5 s by changing only four tests. You can imagine how much we can spare in a larger test suite.

I'd also argue that in most cases unit tests don't really need to interact with DOM. We should be able to test the code without it. DOM interaction can always be covered with E2E tests, which don't need to be constantly run in the inner development loop.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License