~8 min de lecture
RouterTestingHarness : ton guard est vert, ta route /admin est grande ouverte
Tu as écrit un guard (canActivate ou canMatch, deux familles qui ne refusent pas de la même façon). Tu as écrit son test. Il est vert. Tu déploies, et un utilisateur déconnecté atterrit quand même sur /admin, devant une page qui explose faute de session.
Le test n'avait pourtant pas menti : la fonction authGuard renvoie bien un UrlTree vers /login. Ce qu'il n'a jamais vérifié, c'est que cette fonction soit branchée sur la route. Un canActivate: [authGuard] oublié dans la config, et ton guard est un morceau de code parfaitement testé que personne n'appelle.
C'est le problème de fond du test de guard "appelé à la main" : il teste une fonction, pas une navigation. Et le routing, c'est une navigation. RouterTestingHarness teste la navigation de bout en bout : les routes, les guards câblés dessus, les redirections, et le composant qui s'affiche à l'arrivée. Un harness, littéralement un banc d'essai : un composant hôte qui porte le RouterOutlet et pilote le router à ta place (rien à voir avec les component harnesses de @angular/cdk/testing, une autre API). Il est livré avec @angular/router/testing depuis Angular 15.2, et il tourne sans monter l'app entière, sans navigateur, dans un test unitaire ordinaire.
TL;DR
Tester un guard en l'appelant à la main prouve que ta fonction est bonne, pas que ta route est protégée : débranche le canActivate de la config, ce test reste vert. RouterTestingHarness fait naviguer un vrai router sur tes vraies routes, et le même scénario devient rouge. Au passage, tu vérifies la redirection, l'existence de la cible et les params poussés dans tes inputs. Quatre pièges t'attendent quand même, dont une surcharge typée qui jette au lieu de rendre null. Tous les exemples tournent sous Vitest (rien n'est spécifique au runner) et sont vérifiés sur Angular 22.
Le test qu'on écrit tous
Le guard, version signals, rien d'exotique :
import { inject, Injectable, signal } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class Session {
readonly isLoggedIn = signal(false);
}
export const authGuard: CanActivateFn = (route, state) => {
const session = inject(Session);
return (
session.isLoggedIn() ||
inject(Router).createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
})
);
};
Et le test que tu as probablement dans ton projet en ce moment - c'est d'ailleurs celui qu'on te recommandait dans l'article sur les guards fonctionnels :
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
} from '@angular/router';
describe('authGuard', () => {
it('redirige vers /login quand on est déconnecté', () => {
TestBed.configureTestingModule({});
const result = TestBed.runInInjectionContext(() =>
authGuard(
{} as ActivatedRouteSnapshot,
{ url: '/admin' } as RouterStateSnapshot,
),
);
expect(result.toString()).toBe('/login?returnUrl=%2Fadmin');
});
});
Ce test passe. Il n'est pas inutile : il prouve que la logique du guard est bonne. Mais fais la liste de ce qu'il ne prouve pas :
- Que
authGuardfigure bien dans lecanActivatede la route/admin. Tu castes deux objets bricolés en snapshots, le vraiRoutes[]n'est jamais chargé. - Que la redirection aboutit. Renvoyer un
UrlTreevers/loginne sert à rien si/loginn'existe pas. - Que l'utilisateur connecté voit réellement la page admin, avec ses params résolus et ses inputs bindés.
- Que l'ordre des routes est le bon. Une route
:slugdéclarée avant une route statique de même profondeur avale cette dernière, et aucun test de fonction ne le verra jamais.
Quatre bugs de prod possibles, zéro test rouge. C'est exactement la définition d'une fausse confiance.
RouterTestingHarness en deux minutes
Une fois le router fourni, le harness se construit en un appel :
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
TestBed.configureTestingModule({
providers: [provideRouter(routes)],
});
const harness = await RouterTestingHarness.create();
create() fabrique un composant racine minimal contenant un RouterOutlet, et te rend la main avec quatre outils :
navigateByUrl(url): déclenche une vraie navigation et résout avec l'instance du composant activé dans l'outlet, ounullsi aucun composant n'est activé au bout (après une navigation réussie, un refus te rend le composant précédent, pasnull) ;navigateByUrl(url, ComposantAttendu): pareil, mais typé, et jette si c'est un autre composant qui s'active ;routeNativeElementetrouteDebugElement: le DOM du composant routé, pour tes assertions ;fixtureetdetectChanges(): la fixture racine, quand tu dois forcer un cycle de rendu.
Pas de Location à mocker toi-même : TestBed fournit par défaut un faux PlatformLocation (MockPlatformLocation depuis Angular 16, remplacé en Angular 21 par une implémentation qui supporte la Navigation API du navigateur, window.navigation). C'est la raison avancée dans la note de dépréciation de RouterTestingModule, rédigée à l'époque de MockPlatformLocation : l'apport principal de ce module était justement de fournir des faux Location.
Cas 1 : la redirection, de bout en bout
Même guard, mais cette fois on teste la route, pas la fonction :
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { provideRouter, Router, type Routes } from '@angular/router';
@Component({
template: `<h1>Admin</h1>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminDashboard {}
@Component({
template: `<h1>Login</h1>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Login {}
export const routes: Routes = [
{ path: 'login', component: Login },
{ path: 'admin', component: AdminDashboard, canActivate: [authGuard] },
];
describe('authGuard via RouterTestingHarness', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter(routes)],
});
});
it('redirige un visiteur déconnecté vers /login avec le returnUrl', async () => {
const harness = await RouterTestingHarness.create();
const activated = await harness.navigateByUrl('/admin');
expect(activated).toBeInstanceOf(Login);
expect(TestBed.inject(Router).url).toBe('/login?returnUrl=%2Fadmin');
});
it('laisse passer un visiteur connecté', async () => {
TestBed.inject(Session).isLoggedIn.set(true);
const harness = await RouterTestingHarness.create();
const activated = await harness.navigateByUrl('/admin', AdminDashboard);
expect(activated).toBeInstanceOf(AdminDashboard);
expect(harness.routeNativeElement?.textContent).toContain('Admin');
});
});
Regarde ce que le premier test vérifie d'un seul tenant : le guard est câblé sur /admin, il refuse, le router suit le UrlTree, la route /login existe, et c'est bien Login qui s'affiche au bout, query param encodé compris. Les angles morts 1 et 2 du test "à la main" sont couverts, et le test n'est pas plus long. Les deux autres arrivent juste après : les inputs au cas 2, l'ordre des routes au piège 4.
Si quelqu'un retire le canActivate de la config demain, ce test devient rouge. L'ancien restait vert.
Cas 2 : les params de route dans tes inputs
Depuis Angular 16, withComponentInputBinding() pousse les params de route directement dans les inputs du composant (avec un piège de valeurs par défaut écrasées qui mérite sa propre lecture). C'est précisément le genre de câblage qu'un test de composant isolé ne voit pas : tu peux renseigner l'input à la main dans ton test, et découvrir en prod que le binding ne se fait pas parce que le nom du param ne correspond pas à celui de l'input.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
template: `<h1>Article {{ slug() }}</h1>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ArticleDetail {
readonly slug = input.required<string>();
}
export const routes: Routes = [
{ path: 'articles/:slug', component: ArticleDetail },
];
import { withComponentInputBinding } from '@angular/router';
it('reçoit le slug de la route dans son input', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes, withComponentInputBinding())],
});
const harness = await RouterTestingHarness.create();
const page = await harness.navigateByUrl(
'/articles/signals-cheatsheet',
ArticleDetail,
);
expect(page.slug()).toBe('signals-cheatsheet');
});
La surcharge typée de navigateByUrl te rend un ArticleDetail, pas un {} : tu lis page.slug() sans cast. Et le test traverse toute la chaîne URL -> param -> binding -> input signal. Renomme le param en :id sans toucher à l'input, et il devient rouge. C'est lui qui garde le contrat, pas ta discipline.
Bonus : si ta navigation initiale est fixe, create() accepte l'URL directement :
const harness = await RouterTestingHarness.create('/articles/zoneless');
Les 4 pièges qui t'attendent
1. La surcharge typée jette quand un guard redirige. navigateByUrl('/admin', AdminDashboard) avec un visiteur déconnecté ne te rend pas null : ça jette, puisque c'est Login qui s'active. Et la version non typée, on l'a vu, peut te rendre le composant resté affiché après un refus. Pour tester un refus ou une redirection, vérifie TestBed.inject(Router).url. La version typée, c'est pour les navigations qui doivent aboutir.
2. harness.fixture.componentInstance n'est pas ta page. C'est le composant racine créé par le harness, celui qui porte l'outlet. Ta page, c'est le retour de navigateByUrl, ou routeNativeElement pour son DOM. Si tes assertions portent sur fixture.componentInstance, tu testes une coquille vide.
3. Un seul create() par test. Le harness jette si tu l'instancies deux fois. Pour enchaîner des navigations, réutilise le même harness : navigateByUrl se rappelle autant de fois que tu veux, et l'outlet réutilise le composant quand la route ne change pas.
4. Teste tes vraies routes. Tout l'intérêt est de vérifier le câblage réel. Si tu redéclares un Routes[] de test qui duplique ta config, tu retombes dans le piège du début : la copie est verte, l'originale est cassée. Importe le vrai tableau de routes de ta feature (ou le sous-arbre concerné) et navigue dedans. C'est aussi comme ça que tu attrapes les bugs d'ordre de déclaration, par exemple un :slug déclaré avant tags, qui avale /blog/tags.
Avant / après
Avant, pour couvrir le scénario "déconnecté sur /admin" :
- un test du guard avec deux snapshots castés avec
as; - un test du composant
Loginmonté isolément ; - et une prière pour que la config de routes relie bien les deux.
Après :
- un test de la fonction si sa logique est riche (il reste l'outil adapté) ;
- un test de navigation qui prouve le câblage, le refus, la redirection et le rendu, en quelques lignes.
Le test de fonction répond à "ma logique est-elle bonne ?". Le harness répond à "mon app route-t-elle correctement ?". Ce sont deux questions différentes, et la deuxième est celle que ton utilisateur pose en prod.
Le récap actionnable
- Garde les tests de fonction pour la logique pure d'un guard complexe : rôles, expiration de token, combinaisons. C'est rapide et précis.
- Ajoute un test de navigation par comportement de routing : un refus, une redirection, un param bindé.
provideRouter(routes)+RouterTestingHarness.create()+navigateByUrl, c'est tout le setup. - Importe tes vraies routes, jamais une copie. Le câblage est précisément ce que tu veux tester.
- Vérifie
TestBed.inject(Router).urlpour les refus et redirections (le retour denavigateByUrlpeut être le composant précédent, pasnull), et le retour typé denavigateByUrlpour les navigations qui aboutissent. - Si tu croises encore
RouterTestingModuledans ta base de code, remplace-le parprovideRouter: il est déprécié, etTestBedfournit déjà un fauxPlatformLocation. Trois points de vigilance :- tes tests utilisent l'API de
SpyLocation(simulateUrlPop...) : ajouteprovideLocationMocks(); - un composant non standalone déclaré dans
declarationsutiliserouterLinkourouter-outlet: ajouteRouterModule(ou ces directives) auximportsdu test, sinon elles disparaissent ; - les options de
withRoutes(routes, config)passent désormais par des features (withHashLocation(),withRouterConfig()...).
- tes tests utilisent l'API de
La fonction, tu l'as déjà testée. Teste la route : c'est elle que tes utilisateurs empruntent.