Component Testing: Routing Components (DRAFT) 5.1


If you notice any issues with this page, please report them.

This page describes how to test routing components using real or mock routers. Whether or not you mock the router will, among other reasons, depend on the following:

  • The degree to which you wish to test your component in isolation
  • The effort you are willing to invest in coding mock router behavior for your particular tests

Running example

This page uses the heroes app from part 5 of the tutorial as a running example.

Using a mock router

To test using a mock router, you must add the mock router class to the providers list of the generated root injector, as described in the section on Component-external services: mock or real:

toh-5/test/heroes.dart (excerpt)

import 'package:angular_tour_of_heroes/src/hero_list_component.template.dart'
    as ng;
import 'package:angular_tour_of_heroes/src/hero_service.dart';
import 'package:angular_tour_of_heroes/src/route_paths.dart';
import 'package:mockito/mockito.dart';
import 'package:pageloader/html.dart';
import 'package:test/test.dart';

import 'heroes.template.dart' as self;
import 'heroes_po.dart';
import 'utils.dart';

NgTestFixture<HeroListComponent> fixture;
HeroesPO po;

@GenerateInjector([
  ClassProvider(HeroService),
  ClassProvider(Router, useClass: MockRouter),
])
final InjectorFactory rootInjector = self.rootInjector$Injector;

void main() {
  final injector = InjectorProbe(rootInjector);
  final testBed = NgTestBed.forComponent<HeroListComponent>(
      ng.HeroListComponentNgFactory,
      rootInjector: injector.factory);

  setUp(() async {
    fixture = await testBed.create();
    final context =
        HtmlPageLoaderElement.createFromElement(fixture.rootElement);
    po = HeroesPO.create(context);
  });

  tearDown(disposeAnyRunningTest);
  // ···
}

The InjectorProbe will allow individual tests to access the test context injector. You’ll see an example soon.

toh-5/test/utils.dart (InjectorProbe)

class InjectorProbe {
  InjectorFactory _parent;
  Injector _injector;

  InjectorProbe(this._parent);

  InjectorFactory get factory => _factory;
  Injector get injector => _injector ??= _factory();

  Injector _factory([Injector parent]) => _injector = _parent(parent);
  T get<T>(dynamic token) => injector?.get(token);
}

The following one-line class definition of MockRouter illustrates how easy it is to use the Mockito package to define a mock class with the appropriate API:

toh-5/test/utils.dart (MockRouter)

class MockRouter extends Mock implements Router {}

Selecting a hero from the heroes list causes a “mini detail” view to appear:

Mini Hero Detail

Clicking the “View Details” button should cause a request to navigate to the corresponding hero’s detail view. The button’s click event is bound to the gotoDetail() method which is defined as follows:

toh-5/lib/src/hero_list_component.dart (gotoDetail)

Future<NavigationResult> gotoDetail() =>
    _router.navigate(_heroUrl(selected.id));

In the following test excerpt:

  • The setUp() method selects a hero.
  • The test expects a single call to the mock router’s navigate() method, with an appropriate path a parameter.

void selectedHeroTests(InjectorProbe injector) { const targetHero = {'id': 15, 'name': 'Magneta'}; setUp(() async { await po.selectHero(4); }); ··· test('go to detail', () async { await po.gotoDetail(); final mockRouter = injector.get<MockRouter>(Router); final c = verify(mockRouter.navigate(captureAny)); expect(c.captured.single, RoutePaths.hero.toUrl(parameters: {idParam: '${targetHero['id']}'})); }); ··· } import 'dart:async'; import 'package:pageloader/pageloader.dart'; import 'utils.dart'; part 'heroes_po.g.dart'; @PageObject() abstract class HeroesPO { HeroesPO(); factory HeroesPO.create(PageLoaderElement context) = $HeroesPO.create; @First(ByCss('h2')) PageLoaderElement get _title; @ByTagName('li') List<PageLoaderElement> get _heroes; @ByTagName('li') @WithClass('selected') PageLoaderElement get _selected; @First(ByCss('div h2')) PageLoaderElement get _miniDetailHeading; @ByTagName('button') PageLoaderElement get _gotoDetail; String get title => _title.visibleText; Iterable<Map> get heroes => _heroes.map((el) => _heroDataFromLi(el.visibleText)); Future<void> selectHero(int index) => _heroes[index].click(); Map get selected => _selected.exists ? _heroDataFromLi(_selected.visibleText) : null; String get myHeroNameInUppercase { if (!_miniDetailHeading.exists) return null; final text = _miniDetailHeading.visibleText; final matches = RegExp((r'^\s*(.+) is my hero\s*$')).firstMatch(text); return matches[1]; } Future<void> gotoDetail() => _gotoDetail.click(); Map<String, dynamic> _heroDataFromLi(String liText) { final matches = RegExp((r'^(\d+) (.*)$')).firstMatch(liText); return heroData(matches[1], matches[2]); } }

The app dashboard, from part 5 of the tutorial, supports direct navigation to hero details using router links:

toh-5/lib/src/dashboard_component.html (excerpt)

<a *ngFor="let hero of heroes" class="col-1-4"
   [routerLink]="heroUrl(hero.id)">
  <div class="module hero">
    <h4>{{hero.name}}</h4>
  </div>
</a>

Setting up the initial providers list is a bit more involved. Because the dashboard uses the RouterLink directive, you need to add all the usual router providers (routerProviders). Since you’ll be testing outside of the context of the app’s index.html file, which sets the <base href>, you also need to provide a value for appBaseHref:

toh-5/test/dashboard.dart (providers)

final testBed = NgTestBed.forComponent<DashboardComponent>(
    ng.DashboardComponentNgFactory,
    rootInjector: injector.factory);

The test itself is similar to the one used for heroes, with the exception that navigating a routerLink causes the router’s navigate() method to be called with two arguments. The following test checks for expected values for both arguments:

test('select hero and navigate to detail', () async { final mockRouter = injector.get<MockRouter>(Router); clearInteractions(mockRouter); await po.selectHero(3); final c = verify(mockRouter.navigate(captureAny, captureAny)); expect(c.captured[0], '/heroes/15'); expect(c.captured[1], isNavParams()); // empty params expect(c.captured.length, 2); }); import 'dart:async'; import 'package:pageloader/pageloader.dart'; part 'dashboard_po.g.dart'; @PageObject() abstract class DashboardPO { DashboardPO(); factory DashboardPO.create(PageLoaderElement context) = $DashboardPO.create; @First(ByCss('h3')) PageLoaderElement get _title; @ByTagName('a') List<PageLoaderElement> get _heroes; String get title => _title.visibleText; Iterable<String> get heroNames => _heroes.map((el) => el.visibleText); Future<void> selectHero(int index) => _heroes[index].click(); }

How might you write the tests shown in this section using a real router? That’s covered next.

Using a real router

Testing the app root

Provisioning and setup for use of a real router is similar to what you’ve seen already:

toh-5/test/app.dart (provisioning and setup)

final injector = InjectorProbe(rootInjector);
final testBed = NgTestBed.forComponent<AppComponent>(ng.AppComponentNgFactory,
    rootInjector: injector.factory);

setUp(() async {
  fixture = await testBed.create();
  router = injector.get<Router>(Router);
  await router?.navigate('/');
  await fixture.update();
  final context =
      HtmlPageLoaderElement.createFromElement(fixture.rootElement);
  appPO = AppPO.create(context);
});

Where routerProvidersForTesting is defined as follows:

toh-5/test/utils.dart (routerProvidersForTesting)

const /* List<Provider|List<Provider>> */ routerProvidersForTesting = [
  ValueProvider.forToken(appBaseHref, '/'),
  routerProviders,
  // Mock platform location even with real router, otherwise sometimes tests hang.
  ClassProvider(PlatformLocation, useClass: MockPlatformLocation),
];

Among other things, testing the app root using a real router allows you to exercise features like deep linking:

toh-5/test/app.dart (deep linking)

group('Deep linking:', () {
  test('navigate to hero details', () async {
    await router.navigate('/heroes/11');
    await fixture.update();
    expect(fixture.rootElement.querySelector('my-hero'), isNotNull);
  });

  test('navigate to heroes', () async {
    await router.navigate('/heroes');
    await fixture.update();
    expect(fixture.rootElement.querySelector('my-heroes'), isNotNull);
  });
});

Not only are you using the real router, but the tests shown above are also testing out the use of app routes.

Testing using a real router for a non-app-root component requires more test infrastructure, as you’ll see in the next section.

Testing a non-root component

Consider testing the dashboard component using a real router. Remember that the dashboard is navigated to, and its view is displayed in the app root’s RouterOutlet, which gets initialized with the full set of app routes:

toh-5/lib/app_component.dart (template)

template: '''
  <h1>{{title}}</h1>
  <nav>
    <a [routerLink]="RoutePaths.dashboard.toUrl()"
       [routerLinkActive]="'active'">Dashboard</a>
    <a [routerLink]="RoutePaths.heroes.toUrl()"
       [routerLinkActive]="'active'">Heroes</a>
  </nav>
  <router-outlet [routes]="Routes.all"></router-outlet>
''',

Before you can test a dashboard, you need to create a test component with a router outlet and a suitably (restricted) set of routes, something like this:

toh-5/test/dashboard_real_router.dart (TestComponent)

@Component(
  selector: 'test',
  template: '''
    <my-dashboard></my-dashboard>
    <router-outlet [routes]="[Routes.hero]"></router-outlet>
  ''',
  directives: [RouterOutlet, DashboardComponent],
  exports: [Routes],
)
class TestComponent {
  final Router router;

  TestComponent(this.router);
}

The test bed and test fixture are then parameterized over TestComponent rather than DashboardComponent:

toh-5/test/dashboard_real_router.dart (excerpt)

NgTestFixture<TestComponent> fixture;
DashboardPO po;
Router router;

@GenerateInjector([
  ClassProvider(HeroService),
  routerProvidersForTesting,
])
final InjectorFactory rootInjector = self.rootInjector$Injector;

void main() {
  final injector = InjectorProbe(rootInjector);
  final testBed = NgTestBed.forComponent<TestComponent>(
      self.TestComponentNgFactory,
      rootInjector: injector.factory);
  // ···
}

One way to test navigation, is to log the real router’s change in navigation state. You can achieve this by registering a listener:

toh-5/test/dashboard_real_router.dart (setUp)

List<RouterState> navHistory;

setUp(() async {
  fixture = await testBed.create();
  router = fixture.assertOnlyInstance.router;
  navHistory = [];
  router.onRouteActivated.listen((newState) => navHistory.add(newState));
  final context =
      HtmlPageLoaderElement.createFromElement(fixture.rootElement);
  po = DashboardPO.create(context);
});

Using this navigation history, the go-to-detail test illustrated previously when using a mock router, can be written as follows:

toh-5/test/dashboard_real_router.dart (go to detail)

test('select hero and navigate to detail + navHistory', () async {
  await po.selectHero(3);
  await fixture.update();
  expect(navHistory.length, 1);
  expect(navHistory[0].path, '/heroes/15');
  // Or, using a custom matcher:
  expect(navHistory[0], isRouterState('/heroes/15'));
});

Contrast this with the heroes “go to details” test shown earlier. While the dashboard test requires more testing infrastructure, the test has the advantage of ensuring that route configurations are declared as expected.

Alternatively, for a simple test scenario like go-to-detail, you can simply test the last URL cached by the mock platform location:

toh-5/test/dashboard_real_router.dart (excerpt)

test('select hero and navigate to detail + mock platform location', () async {
  await po.selectHero(3);
  await fixture.update();
  final mockLocation = injector.get<MockPlatformLocation>(PlatformLocation);
  expect(mockLocation.pathname, '/heroes/15');
});