angular: "Navigation triggered outside Angular zone" warning in unit tests

I’m submitting a…


[x] Regression (a behavior that used to work and stopped working in a new release)
[ ] Bug report  
[ ] Performance issue
[ ] Feature request
[ ] Documentation issue or request
[ ] Support request => Please do not submit support request here, instead see https://github.com/angular/angular/blob/master/CONTRIBUTING.md#question
[ ] Other... Please describe:

Current behavior

Using the last Angular 6.1.7 which introduced a warning if a navigation is triggered outside a zone (see https://github.com/angular/angular/commit/010e35d99596e1b35808aaaeecfc32f3b247a7d8), a simple unit test calling router.navigate yields the warning (resulting in hundreds of warnings in the console).

Expected behavior

The warning should not be emitted in unit tests. It’s currently possible to remove it by using zone.run or using NoopNgZone, but this is cumbersome. A way to disable it in unit tests would be great.

Minimal reproduction of the problem with instructions

Build a minimal app with the CLI

ng new router-warning --routing

Then replace the default unit test with:

import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';

import { AppComponent } from './app.component';

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule
      ],
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));

  it('should create the app', async(() => {
    const router = TestBed.get(Router) as Router;
    router.navigateByUrl('/');
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
});

Run ng test, and the following warning should appear:

WARN: 'Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?'

Environment


Angular version: 6.1.7 or 7.0.0-beta.5

Browser:
- [x] Chrome (desktop) version XX
- [ ] Chrome (Android) version XX
- [ ] Chrome (iOS) version XX
- [ ] Firefox version XX
- [ ] Safari (desktop) version XX
- [ ] Safari (iOS) version XX
- [ ] IE version XX
- [ ] Edge version XX
 
For Tooling issues:
- Node version: 8.11.3
- Platform:  Mac

cc @trotyl

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Reactions: 127
  • Comments: 34 (6 by maintainers)

Commits related to this issue

Most upvoted comments

Workaround:

constructor(private ngZone: NgZone, private router: Router) {}

public navigate(commands: any[]): void {
    this.ngZone.run(() => this.router.navigate(commands)).then();
}

In unit test:

it('should just work', async(() => {
    fixture.ngZone.run(() => {
        router.navigate(['path1']);
        fixture.detectChanges();
        fixture.whenStable().then(() => {
            expect({stuff}).toHaveBeenCalled();
        });
    });
}));

I’d say you don’t need to navigate in unit tests. You should spy on those methods and assert that they have been called.

You also need to wrap router.initialNavigation();

fixture.ngZone.run(() => {
      router.initialNavigation();
});

Workround proposed by @ItzhakBokris worked, but it must not be a permanent solution. Waiting to angular team!

this.ngZone.run(() => this.router.navigate(['home'])).then();

+1

As a workaround for tests, I created small proxy that runs all methods of router within NgZone.

import { NgZone } from '@angular/core';
import { Router } from '@angular/router';
import { isFunction } from 'lodash';

/**
 * Wrapper of Angular router (only for testing purpose)
 * Meant to run all router operations within Angular zone
 *  * Keep change detection enabled
 *  * Avoids flooded console with warnings
 *    https://github.com/angular/angular/issues/25837
 *
 * @see Router
 */
export function wrapRouterInNgZone(router: Router, ngZone: NgZone): Router {
  return new Proxy(router, {
    get(target: Router, p: PropertyKey): unknown {
      const invokedProperty = target[p];
      if (!isFunction(invokedProperty)) {
        return invokedProperty;
      }

      return function(...args: Array<unknown>): unknown {
        return ngZone.run(() => invokedProperty.apply(target, args));
      };
    },
  });
}

Note: unknown is replacement for any in TypeScript 3.0.

Usage:

  let router: Router;
  beforeEach(() => {
     router = wrapRouterInNgZone(
      TestBed.get(Router),
      TestBed.get(NgZone),
    );
  });

  // no flood console:
  it('some test', fakeAsync(() => {
    router.navigate(['']);
    tick();
  }));

I’d say you don’t need to navigate in unit tests.

For unit tests, yes. But the same framework can also be used for integration tests where you should be able to instantiate routing and do navigation.

I’ve also run into this in tests, and the workaround proposed by @piotrl doesn’t work for me as some navigations are triggered by the tested code, also causing these warnings – since that is what I am testing I don’t want to mock everything here.

My approach is thus to instead silence the particular warning:

/** See https://github.com/angular/angular/issues/25837 */
export function setupNavigationWarnStub() {
    const warn = console.warn;

    spyOn(console, 'warn').and.callFake((...args: any[]) => {
        const [firstArg] = args;
        if (typeof firstArg === 'string' && firstArg.startsWith('Navigation triggered outside Angular zone')) {
            return;
        }

        return warn.apply(console, args);
    });
}

and then

beforeEach(() => setupNavigationWarnStub());

I see this warning in the console when running my app normally, not in unit tests.

constructor(private router: Router) { }

navigateAway() {
  this.router.navigateByUrl('foo');
}
import { Location } from '@angular/common';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { Router, Routes } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
import { HeroesComponent } from './heroes/heroes.component';
import { MessagesComponent } from './messages/messages.component';

describe('AppComponent', () => {
  const routes: Routes = [
    { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
    { path: 'dashboard', component: DashboardComponent },
    { path: 'heroes', component: HeroesComponent },
    { path: 'detail/:id', component: HeroDetailComponent }
  ];

  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;
  let compiled: HTMLElement;
  let router: Router;
  let location: Location;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent,
        HeroesComponent,
        HeroDetailComponent,
        MessagesComponent,
        DashboardComponent
      ],
      imports: [FormsModule, RouterTestingModule.withRoutes(routes)]
    })
      .compileComponents()
      .then(() => {
        fixture = TestBed.createComponent(AppComponent);
        component = fixture.debugElement.componentInstance;
        compiled = fixture.debugElement.nativeElement;
        router = TestBed.get(Router);
        location = TestBed.get(Location);

        fixture.detectChanges();

        fixture.ngZone.run(() => {
          router.initialNavigation();
        });
      });
  }));

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  it('should render title in a h1 tag', () => {
    fixture.detectChanges();
    expect(compiled.querySelector('h1').textContent).toContain(
      'Tour of Heroes'
    );
  });

  it('should get 4 RouterLinks', () => {
    expect(router.config.length).toEqual(4);
  });

  it('should redirect to /dashboard when navigate to ""', async(() => {
    fixture.ngZone.run(() => {
      fixture.whenStable().then(() => {
        router.navigate(['']).then(() => {
          expect(location.path()).toEqual('/dashboard');
        });
      });
    });
  }));

  it('should navigate to /heroes', async(() => {
    fixture.ngZone.run(() => {
      fixture.whenStable().then(() => {
        router.navigate(['/heroes']).then(() => {
          expect(location.path()).toEqual('/heroes');
        });
      });
    });
  }));
});

image

That’s all , thank you! No errors, No warnings. very good! 😄

I guess It is because latest angular CLI creates routing in a separate module app-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

so to run navigation with in Angular zone it is required to implement work around -

constructor(private ngZone: NgZone, private router: Router) {}

public navigate(commands: any[]): void {
    this.ngZone.run(() => this.router.navigate(commands)).then();
}

Alternatively if you define routing with in app module, it works fine. app.routing.ts

import { Routes, RouterModule } from '@angular/router';

const appRoutes: Routes = [
    //add app routes here
];

export const routing = RouterModule.forRoot(appRoutes);

I also received the same warning by using ngrx/effects:

@Effect({ dispatch: false })
redirectToLogin$: Observable<Action> = this.actions$.pipe(
  ofType<RedirectToLogin>(AuthActionTypes.REDIRECT_TO_LOGIN),
    tap(() => {
      this.router.navigate(['/signin']);
     })
  );

As a workaround for tests, I created small proxy that runs all methods of router within NgZone.

import { NgZone } from '@angular/core';
import { Router } from '@angular/router';
import { isFunction } from 'lodash';

/**
 * Wrapper of Angular router (only for testing purpose)
 * Meant to run all router operations within Angular zone
 *  * Keep change detection enabled
 *  * Avoids flooded console with warnings
 *    https://github.com/angular/angular/issues/25837
 *
 * @see Router
 */
export function wrapRouterInNgZone(router: Router, ngZone: NgZone): Router {
  return new Proxy(router, {
    get(target: Router, p: PropertyKey): unknown {
      const invokedProperty = target[p];
      if (!isFunction(invokedProperty)) {
        return invokedProperty;
      }

      return function(...args: Array<unknown>): unknown {
        return ngZone.run(() => invokedProperty.apply(target, args));
      };
    },
  });
}

Note: unknown is replacement for any in TypeScript 3.0.

Usage:

  let router: Router;
  beforeEach(() => {
     router = wrapRouterInNgZone(
      TestBed.get(Router),
      TestBed.get(NgZone),
    );
  });

  // no flood console:
  it('some test', fakeAsync(() => {
    router.navigate(['']);
    tick();
  }));

nice workaround!

@trotyl, yeah, several other issues have the same problems, in test, NgZone.isInAngularZone() will be false, we may need a TestNgZone to work with ComponentFixture.autoDetectChanges

I am getting this in my actual code. Not just in the tests. this.router.navigate([this.returnUrl])

I usually do this,

      const originalNavigate = TestBed.inject(Router).navigate;
      spyOn(TestBed.inject(Router), 'navigate').and.callFake((...options) => {
        new NgZone({}).run(() => {
          originalNavigate.apply(TestBed.inject(Router), options);
        });
      });

this ensures that navigation is performed inside ngZone. thanks to @fpellanda

I tried this workaround to avoid the warning:

  it('should navigate "" to "/app"', async() => {
    const success = await fixture.ngZone.run(() => router.navigateByUrl(''));
    expect(success).toBeTruthy();
    expect(location.path()).toBe('/app');
  });

I suggest as workaround to run navigate in fixture.ngZone with a spy:

    // run navigate in ngZone
    beforeEach(() => {
      const originalNavigate = TestBed.get(Router).navigate;
      spyOn(TestBed.get(Router), 'navigate').and.callFake((...options) => {
        fixture.ngZone.run(() => {
          originalNavigate.apply(TestBed.get(Router), options);
        });
      });
    });

Please open a new issue with minimal reproduction if you’re encountering this outside testing.

@quirogamauricio It’s the expected warning, when perform navigation outside Angular zone than no change detection will be triggered. You do can perform navigation from anywhere of the JavaScript engine, but just need to ensure it inside Angular zone.