7
Testing the router

This chapter covers

  • An overview of the router and what it does in single-page applications
  • Configuring the router for an Angular application
  • Testing components that use the router
  • Testing advanced router configuration options

Almost every Angular application needs a way to convert the web address in the location bar to some destination in the web application, and that’s where the router comes in.

In simple applications, such as the Contacts app you use throughout this book, the router configuration may only involve associating a URL path with a component, but you can do much more with the router. For example, your application may have sections that a user can only access if they have permission to see the data. A router can verify the user’s credentials before even loading that part of the application.

In this chapter, you’ll learn more about the router, including how to configure it, and go through some examples of testing both the router code and components that need to use it. Understanding how to test the router in your application builds on skills you’ve already learned for testing components and services, so make sure you understand the chapters on those topics before continuing with the material in this chapter.

7.1 What is the Angular router?

The Angular router is a part of the Angular framework that converts a web address to a specific view of the Angular application; it’s integral to the Angular application architecture. In practice, all Angular applications need to define a router, so the router a key part of Angular development. When a user goes to a URL for an Angular application, the router parses the URL to determine which component and data to load. Whenever a URL changes, whether someone enters it directly or clicks a link, the router sets up the appropriate application state. Each segment of the path encodes information about that state. The Angular router examines the path and breaks it into a series of tokens that you can use for loading components and making data available to them.

Suppose the Contacts app is available at www.example.com, and a user wants to edit the contact for the person with ID 5. The page for doing so is http://www.example.com/app/contacts/5/edit. Figure 7.1 shows how the website URL corresponds to the router configuration. First, notice that the base path, which you configure in the HTML of your application using the <base href> tag, isn’t included as part of the router configuration. Angular uses the base path as the starting point (or default view) of the application. All the other URLs in the application will be under the base path. Second, notice that the dynamic portion of the URL, the contactId, has a colon in front of it. The router will use the name of this label to send this parameter along to any components that need to use it.

c07_01.png

7.1 How the application URL corresponds to the router configuration

The router configuration could be simple for an application that has only a handful of routes, such as the Contacts app, or it could be long and spread among many different files, say for a large enterprise application. As we explain the details, route configuration refers to the configuration of a single URL, and the collection of all the route configurations is called the router configuration. A router configuration will contain one or more route configurations.

The process of starting and completing a route change is called its lifecycle. Over the course of the route change lifecycle, multiple opportunities are available for checking whether the route change can continue. Generically, these are called lifecycle hooks, but they’re known in Angular as route guards. Whenever the router loads a new route, components configured for that route have access to the route’s parameters. These parameters have no prescribed use, but they’re often used for retaining information about something the component is displaying (for example, pagination or table sorting information).

The router configuration affects how components will function throughout an application, so it’s useful to add tests to validate the ones that interact with it, or to test the route guards that the router itself fires.

You can test code related to the router in two different ways. The first is by testing how components receive values from the router or call actions on it. The second is by testing the application code invoked by the route guards

Imagine the router from a component’s point of view. A component may need route information passed into it, or it may need to tell the router to perform navigation actions. When writing tests from this approach, you’ll use a fake router configuration (the components won’t care that it’s fake) and write tests to make sure the components work together correctly.

Now think of testing from the router’s point of view. Suppose an unauthorized user is trying to access the app’s administration panel. You’ll want to use a router configuration that checks to see if the user is logged in and has permission to view the content before allowing them to continue. In this case, the router configuration itself should be the subject of the test because it drives which route guards the router calls.

You test router and component interactions by following the same pattern of testing you’d use for any other parts of Angular, so what you’ve already learned about testing components and services applies. The only difference is that in these tests, you’ll use the Angular RouterTestingModule, a built-in testing utility that was created for such scenarios.

In either case, you’ll need to have a router configuration to test your code. The next section will help you create one.

7.1.1 Configuring the router

You need to have a router configuration to use the router. During testing, you can use your application’s router configuration, but usually you’ll create a configuration meant for testing so you’ll have more control over your tests.

The most basic router configuration associates a URL path with a component. Listing 7.1 shows the router configuration for the Contacts app, which includes the default home page, pages for adding and modifying contact entries, and a default for an unknown route. In some of the path entries, you’ll see part of the path with a colon in front of it (for example, 'edit/:id'). The colon prefix tells the router that the value in that part of the path is dynamic and the router should make that data available to all the components. Testing a configuration this simple has little worth because there’s no behavior to test. The only reason why it might be worth it to write a test is to provide an extra layer of control when changing any of the path information. We recommend writing tests only when a particular route configuration is more complex, such as when using route guards. It’s valuable to test a route if the route behaves differently under various conditions.

Listing 7.1 Router configuration for the Contacts application

export const routes: Routes = [
  { path: '', component: ContactsComponent },    ①  
  { path: 'add', component: NewContactComponent },
  { path: 'contacts', component: ContactsComponent },
  { path: 'contact/:id', component: ContactDetailComponent },    ②  
  { path: 'edit/:id', component: ContactEditComponent },    ②  
  { path: '**', component: PageNotFoundComponent }    ③  
];

Typically, testable code related to the router concerns some type of state change, such as attempting to navigate to a route or loading a component after a transition (including initial load). In a test, you only need to configure the router properties that the test requires. Because of this, it’s unusual to use your application’s live router configuration for unit testing. Your unit tests usually will contain as much route information as needed to execute a single group of tests.

7.1.2 Route guards: the router’s lifecycle hooks

You may want to add some application before, during, or after the route change. To help you do this, the router has a defined set of methods it looks for on a route configuration. When the route change happens, the router looks at the route configuration to see if the method exists. If it does, it executes the method and uses its return value to determine whether the route change can proceed.

Route guards make it easy to coordinate application behavior as the user moves from place to place in the application. When a route guard is defined, the router will pass the route guard method some parameters (which vary by route guard) and will wait for a return value that tells the router whether it can continue to the next step in loading the route or whether it should abort the route change attempt.

The order of execution for route guards is as follows:

  • CanDeactivate—Runs before a user can leave the current route. This is useful for prompting the user if they have any unsaved forms or other unfinished activities.
  • CanActivateChild—For any route that has child routes defined, this hook runs first. This is useful if a feature has some sections that are restricted to users based on permissions.
  • CanActivate—This hook must return true for the route to continue loading. Like with CanActivateChild, this hook is useful for keeping unauthorized users from loading application features.
  • Resolve—If a user is allowed to activate the attempted route, the resolve method is used to load data prior to activating the route itself. The data is then available from the ActivatedRoute service in the routed components.

In addition to these route guards, the CanLoad guard is useful for dynamically loading only the parts of an application relevant to a user’s needs (also known as lazy loading).

This wraps up the introduction to the Angular router, router configuration, and route guards. The next section covers testing your application code related to the router.

7.2 Testing routed components

In this section, you’ll see two different examples of tests related to interactions between components and the router. The first example is a component that makes dynamic calls to the router service. The test will check the calls to the router to make sure the component is creating the dynamic paths correctly. The second example is a component that receives parameters passed from the ActivatedRoute service.

Because the Contacts app doesn’t use advanced routing features, the examples we look at in this chapter will be standalone code, but you’ll still be able to run them on your computer.

7.2.1 Testing router navigation with RouterTestingModule

Suppose you have a component that dynamically generates a navigation menu. Your test finds the menu element and clicks it. You expect the next route to load with the right parameters, but clicking the link changes the URL and causes the tests to fail in the Karma test runner. How do you work around this problem?

When you’re testing a component that could cause a navigation event (as you are here by clicking a link), use RouterTestingModule to keep Angular from loading the navigation target component. This module intercepts navigation attempts and allows you to check their parameters. Could you do so manually by providing your own mock router service? Yes, but it’s easier to use the helper that Angular provides. In this section, the component you’re testing generates links dynamically based on a menu configuration, and the test uses RouterTestingModule to confirm that the link is working and the target is correct.

Generate links

The NagivationMenu component in the following example shows how to test components that interact with the router. If you were writing this component for production, it would have other behaviors, like animation or nested menus. Remember, in this example, you’re testing how the component behaves with respect to the router, not the router configuration itself.

In listing 7.2, notice when the NavigationMenu component initializes (ngOnInit), it receives its configuration from NavConfigService containing route information, which it uses to generate a list of links using the RouterLink directive. The test for this component makes sure that NavConfigService generates the links correctly and they link to the expected targets.

Listing 7.2 The NavigationMenu component

@Component({ 
  selector: 'navigation-menu', 
  template: '<div><a *ngFor="let item of menu" [id]="item.label" [routerLink]="item.path">{{ item.label }}</a></div>' 
}) 
class NavigationMenu implements OnInit { 
  menu: any; 
  constructor(private navConfig: NavConfigService) { } 
  ngOnInit() { 
    this.menu = this.navConfig.menu; 
  } 
} 

Configure routes and create test components

Although the NavigationMenu component is simple, tests using RouterTestingModule require some setup. You need to configure at least two routes for the route configuration: the initial route and a second route to be the target of the navigation attempt. The initial route loads the component under test, and although the target doesn’t affect the outcome of the test, it should be a valid target for whatever link the component constructs.

It’s helpful to create a simple component for the test that only exists to be its target. You could import another component in your application for this setup, but defining a simple target component in your test, as shown in the following listing, reduces the complexity and the number of things that could go wrong.

Listing 7.3 Creating test components for NavigationMenu test

@Component({    ①  
  selector: 'app-root',    ①  
  template: '<router-outlet></router-outlet>',    ①  
})    ①  
class AppComponent { }    ①  

@Component({    ②  
  selector: 'simple-component',    ②  
  template: 'simple'    ②  
})    ②  
class SimpleComponent { }    ②  

Set up routes

The last step in the setup is to import RouterTestingModule, which will spy on navigation calls and make their results available for checking in the tests. RouterTestingModule takes an optional router configuration.

Instead of using the application’s router configuration, it’s better to create a fake router configuration so you don’t need to import all of the components into your test and configure them. You should make the test as simple as possible by avoiding pulling in dependencies you don’t need. For this test, you’ll need two routes: the default route, which loads the component under test, and the target route.

Before each test, you configure TestBed with modules and mocks needed for the tests themselves. You run this prior to each test to make sure the values the tests create are reset between each instance, so the tests won’t interact with one another. To avoid repetition in each test, the router will load the initial page and then advance the Angular application to settle any asynchronous events.

When a navigation event occurs, it resolves asynchronously, and you have to account for this in the test. The example in listing 7.4 uses the Angular fakeAsync helper to handle settling asynchronous calls. When using fakeAsync, you have to resolve outstanding asynchronous calls manually with the flush method, and then update the fixture with detectChanges. When writing a full suite of unit tests, you can avoid repetition by creating a helper method to call flush and detectChanges together. This test defines a helper function called advance that makes the test code a bit easier to read.

Listing 7.4 Setup code to run before each test

let router: Router;
let location: Location;

let fixture; 
let router: Router; 
let location: Location;
  
beforeEach(() => {
  TestBed.configureTestingModule({
    imports: [RouterTestingModule.withRoutes([    ①  
      { path: '', component: NavigationMenu },    ①  
        { path: 'target/:id', component: SimpleComponent }    ①  
      ])],
    providers: [{  
      provide: NavConfigService,  
      useValue: { menu: [{ label: 'Home', path: '/target/fakeId' }] }
    }],
    declarations: [NavigationMenu, SimpleComponent, AppComponent],
  });
});

beforeEach(fakeAsync(() => { 
  router = TestBed.get(Router); 
  location = TestBed.get(Location);
  fixture = TestBed.createComponent(AppComponent);
  router.navigateByUrl('/');    ②  
  advance();    ②  
}));

function advance(): void {    ③  
  flush(); //    ③  
  fixture.detectChanges();    ③  
}    ③  

This component involves a lot of setup for testing, so let’s do a quick review:

  • The component under test generates navigation links.
  • The setup creates two mock components to facilitate the test, one for the app fixture and one for the target.
  • The TestBed configuration uses RouterTestingModule with fake route information. Before each test, the RouterTestingModule loads the default route and updates the test fixture.

Now you can write the first test, which is pretty simple. After you’ve set up the fixture, NavigationMenu should generate links based on its input. The test in the following listing gets a copy to a link, clicks it, and then checks with the Location service to see if the path updated to the expected target.

Listing 7.5 Testing generated NavigationMenu links

it('Tries to route to a page', fakeAsync(() => {
  const menu = fixture.debugElement.query(By.css('a'));    ①  
  menu.triggerEventHandler('click', { button: 0 });    ②  
  advance();    ③  
  expect(location.path()).toEqual('/target/fakeId');    ④  
})); 

Why did it take so much setup for such a small test? Remember that the router is tightly integrated into the backbone of an Angular application. Because of that, it takes extra work to isolate it from its configuration and from any side effects caused by navigating to different routes.

In the next section, we’ll cover another case of testing interactions between a component and the router, but this time the component receives values from the router.

7.2.2 Testing router parameters

Deep linking is the ability to link to a view of specific content in a website or a web application. A URL for a deep link embeds information about the content (usually through an identifier), sorting and filtering parameters, and sometimes pagination parameters. For example, a car sales web app makes it possible to deeply link into a specific model and year range sorted by price. You can save and share that link, and although the content is generated dynamically, the parameters are always the same.

Testing ActivatedRoute components

In an Angular application, you implement deep linking through route parameters. The router captures these parameters and makes them available to any component that needs them through the ActivatedRoute service. Whenever a user navigates to a different route, the ActivatedRoute service makes information about the route change available to components that use the service.

One of the most basic uses of ActivatedRoute is to pass along a unique identifier for further content lookup. For example, in the Contacts app, the ContactEdit component uses ActivatedRoute to get the identifier for a contact. When the user navigates to http://localhost/edit/1, the router compares the path to the router configuration and extracts the last part of the path to be used as the value of id. After that, the router publishes this value to ActivatedRoute, which sends the update to all subscribing components.

The test in this section will use a simplified example of how to test a component that depends on ActivatedRoute. Components can subscribe to the values that ActivatedRoute publishes either as an observable or as a snapshot, an object holding the last updated values for all parameters. Subscribing to an observable is a good choice for a long-lived component that needs to update regularly based on route changes. (See chapter 6, section 6.6.) Using the snapshot is simpler and is a good choice for when a component only needs to use route parameters when it’s constructed. The testing setup for either is similar, but this example will use a snapshot.

The example in listing 7.6 is simplified for illustration purposes. Normally, a component for editing data would have a form and controls for modifying and saving the data, but here you’re focusing on loading the Contact ID from the ActivatedRoute service and using it in the template.

Listing 7.6 Simplified ContactEdit component using ActivatedRoute

@Component({
   selector: 'contact-edit',
   template: '<div class="contact-id">{{ contactId }}</div>',    ①  
})
class ContactEditComponent implements OnInit {
private contactId: number;
constructor(private activatedRoute: ActivatedRoute) { }    ②  
ngOnInit () {
  this.contactId = this.activatedRoute.snapshot.params['id'];    ③  
}
}

Setting up the test

Compared with testing components that cause navigation events to occur, setting up the test for ActivatedRoute is much simpler. This component only listens for data and then renders its template.

The only mock this test requires is a mock for ActivatedRoute. TestBed will provide the mock value to the component. Notice that in the following listing, the test, unlike the NavigationMenu component test, doesn’t use RouterTestingModule. It isn’t necessary for this test because no navigation is occurring.

Listing 7.7 Setting up the ActivatedRoute mock for component testing

let fixture;
const mockActivatedRoute = {    ①  
  snapshot: {    ①  
    params: {    ①  
      id: 'aMockId'    ①  
    }    ①  
  }    ①  
};    ①  

 beforeEach(() => {
   TestBed.configureTestingModule({
    providers: [
      { provide: ActivatedRoute, useValue: mockActivatedRoute}    ②  
    ], 
    declarations: [ContactEditComponent],
  });
});

beforeEach(async(() => {    ③  
  fixture = TestBed.createComponent(ContactEditComponent);    ③  
  fixture.detectChanges();    ③  

}));    ③  

Testing the component

When the router resolves a navigation event, ActivatedRoute produces a snapshot of the route data associated with the component when the component is instantiated. You can avoid incorporating the RouterTestingModule into this type of test by mocking the router snapshot that ActivatedRoute normally supplies. Providing your own test snapshot avoids the extra setup work that would be required to have the router generate the snapshot automatically. As long as you know what the snapshot looks like, you can use a mock instead, which simplifies the test.

As shown in listing 7.8, the test involves initializing the component and checking the result. This is another asynchronous test. When the component’s ngOnInit method is activated, the TestBed returns the mock snapshot for ActivatedRoute. This process happens asynchronously, and this test is easier to read using the Angular async test helper instead of the fakeAsync helper. The test checks the value of the Contact ID after it’s rendered in the form to ensure that it’s the value coming from the ActivatedRoute service.

Listing 7.8 Testing the ContactEdit component loading route parameters

it('Tries to route to a page', async(() => {    ①  
  let testEl = fixture.debugElement.query(By.css('div'));    ②  
  expect(testEl.nativeElement.textContent).toEqual('aMockId');    ③  
}));

Because this component uses the ActivatedRoute snapshot, setting up the test is easy. If your component uses properties of ActivatedRoute that emit observables, then your mock would be an observable emitting the mocked properties, as shown in the following listing.

Listing 7.9 Using a mock observable for ActivatedRoute

const paramsMock = Observable.create((observer) => {    ①  
  observer.next({    ①  
    id: 'aMockId'    ①  
  });    ①  
  observer.complete();    ①  
});    ①  

beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        { provide: ActivatedRoute, useValue: { params: paramsMock }}    ②  
      ],
      declarations: [ContactEditComponent],
    });
  });

Using a mock observable

Whether you use a route snapshot or create an observable for your mock ActivatedRoute, testing a component that reads values from ActivatedRoute is straightforward.

We’ve covered all you need to know about testing routes from the point of view of a component. The rest of this chapter will look at testing special features of routes that are especially useful in an enterprise single-page application—route guards and resolved data services.

7.3 Testing advanced routes

The router can perform functions that enable Angular applications to implement enterprise application-level features. With the router, it’s easy to route unauthenticated users to a login page, pre-fetch data before loading components, and lazy-load other modules to help reduce application start time. As discussed earlier, the router configuration can define route guards that the router calls before navigation events. Another feature of the router is pre-loading, or resolving, data before activating components.

7.3.1 Route guards

Enterprise web applications, unlike other web applications, have user authentication, user roles, privileges, and other means of allowing users to access features, or keeping them from doing so. The Angular router makes it easy to add application logic to make sure users can only access the features they’re allowed to access. The mechanism for this system is called a route guard. Route guards are specialized services that the router runs prior to a router navigation event. Route guards are simple in design—if a route guard method returns true, the navigation attempt can continue. Otherwise, the navigation attempt fails.

Why would you want to use a route guard instead of adding these functions directly to a component? Because route guards exist outside of your component, they let you separate the access or permissions functions from the core functionality of the component. Also, if you define route-guard services separately from components, you can reuse the validation logic by configuring your route configuration rather than having to add component logic.

You can divide route guards into two main categories: those that check before a user tries to leave a current route, and those that check before a user can load a new route. We’ll cover a third type of route guard, called a resolver guard, in the next section.

In listing 7.10, you’ll see a CanActivate route guard. To use this route guard, a route configuration entry specifies a new property called canActivate, which takes an array of route guards. The system under test in this example is AuthenticationGuard, a route guard that depends on the UserAuthentication service to see if a theoretical user is allowed to access the route. If not, the navigation attempt fails.

Listing 7.10 AuthenticationGuard service

@Injectable()    ①  
class AuthenticationGuard implements CanActivate {    ①  
  constructor(private userAuth: UserAuthentication) {}    ①  
  canActivate(): Promise<boolean> {    ①  
    return new Promise((resolve) =>    ①  
       resolve(this.userAuth.getAuthenticated());    ①  
    );    ①  
  }    ①  
}    ①  

@Injectable()    ②  
class UserAuthentication {    ②  
  private isUserAuthenticated: boolean = false;    ②  
  authenticateUser() {    ②  
    this.isUserAuthenticated = true;    ②  
  }    ②  
  getAuthenticated() {    ②  
    return this.isUserAuthenticated;    ②  
  }    ②  
}    ②  

We’ve chosen to show UserAuthentication as a separate service. This makes the example a little more complicated, but it’s good to reinforce the idea that you should separate a service that handles user authentication—which in real life would make network calls and have other complexities—from the route guard service itself. Smaller services are easier to test, and with this level of separation, it’s easy to mock the dependencies for the system under test.

Setting up the test is similar to the setup for the NavigationMenu test. This test requires a component for initializing the application fixture and a simple component to act as the target for the navigation attempt. (Refer to listing 7.3 for an example of this type of test component.) What’s new in the test setup in listing 7.11? First, notice that this test again uses RouterTestingModule with a configuration specific to testing AuthenticationGuard. As mentioned before, it’s better to create a test router configuration, because it’s less complicated and more reliable than trying to use the application’s router configuration. The test configuration specifies the canActivate property, which activates the code that’s the focus of this test.

Listing 7.11 Setting up the AuthenticationGuard test

beforeEach(() => {
  TestBed.configureTestingModule({
    imports: [RouterTestingModule.withRoutes([    ①  
      { path: '', component: AppComponent },
      {
        path: 'protected',
        component: TargetComponent,
        canActivate: [AuthenticationGuard],    ②  
      }
      ])],
    providers: [AuthenticationGuard, UserAuthentication],
    declarations: [TargetComponent, AppComponent],
  });

  router = TestBed.get(Router);
  location = TestBed.get(Location);
  userAuthService = TestBed.get(UserAuthentication);    ③  
});

beforeEach(fakeAsync(() => {
  fixture = TestBed.createComponent(AppComponent);
  router.initialNavigation();
}));

AuthenticationGuard will check the UserAuthentication service when the route navigation attempt occurs, so you capture a reference to the service to control it during the test. The testing setup is finished, so what remains are two tests, shown in listing 7.12. Both will try to navigate to the protected route. The first will try without authenticating, and in the second you’ll manually authenticate the user. After the navigation attempt, the tests check the Location service for the expected result.

Listing 7.12 AuthenticationGuard tests

it('tries to route to a page without authentication', fakeAsync(() => {
  router.navigate(['protected']);    ①  
  flush();
  expect(location.path()).toEqual('/');
}));

it('tries to route to a page after authentication', fakeAsync(() => {
  userAuthService.authenticateUser();    ②  
  router.navigate(['protected']);
  flush();
  expect(location.path()).toEqual('/protected');
}));

These tests make sure the route guards behave correctly under different application scenarios. You could use other approaches for testing route guards. For example, you could spy on the canActivate method to make sure it’s called as expected and is returning the correct response. Both methods work, so use whichever you prefer.

7.3.2 Resolving data before loading a route

Sometimes you’ll want to load data before activating a component. The resolver route guard specifies an object of key-value pairs, where the keys are the names of data properties and the values are route guard services that fetch the data. Once all services have resolved, the router makes the data available to components through the ActivatedRoute service on the data property. In the following listing, you can see an example where user preferences and contacts data is loaded prior to the route change via UserPreferencesResolver and ContactsResolver respectively.

Listing 7.13 Configured resolver route guard

{ 
  path: 'contacts', 
  component: TargetComponent,
  resolve: { 
    userPreferences: UserPreferencesResolver, 
    contacts: ContactsResolver 
  } 
}

Testing a resolver route guard uses the techniques we already covered in section 7.3, so we won’t be providing a separate example. To test these resolvers, follow the same process as the canActivate route guard. The resolvers themselves usually interact with some other data service, for which you’ll want to provide mock services. Then in the unit test, you’ll inject the ActivatedRoute service and check that the values available on ActivatedRoute.snapshot.data match your expected values.

Summary

  • The Angular router is like the backbone of your application. It takes a URL and figures out which components to load based on the URL segments.
  • Angular components can navigate to other parts of an application by including the RouterLink directive. When testing navigation components, you use the Location service to verify the navigation path is correct.
  • Angular components also can receive route information from the ActivatedRoute service. You saw an example of the test configuration required to test a component that depends on ActivatedRoute values.
  • Whenever any route change occurs, the router can check if the route change is allowed to happen. Route guards are methods that either let the route change continue or stop it from happening.
  • The RouterTestingModule helps you to write tests for your components that interact with the router by providing test router configurations and inspecting the calls to the router itself.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.141.199.243