Write better tests without Router mocks/stubs

Dog with a harness on gazing over some rocks at the Oregon coast.
Andrew Scott’s pup gazing at the Oregon Coast

Prior advice

Conventional wisdom might suggest that test authors should stub external dependencies like ActivatedRoute or RouterLink and spy on services and service methods like navigateByUrl. A defense for this testing strategy might be

“Relying on the real router would make them brittle. They could fail for reasons unrelated to the component.”

This isn’t entirely true, but for now we will point out one very important detail in this defense that’s often ignored. Per the documentation:

“A different battery of tests can explore whether the application navigates as expected in the presence of conditions that influence guards such as whether the user is authenticated and authorized.”

It’s important that there still exists a suite of tests that do cover the resulting behavior of executing the real code of the dependency. Unfortunately, this is most often not done. It creates a burden on developers to maintain additional suites of tests when the unit tests being written with mocks could easily cover these behaviors as well. APIs change over time, so writing and maintaining a complete set of mocks and stubs for all the dependencies is often much more difficult and brittle than just using the real ones.

Boilerplate to test with real Router instances

Testing components and services that depend on the Angular Router can be difficult. For example, a component might inject ActivatedRoute and only use it to access the Observable for queryParams. Getting the Router to create a real instance of ActivatedRoute for the test would require quite a bit of boilerplate. For example:

  1. Write a Route for the component under test ( {path: ‘**’, component: MyComponent} )
  2. Add the Router to TestBed ( TestBed.configureTestingModule)
  3. Create a wrapper component with a router-outlet to render the component
  4. Create the test ComponentFixture for the wrapper component instead of MyComponent
  5. Tell the Router to navigate to the Route with the MyComponent
  6. Use the wrapper ComponentFixture to query for MyComponent

It’s not until step 6 that you finally have your component you can use to do test assertions. With all of this complicated boilerplate, it makes sense that test authors might want to create a shortcut and share a test stub for ActivatedRoute and override the injectable in TestBed.

RouterTestingHarness to help

The RouterTestingHarness was released in Angular 15.2 to streamline tests for components and services that depend on the Router.

// Provide the router with your test route
TestBed.configureTestingModule({
providers: [
provideRouter([{path: '', component: TestCmp}])
],
});
// Create the testing harness
const harness = await RouterTestingHarness.create();
// Navigate to the route to get your component
const activatedComponent = await harness.navigateByUrl('/', TestCmp);

Notice how little boilerplate there is now. There’s no need to define a wrapper component yourself, no creation of the wrapper fixture, and no manual query for your test component. All of this is done behind the scenes in the harness.

Let’s cover a test scenario and compare the mock/stub and spy approach to what the test looks like with new RouterTestingHarness.

Mock ActivatedRoute for accessing query parameters

Let’s test a simple component that displays the value of a query parameter named search and provides a method for having the router update that parameter:

@Component({
standalone: true,
imports: [AsyncPipe],
template: `search: {{(route.queryParams | async)?.search}}`
})
class SearchCmp {
constructor(readonly route: ActivatedRoute, readonly router: Router) {}
searchFor(searchText: string) {
return this.router.navigate([], {queryParams: {'search': searchText}});
}
}

Now we write a test that ensures the template updates with the current value of the parameter. First, we need to create our stubs:

const activatedRouteStub = {queryParams: new BehaviorSubject<any>({})};
const routerStub = jasmine.createSpyObj('router', ['navigate']);
routerStub.navigate.and.callFake(
(commands, navigationExtras) =>
activatedRouteStub.queryParams.next(navigationExtras.queryParams));

Next, we set up the testing module and create our component:

TestBed.configureTestingModule({
imports: [SearchCmp],
providers: [
{provide: Router, useValue: routerStub},
{provide: ActivatedRoute, useValue: activatedRouteStub},
]
});
const fixture = TestBed.createComponent(SearchCmp);

Finally, let’s write a quick test to ensure the template updates with the current search query parameter:

await fixture.componentInstance.searchFor('books');
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toContain('search: books');

Problems

  • If there’s a RouterLink on the page, the RouterLink creates a url from the stub. Stubs are frequently not set up to fully satisfy the interface. Refactors in the Router/RouterLink can cause the test to break if they access new properties that are supposed to exist but don’t on the stub.
  • Refactoring the component to access other properties of ActivatedRoute would break the test even though the component code is not broken.
  • Refactoring the component to get the parameters in a new way would break the test even though the component code is not wrong.

Recall the argument from earlier:

“Relying on the real router would make them brittle.”

In fact, these tests are more brittle because they rely on implementation details of the component. There’s a good argument that these tests are actually harmful change detector tests. Refactors in the component implementation that do not affect the end behavior or rendered template but cause the tests to fail arguably indicate you have change detector tests.

In addition, if the Router behavior changes in a way that changes the outcome of the component’s interactions with the Router API, you would want the test to fail. If it doesn’t fail, the tests don’t catch the difference in behavior, and this can cause a production bug.

Using RouterTestingHarness instead

We’ll take this same example and convert it to use the new RouterTestingHarness. First, configure the testing module to provide the Router with a route using the SearchCmp:

TestBed.configureTestingModule({
providers: [
provideRouter([{path: '**', component: SearchCmp}])
],
});

Next, use the harness to get the component instance:

const harness = await RouterTestingHarness.create();
const activatedComponent = await harness.navigateByUrl('/', SearchCmp);

Finally, we make the updates and assertions as before:

await activatedComponent.searchFor(‘books’);
harness.detectChanges();
expect(harness.routeNativeElement?.innerHTML).toContain('search: books');

We have at least a couple of nice outcomes when writing the test this way:

  • We don’t have to write or maintain any test stubs or override providers. The Router executes and we don’t have to worry about rewriting stubs to behave similarly.
  • As an example, we could refactor the component to use matrix parameters instead and do not need to update any test stubs.

Wrap up

Using the RouterTestingHarness rather than mocking or stubbing Router dependencies will make tests more reliable. The example above only outlines one scenario, but there are many more examples where a mock or stub would make tests more brittle and/or hide actual production issues in the application due to upstream Router changes.


Write better tests without Router mocks/stubs was originally published in Angular Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.


This content originally appeared on Angular Blog - Medium and was authored by Andrew Scott

Dog with a harness on gazing over some rocks at the Oregon coast.
Andrew Scott’s pup gazing at the Oregon Coast

Prior advice

Conventional wisdom might suggest that test authors should stub external dependencies like ActivatedRoute or RouterLink and spy on services and service methods like navigateByUrl. A defense for this testing strategy might be

“Relying on the real router would make them brittle. They could fail for reasons unrelated to the component.”

This isn’t entirely true, but for now we will point out one very important detail in this defense that’s often ignored. Per the documentation:

“A different battery of tests can explore whether the application navigates as expected in the presence of conditions that influence guards such as whether the user is authenticated and authorized.”

It’s important that there still exists a suite of tests that do cover the resulting behavior of executing the real code of the dependency. Unfortunately, this is most often not done. It creates a burden on developers to maintain additional suites of tests when the unit tests being written with mocks could easily cover these behaviors as well. APIs change over time, so writing and maintaining a complete set of mocks and stubs for all the dependencies is often much more difficult and brittle than just using the real ones.

Boilerplate to test with real Router instances

Testing components and services that depend on the Angular Router can be difficult. For example, a component might inject ActivatedRoute and only use it to access the Observable for queryParams. Getting the Router to create a real instance of ActivatedRoute for the test would require quite a bit of boilerplate. For example:

  1. Write a Route for the component under test ( {path: ‘**', component: MyComponent} )
  2. Add the Router to TestBed ( TestBed.configureTestingModule)
  3. Create a wrapper component with a router-outlet to render the component
  4. Create the test ComponentFixture for the wrapper component instead of MyComponent
  5. Tell the Router to navigate to the Route with the MyComponent
  6. Use the wrapper ComponentFixture to query for MyComponent

It’s not until step 6 that you finally have your component you can use to do test assertions. With all of this complicated boilerplate, it makes sense that test authors might want to create a shortcut and share a test stub for ActivatedRoute and override the injectable in TestBed.

RouterTestingHarness to help

The RouterTestingHarness was released in Angular 15.2 to streamline tests for components and services that depend on the Router.

// Provide the router with your test route
TestBed.configureTestingModule({
providers: [
provideRouter([{path: '', component: TestCmp}])
],
});
// Create the testing harness
const harness = await RouterTestingHarness.create();
// Navigate to the route to get your component
const activatedComponent = await harness.navigateByUrl('/', TestCmp);

Notice how little boilerplate there is now. There’s no need to define a wrapper component yourself, no creation of the wrapper fixture, and no manual query for your test component. All of this is done behind the scenes in the harness.

Let’s cover a test scenario and compare the mock/stub and spy approach to what the test looks like with new RouterTestingHarness.

Mock ActivatedRoute for accessing query parameters

Let’s test a simple component that displays the value of a query parameter named search and provides a method for having the router update that parameter:

@Component({
standalone: true,
imports: [AsyncPipe],
template: `search: {{(route.queryParams | async)?.search}}`
})
class SearchCmp {
constructor(readonly route: ActivatedRoute, readonly router: Router) {}
searchFor(searchText: string) {
return this.router.navigate([], {queryParams: {'search': searchText}});
}
}

Now we write a test that ensures the template updates with the current value of the parameter. First, we need to create our stubs:

const activatedRouteStub = {queryParams: new BehaviorSubject<any>({})};
const routerStub = jasmine.createSpyObj('router', ['navigate']);
routerStub.navigate.and.callFake(
(commands, navigationExtras) =>
activatedRouteStub.queryParams.next(navigationExtras.queryParams));

Next, we set up the testing module and create our component:

TestBed.configureTestingModule({
imports: [SearchCmp],
providers: [
{provide: Router, useValue: routerStub},
{provide: ActivatedRoute, useValue: activatedRouteStub},
]
});
const fixture = TestBed.createComponent(SearchCmp);

Finally, let’s write a quick test to ensure the template updates with the current search query parameter:

await fixture.componentInstance.searchFor('books');
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toContain('search: books');

Problems

  • If there’s a RouterLink on the page, the RouterLink creates a url from the stub. Stubs are frequently not set up to fully satisfy the interface. Refactors in the Router/RouterLink can cause the test to break if they access new properties that are supposed to exist but don’t on the stub.
  • Refactoring the component to access other properties of ActivatedRoute would break the test even though the component code is not broken.
  • Refactoring the component to get the parameters in a new way would break the test even though the component code is not wrong.

Recall the argument from earlier:

“Relying on the real router would make them brittle.”

In fact, these tests are more brittle because they rely on implementation details of the component. There’s a good argument that these tests are actually harmful change detector tests. Refactors in the component implementation that do not affect the end behavior or rendered template but cause the tests to fail arguably indicate you have change detector tests.

In addition, if the Router behavior changes in a way that changes the outcome of the component’s interactions with the Router API, you would want the test to fail. If it doesn’t fail, the tests don’t catch the difference in behavior, and this can cause a production bug.

Using RouterTestingHarness instead

We’ll take this same example and convert it to use the new RouterTestingHarness. First, configure the testing module to provide the Router with a route using the SearchCmp:

TestBed.configureTestingModule({
providers: [
provideRouter([{path: '**', component: SearchCmp}])
],
});

Next, use the harness to get the component instance:

const harness = await RouterTestingHarness.create();
const activatedComponent = await harness.navigateByUrl('/', SearchCmp);

Finally, we make the updates and assertions as before:

await activatedComponent.searchFor(‘books’);
harness.detectChanges();
expect(harness.routeNativeElement?.innerHTML).toContain('search: books');

We have at least a couple of nice outcomes when writing the test this way:

  • We don’t have to write or maintain any test stubs or override providers. The Router executes and we don’t have to worry about rewriting stubs to behave similarly.
  • As an example, we could refactor the component to use matrix parameters instead and do not need to update any test stubs.

Wrap up

Using the RouterTestingHarness rather than mocking or stubbing Router dependencies will make tests more reliable. The example above only outlines one scenario, but there are many more examples where a mock or stub would make tests more brittle and/or hide actual production issues in the application due to upstream Router changes.


Write better tests without Router mocks/stubs was originally published in Angular Blog on Medium, where people are continuing the conversation by highlighting and responding to this story.


This content originally appeared on Angular Blog - Medium and was authored by Andrew Scott


Print Share Comment Cite Upload Translate Updates
APA

Andrew Scott | Sciencx (2023-03-02T20:01:28+00:00) Write better tests without Router mocks/stubs. Retrieved from https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/

MLA
" » Write better tests without Router mocks/stubs." Andrew Scott | Sciencx - Thursday March 2, 2023, https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/
HARVARD
Andrew Scott | Sciencx Thursday March 2, 2023 » Write better tests without Router mocks/stubs., viewed ,<https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/>
VANCOUVER
Andrew Scott | Sciencx - » Write better tests without Router mocks/stubs. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/
CHICAGO
" » Write better tests without Router mocks/stubs." Andrew Scott | Sciencx - Accessed . https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/
IEEE
" » Write better tests without Router mocks/stubs." Andrew Scott | Sciencx [Online]. Available: https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/. [Accessed: ]
rf:citation
» Write better tests without Router mocks/stubs | Andrew Scott | Sciencx | https://www.scien.cx/2023/03/02/write-better-tests-without-router-mocks-stubs/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.