Testing the router

Continues from Writing a test. Routing works with the native stack, the same providers the app uses, and NativeStackOutlet as the root. Pressing a nativeRouterLink pushes the next screen:

import { Component, input } from '@angular/core';
import { withComponentInputBinding } from '@angular/router';
import { Pressable, Text } from '@ng-native/components';
import {
  NativeRouterLink,
  NativeStackOutlet,
  provideNativeRouter,
  withLinkParent,
} from '@ng-native/router';
import { render, screen, userEvent } from '@ng-native/testing';
import { expect, it } from 'vitest';

@Component({
  selector: 'app-home',
  imports: [NativeRouterLink, Pressable, Text],
  template: `
    <pressable accessibilityRole="button" nativeRouterLink="/about">
      <text>About us</text>
    </pressable>
  `,
})
class Home {}

@Component({ selector: 'app-about', imports: [Text], template: '<text>We make apps</text>' })
class About {}

@Component({
  selector: 'app-root',
  imports: [NativeStackOutlet],
  template: '<native-stack-outlet />',
})
class App {}

it('pushes a screen when a link is pressed', async () => {
  await render(App, {
    providers: [
      provideNativeRouter([
        { path: '', component: Home },
        { path: 'about', component: About },
      ]),
    ],
  });

  await userEvent.press(screen.getByRole('button', { name: 'About us' }));

  expect(await screen.findByText('We make apps')).toBeTruthy();
});

With eagerly declared routes the pushed screen is already there when the press resolves, but a loadComponent, a guard or a resolver each put the screen a few tasks further away, and findByText keeps working when a route gains one. A pushed screen stacks on top of the one before it, as it does on a device, so the home screen is still in the tree afterwards: assert on what arrived rather than on what left. The native stack's transition and its navigation bar are drawn by the platform, and a test sees neither.

A page that takes a route parameter as an input needs withComponentInputBinding() in the test's providers, the same as the app does, or the input is never set and a required one throws on first render:

@Component({ selector: 'app-trip', imports: [Text], template: '<text>Trip {{ id() }}</text>' })
class Trip {
  readonly id = input.required<string>();
}

it("passes Angular's router features through, so a route parameter arrives as an input", async () => {
  await render(App, {
    providers: [
      provideNativeRouter(
        [
          { path: '', redirectTo: 'trip/lisbon', pathMatch: 'full' },
          { path: 'trip/:id', component: Trip },
        ],
        withComponentInputBinding(),
      ),
    ],
  });

  expect(await screen.findByText('Trip lisbon')).toBeTruthy();
});

provideNativeRouter's native-specific features, such as withLinkParent, sit alongside Angular's own router features in the same call, and both still route:

it('takes its native option as a feature beside Angular ones, and still routes', async () => {
  await render(App, {
    providers: [
      provideNativeRouter(
        [
          { path: '', component: Home },
          { path: 'about', component: About },
        ],
        withLinkParent((url) => (url.startsWith('/about/') ? '/about' : null)),
        withComponentInputBinding(),
      ),
    ],
  });

  await userEvent.press(screen.getByRole('button', { name: 'About us' }));

  expect(await screen.findByText('We make apps')).toBeTruthy();
});

Give provideNativeRouter the same router features the app does - a test whose providers are missing one the app declares will pass for routes that do not need it and fail, confusingly, only on the ones that do.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.