Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to test transition hooks with ui-router 1.0.x

I am migrating to the latest stable release of ui-router and am making use of the $transitions life cycle hooks to perform certain logic when certain state names are being transitioned to.

So in some of my controllers I have this kinda thing now:

this.$transitions.onStart({ }, (transition) => {
    if (transition.to().name !== 'some-state-name') {
        //do stuff here...
    }
});

In my unit tests for the controller, previously I would broadcast a state change event on the $rootScope with the certain state names as the event args to hit the conditions I needed to test.

e.g.

$rootScope.$broadcast('$stateChangeStart', {name: 'other-state'}, {}, {}, {});

Since these state events are deprecated, whats the correct way to now trigger the $transitions.onStart(...) hooks in the tests?

I have tried just calling $state.go('some-state-name') in my tests but I can never hit my own logic within the transition hook callback function. According to the docs here, calling state.go programatically should trigger a transition, unless I am misreading?

Has anyone else managed to get unit tests for transition hooks in their controllers working for the new ui-router 1.0.x?

Full example of my controller code using a transition hook:

this.$transitions.onSuccess({ }, (transition) => {
      this.setOpenItemsForState(transition.to().name);
    });

test spec:

describe('stateChangeWatcher', function() {
      beforeEach(function() {
        spyOn(vm, 'setOpenItemsForState').and.callThrough();
      });

      it('should call the setOpenItemsForState method and pass it the state object', function() {
        $state.go('home');
        $rootScope.$apply();
        expect(vm.setOpenItemsForState).toHaveBeenCalledWith('home');
      });
    });

My spy is never getting hit, when running the application locally this hook does get invoked as expected, so it must be something I have got setup incorrectly in my tests. Is there something extra I need to make the transition succeed in the test, since I am hooking into the onSuccess event?

Thanks

UPDATE

I raised this in the ui-router room on gitter and one of the repo contributors came back to me suggesting I check the call to $state.go('home') in my tests actually ran by adding expect($state.current.name).toBe('home'); in my test spec.

This does pass for me in my test, but I am still unable to hit the call to my function in the transition hook callback:

enter image description here

I'm unsure how to proceed on this, other than installing the polyfill for the legacy $stateChange events so I can use my previous code, but I'd rather not do this and figure out the proper way to test $transition hooks.

UPDATE 2

Following estus' answer, I have now stubbed out the $transitions service and also refactored my transition hook handler into a private named function in my controller:

export class NavBarController {
  public static $inject = [
    '$mdSidenav',
    '$scope',
    '$mdMedia',
    '$mdComponentRegistry',
    'navigationService',
    '$transitions',
    '$state'
  ];

  public menuSection: Array<InterACT.Interfaces.IMenuItem>;

  private openSection: InterACT.Interfaces.IMenuItem;
  private openPage: InterACT.Interfaces.IMenuItem;

  constructor(
    private $mdSidenav,
    private $scope,
    private $mdMedia,
    private $mdComponentRegistry,
    private navigationService: NavigationService,
    private $transitions: any,
    private $state
  ) {
    this.activate();
  }

    private activate() {
    this.menuSection = this.navigationService.getNavMenu();
    if (this.isScreenMedium()) {
      this.$mdComponentRegistry.when('left').then(() => {
        this.$mdSidenav('left').open();
      });
    }
    this.setOpenItemsForState(this.$state.$current.name);
    this.$transitions.onSuccess({ }, this.onTransitionsSuccess);
  }

  private onTransitionsSuccess = (transition) => {
    this.setOpenItemsForState(transition.to().name);
  }

    private setOpenItemsForState(stateName: string) {
        //stuff here...
    }
}

Now in my test spec I have:

describe('Whenever a state transition succeeds', function() {
      beforeEach(function() {
        spyOn(vm, 'setOpenItemsForState').and.callThrough();
        $state.go('home');
      });
      it('should call the setOpenItemsForState method passing in the name of the state that has just been transitioned to', function() {
        expect($transitions.onSuccess).toHaveBeenCalledTimes(1);
        expect($transitions.onSuccess.calls.mostRecent().args[0]).toEqual({});
        expect($transitions.onSuccess.calls.mostRecent().args[1]).toBe(vm.onTransitionsSuccess);
      });
    });

These expectations pass, but Im still not able to hit my inner logic in my named hook callback onTransitionsSuccess function that make a call to setOpenItemsForState

enter image description here

What am I doing wrong here?

UPDATE 3

Thanks again to estu, I was forgetting I can just call my named transition hook function is a separate test:

describe('and the function bound to the transition hook callback is invoked', function(){
        beforeEach(function(){
          spyOn(vm, 'setOpenItemsForState');
          vm.onTransitionsSuccess({
            to: function(){
              return {name: 'another-state'};
            }
          });
        });
        it('should call setOpenItemsForState', function(){
          expect(vm.setOpenItemsForState).toHaveBeenCalledWith('another-state');
        });
      });

And now I get 100% coverage :)

enter image description here

Hopefully this will serve as a good reference to others who may be struggling to figure out how to test their own transition hooks.

like image 872
mindparse Avatar asked Oct 27 '17 12:10

mindparse


People also ask

What is Transition Hook?

A Transition Hook is a callback function that is run during the specific lifecycle event of a Transition. The hook function receives the current Transition object as the first argument.

What is the difference between ngRoute and UI-router?

The ui-router is effective for the larger application because it allows nested-views and multiple named-views, it helps to inherit pages from other sections. In the ngRoute have to change all the links manually that will be time-consuming for the larger applications, but smaller application nrRoute will perform faster.

What is UI-router?

UI-Router is the defacto standard for routing in AngularJS. Influenced by the core angular router $route and the Ember Router, UI-Router has become the standard choice for routing non-trivial apps in AngularJS (1. x).


1 Answers

A good unit test strategy for AngularJS routing is to stub a router entirely. Real router prevents units from being efficiently tested and provides unnecessary moving parts and unexpected behaviour. Since ngMock behaves differently than real application, such tests zcan't be considered proper integration tests either.

All router services in use should be stubbed. $stateProvider stub should reflect its basic behaviour, i.e. it should return itself on state call and should return $state stub on $get call:

let mockedStateProvider;
let mockedState;
let mockedTransitions;

beforeEach(module('app'));

beforeEach(module(($provide) => {
  mockedState = jasmine.createSpyObj('$state', ['go']);
  mockedStateProvider = jasmine.createSpyObj('$stateProvider', ['state', '$get']);
  mockedStateProvider.state.and.returnValue(mockedStateProvider);
  mockedStateProvider.$get.and.returnValue(mockedState);
  $provide.provider('$state', function () { return mockedStateProvider });
}));

beforeEach(module(($provide) => {
  mockedTransitions = jasmine.createSpyObj('$transitions', ['onStart', 'onSuccess']);
  $provide.value('$transitions', mockedTransitions);
}));

A test-friendly way is to provide bound methods as callbacks instead of anonymous functions:

this.onTransitionStart = (transition) => { ... };
this.$transitions.onStart({ }, this.onTransitionStart);

Then stubbed methods can be just tested that they were called with proper arguments:

expect($transitions.onStart).toHaveBeenCalledTimes(1);
$transitions.onStart.mostRecent().args[0].toEqual({});
$transitions.onStart.mostRecent().args[1].toBe(this.onTransitionStart);

A callback function can be tested directly by calling it with expected arguments. This provides full coverage yet leaves some place for human error, so unit tests should be backed up with integration/e2e tests with real router.

like image 189
Estus Flask Avatar answered Oct 22 '22 03:10

Estus Flask