- Angular
Angularガードの使い方|CanActivateFnでログインリダイレクト
Angular のガードとは、ルート(URL とコンポーネントの対応)に入る前や離れる前に呼ばれ、移動を許可するか・別のURLへ移すかを決める関数です。 いまの Angular では CanActivateFn という型の関数を書き、ルート定義の canActivate に並べるだけで使えます。true を返せば通過、UrlTree(移動先URLを表すオブジェクト)を返せばそのURLへリダイレクトされます。
この記事では、未ログインなら returnUrl 付きで /login へ移し、ログイン後に元のURLへ戻るまでの全ファイルを、コピーしてそのまま動く形で載せます。あわせて、5種類のガード(canActivate・canActivateChild・canMatch・canDeactivate・resolve)がどの順で動き、何を返せるかをテストで記録した結果と、クラスガードから関数ガードへの書き換え対応、TestBed.runInInjectionContext を使ったテストまでをまとめました。
確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular CLI 22.2.1・Angular 22.2.1(ng new の既定構成)/ TypeScript 6.0.3 / Vitest 5.0.3。ブラウザでの動作は Playwright 1.56.0 の Chromium で ng build の出力を開いて記録しました。
Angular ガードの早見表
| やりたいこと | 使うガード | 型 | ルート定義の書き方 |
|---|---|---|---|
| 未ログインならログイン画面へ移す | canActivate | CanActivateFn |
canActivate: [authGuard] |
| 子ルートすべてに同じ権限チェックをかける | canActivateChild | CanActivateChildFn |
親ルートに canActivateChild: [adminChildGuard] |
| 条件によって同じパスで別の画面を出す | canMatch | CanMatchFn |
同じ path を2つ並べ、1つ目に canMatch: [...] |
| 入力途中で画面を離れる前に確認する | canDeactivate | CanDeactivateFn<T> |
canDeactivate: [unsavedChangesGuard] |
| 画面を出す前にデータを用意する | resolve | ResolveFn<T> |
resolve: { orders: ordersResolver } |
| 戻り値 | 意味 | 使いどころ |
|---|---|---|
true |
通過する | 条件を満たしたとき |
false |
移動を取りやめる(URL は元のまま) | その場にとどめたいとき |
UrlTree |
そのURLへリダイレクトする | ログイン画面へ移すとき。router.createUrlTree(...) または router.parseUrl(...) で作る |
RedirectCommand |
オプション付きでリダイレクトする | replaceUrl: true で履歴を置き換えたいとき |
上記の Promise / Observable |
非同期に判定する | API に問い合わせてから決めるとき |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angularガードの使い方|CanActivateFnでログインリダイレクト」
ガードの種類と、動くタイミング・戻り値の一覧
Angular 22.2.1 の @angular/router の型定義では、ガードの戻り値は次のように定義されています。
type GuardResult = boolean | UrlTree | RedirectCommand;
type MaybeAsync<T> = T | Observable<T> | Promise<T>;
type CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => MaybeAsync<GuardResult>;
type CanActivateChildFn = (childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot) => MaybeAsync<GuardResult>;
type CanDeactivateFn<T> = (component: T, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState: RouterStateSnapshot) => MaybeAsync<GuardResult>;
type CanMatchFn = (route: Route, segments: UrlSegment[], currentSnapshot: PartialMatchRouteSnapshot) => MaybeAsync<GuardResult>;
type ResolveFn<T> = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => MaybeAsync<T | RedirectCommand>;
4種類のガードはどれも boolean・UrlTree・RedirectCommand のいずれか(またはその Promise / Observable)を返せます。 違いは「いつ呼ばれるか」と「引数に何が渡るか」です。
| ガード | 呼ばれるタイミング | 主な引数 | false を返したとき |
|---|---|---|---|
| canMatch | URL とルート定義を照合するとき(最初) | route(ルート定義)、segments(URL の区切り) |
そのルートを候補から外し、次の候補を試す |
| canDeactivate | いまの画面を離れるとき | いま表示中のコンポーネント、移動先の nextState |
移動を取りやめる |
| canActivateChild | 子ルートへ入るとき(親に書く) | 子ルートの childRoute、state |
移動を取りやめる |
| canActivate | そのルートへ入るとき | route、state(state.url が行き先URL) |
移動を取りやめる |
| resolve | すべてのガードを通過したあと、コンポーネント生成の前 | route、state |
(戻り値はデータ。RedirectCommand を返すとリダイレクト) |
実行順をテストで記録した結果
親子のルートに全種類のガードを付け、呼ばれた順に名前を記録するテストを書きました(コードは後半の「実行順を記録するテスト」に全文があります)。Angular 22.2.1 での出力は次のとおりです。
--- / → /parent/child
parent canMatch
child canMatch
parent canActivate
parent canActivateChild
child canActivate
parent resolve
child resolve
Child コンポーネント生成
--- /parent/child → /other
other canMatch
child canDeactivate
other canActivate
Other コンポーネント生成
--- /other → /m
m(1つ目) canMatch → false
m(2つ目) canMatch → true
Child コンポーネント生成
URL: /m
--- /m → /a
a(1つ目) canActivate → false
navigateByUrl の結果: false / URL: /m
ここから読み取れることを整理します。
| 確認できたこと | 出力の該当箇所 |
|---|---|
| canMatch が最初(親 → 子の順)に動く | 1〜2行目 |
| 入る側は canActivate(親)→ canActivateChild(親)→ canActivate(子) の順 | 3〜5行目 |
| resolve はガードがすべて通ったあと、コンポーネント生成の前に動く | 6〜8行目 |
| 離れるときは、移動先の canMatch → いまの画面の canDeactivate → 移動先の canActivate の順 | 2つ目のブロック |
canMatch が false なら、同じパスの次のルートが使われる |
3つ目のブロック(URL は /m のまま、2つ目の定義が表示された) |
canActivate が false なら移動そのものが取りやめになり、同じパスの次の定義には進まない |
4つ目のブロック(navigateByUrl が false、URL は /m のまま) |
canMatch と canActivate の違いは、最後の2つのブロックにはっきり表れています。「条件に合わなければ別の画面を出したい」なら canMatch、「条件に合わなければ止める・別URLへ移したい」なら canActivate と覚えると選びやすくなります。
ログイン後に元のURLへ戻るガード(全ファイル)
ここからが本題です。次の動きをするアプリを、ng new で作ったプロジェクトの src/app/ にファイルを置くだけで作ります。
sequenceDiagram
participant U as 利用者
participant R as Router
participant G as authGuard
participant L as Login画面
U->>R: /mypage/orders?page=2 を開く
R->>G: canActivate(route, state)
G-->>R: UrlTree(/login?returnUrl=...)
R->>L: /login を表示
U->>L: ログインボタン
L->>R: navigateByUrl(returnUrl)
R->>G: canActivate(今度は true)
R-->>U: /mypage/orders?page=2 を表示
プロジェクトの作成
npx @angular/cli@latest new app --skip-git --defaults --style=css --ssr=false
cd app
ng new が作る src/app/app.html・app.css・app.spec.ts は使わないので削除し、以下のファイルを置きます。
auth.service.ts(ログイン状態を持つサービス)
ログイン状態はシグナル(値の変化を画面に伝える仕組み)で持ちます。サンプルなのでメモリ上に保持するだけの作りです。実際のアプリでは、ここで API を呼んでトークンやセッションを扱います。
import { Injectable, computed, signal } from '@angular/core';
export type Role = 'member' | 'admin';
export interface User {
name: string;
role: Role;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly user = signal<User | null>(null);
readonly isLoggedIn = computed(() => this.user() !== null);
readonly userName = computed(() => this.user()?.name ?? '');
readonly role = computed(() => this.user()?.role ?? null);
login(name: string, role: Role = 'member'): void {
this.user.set({ name, role });
}
logout(): void {
this.user.set(null);
}
}
auth.guard.ts(CanActivateFn のガード本体)
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
// state.url は「行こうとしていたURL」。クエリパラメータも含まれる
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
ポイントは3つです。
- サービスは
inject()で受け取る:関数ガードはルーターが「注入コンテキスト」(inject()が使える状態)の中で呼んでくれるので、コンストラクタがなくてもinject(AuthService)と書けます。 state.urlをそのままreturnUrlに入れる:state.urlには/mypage/orders?page=2のようにクエリパラメータまで含まれます。router.navigate()を呼ばずにUrlTreeを返す:戻り値で行き先を伝えると、ルーターが「いまの移動を取りやめて新しい移動を始める」処理をまとめて行います。queryParamsに渡した値は自動でURLエンコードされます。
guards.ts(ほかの4種類)
import { inject } from '@angular/core';
import {
CanActivateChildFn,
CanDeactivateFn,
CanMatchFn,
RedirectCommand,
ResolveFn,
Router,
} from '@angular/router';
import { AuthService } from './auth.service';
// 子ルートへ入るたびに呼ばれる。管理者以外は /forbidden へ(履歴を置き換える)
export const adminChildGuard: CanActivateChildFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.role() === 'admin') {
return true;
}
return new RedirectCommand(router.parseUrl('/forbidden'), { replaceUrl: true });
};
// ルートを「候補にするかどうか」を決める。false なら次の候補ルートへ進む
export const adminMatchGuard: CanMatchFn = () => inject(AuthService).role() === 'admin';
// 離れてよいかを、コンポーネント自身に聞く
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
component.hasUnsavedChanges() ? confirm('入力中の内容があります。移動しますか?') : true;
// 画面を表示する前にデータを用意する
export interface Order {
id: number;
item: string;
}
export const ordersResolver: ResolveFn<Order[]> = async (route) => {
const page = Number(route.queryParamMap.get('page') ?? '1');
await new Promise((r) => setTimeout(r, 50)); // API 呼び出しの代わり
return [
{ id: page * 100 + 1, item: 'ノート' },
{ id: page * 100 + 2, item: 'ペン' },
];
};
pages.ts(各画面のコンポーネント)
import { Component, inject, input, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink, RouterOutlet } from '@angular/router';
import { AuthService, Role } from './auth.service';
import { HasUnsavedChanges, Order } from './guards';
@Component({ selector: 'app-home', template: `<h1>ホーム</h1>` })
export class Home {}
@Component({
selector: 'app-login',
template: `
<h1>ログイン</h1>
<p id="return">ログイン後の移動先: {{ returnUrl }}</p>
<button type="button" id="login" (click)="login('member')">会員でログイン</button>
<button type="button" id="login-admin" (click)="login('admin')">管理者でログイン</button>
`,
})
export class Login {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
// 外部サイトのURLを渡されても移動しないよう、「/」1つで始まるパスだけ受け付ける
protected readonly returnUrl = safeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
login(role: Role): void {
this.auth.login('taro', role);
this.router.navigateByUrl(this.returnUrl, { replaceUrl: true });
}
}
export function safeReturnUrl(value: string | null): string {
if (value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/login')) {
return value;
}
return '/';
}
@Component({
selector: 'app-my-page',
imports: [RouterOutlet, RouterLink],
template: `<h1>マイページ</h1><a routerLink="orders">注文履歴へ</a><router-outlet />`,
})
export class MyPage {}
@Component({
selector: 'app-orders',
template: `
<h2>注文履歴</h2>
<ul>
@for (o of orders(); track o.id) {
<li>{{ o.id }} {{ o.item }}</li>
}
</ul>
`,
})
export class Orders {
// withComponentInputBinding() により、resolve のキーと同じ名前の input に結果が入る
readonly orders = input.required<Order[]>();
}
@Component({ selector: 'app-admin-shell', imports: [RouterOutlet], template: `<h1>管理</h1><router-outlet />` })
export class AdminShell {}
@Component({ selector: 'app-admin-users', template: `<h2>ユーザー一覧(管理者用)</h2>` })
export class AdminUsers {}
@Component({ selector: 'app-forbidden', template: `<h1>このページを表示する権限がありません</h1>` })
export class Forbidden {}
@Component({ selector: 'app-not-found', template: `<h1>ページが見つかりません</h1>` })
export class NotFound {}
@Component({
selector: 'app-memo',
template: `
<h1>メモ</h1>
<textarea id="memo" (input)="dirty.set(true)"></textarea>
<button type="button" id="save" (click)="dirty.set(false)">保存</button>
`,
})
export class Memo implements HasUnsavedChanges {
protected readonly dirty = signal(false);
hasUnsavedChanges(): boolean {
return this.dirty();
}
}
Login コンポーネントでは、returnUrl をそのまま使わず safeReturnUrl() を通しています。 クエリパラメータは誰でも書き換えられるので、https://... や //example.com のような外部URLが入っていたら / に置き換えます。ログイン後の移動に replaceUrl: true を付けているのは、ブラウザの「戻る」でログイン画面に戻らないようにするためです。
app.routes.ts(ルート定義)
import { Routes } from '@angular/router';
import { authGuard } from './auth.guard';
import { adminChildGuard, ordersResolver, unsavedChangesGuard } from './guards';
import {
AdminShell, AdminUsers, Forbidden, Home, Login, Memo, MyPage, NotFound, Orders,
} from './pages';
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'login', component: Login },
{
path: 'mypage',
component: MyPage,
canActivate: [authGuard],
children: [
{ path: 'orders', component: Orders, resolve: { orders: ordersResolver }, runGuardsAndResolvers: 'paramsOrQueryParamsChange' },
],
},
{
path: 'admin',
component: AdminShell,
canActivate: [authGuard],
canActivateChild: [adminChildGuard],
children: [{ path: 'users', component: AdminUsers }],
},
{ path: 'memo', component: Memo, canActivate: [authGuard], canDeactivate: [unsavedChangesGuard] },
{ path: 'forbidden', component: Forbidden },
{ path: '**', component: NotFound },
];
app.config.ts と app.ts
withComponentInputBinding() を付けると、resolve の結果(ここでは orders)を同じ名前の input() で受け取れます。
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
],
};
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './auth.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
template: `
<nav>
<a routerLink="/">ホーム</a> |
<a routerLink="/mypage">マイページ</a> |
<a routerLink="/mypage/orders" [queryParams]="{ page: 2 }">注文履歴</a> |
<a routerLink="/admin/users">管理</a> |
<a routerLink="/memo">メモ</a>
@if (auth.isLoggedIn()) {
<button type="button" id="logout" (click)="logout()">ログアウト({{ auth.userName() }})</button>
}
</nav>
<router-outlet />
`,
})
export class App {
protected readonly auth = inject(AuthService);
private readonly router = inject(Router);
logout(): void {
this.auth.logout();
this.router.navigateByUrl('/');
}
}
ブラウザで動かした結果
ng build の出力を静的サーバーで配信し、Playwright の Chromium で操作したときの URL と見出しの記録です(左が操作、右が操作後の URL と画面)。
1. 未ログインで /mypage/orders?page=2 を開く → /login?returnUrl=%2Fmypage%2Forders%3Fpage%3D2 | ログイン
ログイン後の移動先: /mypage/orders?page=2
2. ログインボタンを押す → /mypage/orders?page=2 | マイページ | 201 ノート, 202 ペン
3. 会員のまま「管理」を押す → /forbidden | このページを表示する権限がありません
confirm: 入力中の内容があります。移動しますか? → キャンセル
4. 入力中に「ホーム」を押してキャンセル → /memo | メモ
confirm: 入力中の内容があります。移動しますか? → OK
5. もう一度押して OK → / | ホーム
6. ログアウトして「管理」を押す → /login?returnUrl=%2Fadmin%2Fusers
7. 管理者でログイン → /admin/users | ユーザー一覧(管理者用)
8. ブラウザの「戻る」 → / | ホーム
9. returnUrl に外部URLを渡す → ログイン後の移動先: /
| 番号 | 確認できたこと |
|---|---|
| 1 | state.url がエンコードされて returnUrl に入る(/ は %2F、? は %3F、= は %3D) |
| 2 | ログイン後、クエリパラメータ page=2 も含めて元のURLに戻る。resolver が page を読んで 201・202 を返している |
| 3 | 会員は canActivate を通るが、canActivateChild の RedirectCommand で /forbidden へ移る |
| 4・5 | canDeactivate が false を返すと URL はそのまま、true なら移動する |
| 8 | replaceUrl: true のおかげで、「戻る」を押してもログイン画面を挟まずホームへ戻る |
| 9 | 外部URLは safeReturnUrl() で / に置き換わる |
canActivateChild・canMatch・canDeactivate・resolve の使い分け
canActivateChild:子ルートをまとめて守る
/admin の下に画面が増えていくとき、子ルートの1つずつに canActivate を書く代わりに、親に canActivateChild を1つ書けば済みます。上のサンプルでは、親の canActivate: [authGuard] で「ログインしているか」、canActivateChild: [adminChildGuard] で「管理者か」を分けて確認しています。実行順の記録のとおり、親の canActivate → 親の canActivateChild → 子の canActivate の順に動きます。
canMatch:同じパスで画面を出し分ける
guards.ts の adminMatchGuard は、同じ path のルートを2つ並べ、1つ目に付けて使います。実行順を記録したテストでは次の定義を使い、1つ目の canMatch が false を返すと2つ目の定義が表示されました。
{ path: 'm', component: Other, canMatch: [g('m(1つ目) canMatch → false', false)] },
{ path: 'm', component: Child, canMatch: [g('m(2つ目) canMatch → true')] },
canDeactivate:コンポーネントに「離れてよいか」を聞く
CanDeactivateFn<T> の1つ目の引数には、いま表示中のコンポーネントのインスタンスが渡ります。ガード側は HasUnsavedChanges というインターフェースだけを知っていればよく、どの画面にも同じガードを使い回せます。なお、canDeactivate が扱うのはアプリ内の移動です。
resolve:画面を出す前にデータを用意する
ResolveFn<T> はガードがすべて通ったあとに動き、戻り値(Promise や Observable なら完了後の値)がルートの data に入ります。withComponentInputBinding() を使うと、resolve: { orders: ordersResolver } のキー orders と同じ名前の input() で受け取れます。
つまずきやすい点:クエリパラメータだけが変わったとき、resolve は既定では再実行されません。 /default?page=1 → /default?page=2 と移動したときの記録です。
const routes: Routes = [
{ path: 'default', component: List, resolve: { data: pageResolver } },
{
path: 'rerun',
component: List,
resolve: { data: pageResolver },
runGuardsAndResolvers: 'paramsOrQueryParamsChange',
},
];
--- 既定
resolve 実行 page=1
--- paramsOrQueryParamsChange
resolve 実行 page=1
resolve 実行 page=2
ページ番号や検索条件をクエリパラメータで渡す一覧画面では、サンプルの orders ルートのように runGuardsAndResolvers: 'paramsOrQueryParamsChange' を付けると、クエリが変わるたびに resolve が動きます。
クラスガードから関数ガードへの書き換え対応
implements CanActivate で書かれたクラスガードは、Angular 22.2.1 でもそのまま動きます。ただし型定義では、クラスを直接渡す形(DeprecatedGuard)に @deprecated が付いており、関数で書く形が案内されています。
書き換え前(クラスガード)
import { Injectable, inject } from '@angular/core';
import {
ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree,
} from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class LegacyAuthGuard implements CanActivate {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree {
return this.auth.isLoggedIn()
? true
: this.router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
}
}
書き換え後(関数ガード)
書き換え後は、先に載せた auth.guard.ts そのものです。対応を表にします。
| クラスガードの書き方 | 関数ガードの書き方 |
|---|---|
@Injectable({ providedIn: 'root' }) と class ... implements CanActivate |
export const authGuard: CanActivateFn = (route, state) => { ... } |
private readonly auth = inject(AuthService)(またはコンストラクタ引数) |
関数の中で const auth = inject(AuthService) |
canActivate(route, state): boolean | UrlTree |
関数の戻り値がそのまま結果 |
canActivate: [LegacyAuthGuard] |
canActivate: [authGuard] |
implements CanActivateChild / CanDeactivate<T> / CanMatch / Resolve<T> |
CanActivateChildFn / CanDeactivateFn<T> / CanMatchFn / ResolveFn<T> |
クラスを残したまま段階的に移す:mapToCanActivate
クラスガードが多くて一度に書き換えにくい場合は、mapToCanActivate() でクラスを関数ガードに変換できます。クラスはそのままで、ルート定義だけを変える方法です。
import { Routes, mapToCanActivate } from '@angular/router';
import { LegacyAuthGuard } from './legacy.guard';
const routes: Routes = [
{ path: 'login', component: X },
{ path: 'a', component: X, canActivate: [LegacyAuthGuard] },
{ path: 'b', component: X, canActivate: mapToCanActivate([LegacyAuthGuard]) },
];
未ログインで /a・/b へ移動したあとの URL を記録すると、次のようになりました。
canActivate: [LegacyAuthGuard] → /login?returnUrl=%2Fa
mapToCanActivate([LegacyAuthGuard]) → /login?returnUrl=%2Fb
どちらの書き方でも同じ結果になりました。型定義のコメントには、canActivate: [() => inject(myGuard).canActivate()] のように inject() でクラスを呼ぶ形も紹介されています。
TestBed.runInInjectionContext でガードをテストする
関数ガードは「ただの関数」ですが、中で inject() を使っているため、テストでは TestBed.runInInjectionContext() の中で呼びます。 ng new の既定のテスト環境(Vitest)でそのまま動きます。
auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree, provideRouter, withComponentInputBinding,
} from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';
import { routes } from './app.routes';
describe('authGuard(関数を直接呼ぶ)', () => {
const route = {} as ActivatedRouteSnapshot;
const state = { url: '/mypage/orders?page=2' } as RouterStateSnapshot;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideRouter([])] });
});
it('ログイン済みなら true を返す', () => {
TestBed.inject(AuthService).login('taro');
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
expect(result).toBe(true);
});
it('未ログインなら returnUrl 付きの UrlTree を返す', () => {
const result = TestBed.runInInjectionContext(() => authGuard(route, state));
expect(result).toBeInstanceOf(UrlTree);
const url = TestBed.inject(Router).serializeUrl(result as UrlTree);
console.log('UrlTree →', url);
expect(url).toBe('/login?returnUrl=%2Fmypage%2Forders%3Fpage%3D2');
});
it('inject を使うガードをそのまま呼ぶと NG0203 になる', () => {
try {
authGuard(route, state);
} catch (e) {
console.log((e as Error).message);
}
expect(() => authGuard(route, state)).toThrowError(/NG0203/);
});
});
describe('authGuard(ルーターごと動かす)', () => {
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideRouter(routes, withComponentInputBinding())] });
});
it('未ログインで /mypage/orders?page=2 へ行くと /login に移る', async () => {
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/mypage/orders?page=2');
const router = TestBed.inject(Router);
console.log('未ログイン →', router.url);
expect(router.url).toBe('/login?returnUrl=%2Fmypage%2Forders%3Fpage%3D2');
expect(harness.routeNativeElement?.textContent).toContain('/mypage/orders?page=2');
});
it('ログインボタンを押すと元のURLへ戻る', async () => {
const harness = await RouterTestingHarness.create('/mypage/orders?page=2');
(harness.routeNativeElement!.querySelector('#login') as HTMLButtonElement).click();
await harness.fixture.whenStable();
await new Promise((r) => setTimeout(r, 100));
console.log('ログイン後 →', TestBed.inject(Router).url);
expect(TestBed.inject(Router).url).toBe('/mypage/orders?page=2');
});
it('会員が /admin/users へ行くと RedirectCommand で /forbidden に移る', async () => {
TestBed.inject(AuthService).login('taro', 'member');
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/admin/users');
console.log('会員で /admin/users →', TestBed.inject(Router).url);
expect(TestBed.inject(Router).url).toBe('/forbidden');
});
});
前半はガードの関数を直接呼ぶ単体テスト、後半は RouterTestingHarness でルーターごと動かすテストです。後者はルート定義への付け忘れや resolve との組み合わせまで確かめられます。
実行結果
npx ng test --watch=false --reporters=verbose
stdout | src/app/auth.guard.spec.ts > authGuard(関数を直接呼ぶ) > 未ログインなら returnUrl 付きの UrlTree を返す
UrlTree → /login?returnUrl=%2Fmypage%2Forders%3Fpage%3D2
stdout | src/app/auth.guard.spec.ts > authGuard(関数を直接呼ぶ) > inject を使うガードをそのまま呼ぶと NG0203 になる
NG0203: The `_AuthService` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
stdout | src/app/auth.guard.spec.ts > authGuard(ルーターごと動かす) > 未ログインで /mypage/orders?page=2 へ行くと /login に移る
未ログイン → /login?returnUrl=%2Fmypage%2Forders%3Fpage%3D2
stdout | src/app/auth.guard.spec.ts > authGuard(ルーターごと動かす) > ログインボタンを押すと元のURLへ戻る
ログイン後 → /mypage/orders?page=2
stdout | src/app/auth.guard.spec.ts > authGuard(ルーターごと動かす) > 会員が /admin/users へ行くと RedirectCommand で /forbidden に移る
会員で /admin/users → /forbidden
Test Files 2 passed (2)
Tests 7 passed (7)
(console.log の出力と集計行を抜き出しています。7件は auth.guard.spec.ts の6件と order.spec.ts の1件です。)
テストでつまずきやすい点
| 症状 | 原因 | 直し方 |
|---|---|---|
NG0203: ... inject() function must be called from an injection context |
ガードを authGuard(route, state) と直接呼んでいる |
TestBed.runInInjectionContext(() => authGuard(route, state)) で包む |
NG0950: Input "orders" is required but no value is available yet. |
テスト側の provideRouter(routes) に withComponentInputBinding() を付けていない |
アプリと同じく provideRouter(routes, withComponentInputBinding()) にする |
UrlTree の中身を比べにくい |
オブジェクト同士を直接比べている | router.serializeUrl(result) で文字列にして比べる |
2つ目の NG0950 は、この記事のテストを書いている途中で実際に出たエラーです。アプリ本体と同じルーター設定をテストにも渡すと解消しました。
実行順を記録するテスト(order.spec.ts)
「ガードの種類と、動くタイミング」で紹介した出力を得るためのテストです。自分のプロジェクトで順序を確かめたいときにも使えます。
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, RouterOutlet, Routes, provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
const log: string[] = [];
const g = (name: string, ret: unknown = true) => () => { log.push(name); return ret as true; };
@Component({ selector: 'app-parent', imports: [RouterOutlet], template: `<router-outlet />` })
class Parent {}
@Component({ selector: 'app-child', template: `child` })
class Child { constructor() { log.push('Child コンポーネント生成'); } }
@Component({ selector: 'app-other', template: `other` })
class Other { constructor() { log.push('Other コンポーネント生成'); } }
const routes: Routes = [
{
path: 'parent',
component: Parent,
canMatch: [g('parent canMatch')],
canActivate: [g('parent canActivate')],
canActivateChild: [g('parent canActivateChild')],
resolve: { p: g('parent resolve', 'P') },
children: [
{
path: 'child',
component: Child,
canMatch: [g('child canMatch')],
canActivate: [g('child canActivate')],
canDeactivate: [g('child canDeactivate')],
resolve: { c: g('child resolve', 'C') },
},
],
},
{ path: 'other', component: Other, canMatch: [g('other canMatch')], canActivate: [g('other canActivate')] },
{ path: 'm', component: Other, canMatch: [g('m(1つ目) canMatch → false', false)] },
{ path: 'm', component: Child, canMatch: [g('m(2つ目) canMatch → true')] },
{ path: 'a', component: Other, canActivate: [g('a(1つ目) canActivate → false', false)] },
{ path: 'a', component: Child },
];
describe('ガードの実行順', () => {
it('入るとき・離れるとき', async () => {
TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
const harness = await RouterTestingHarness.create();
const router = TestBed.inject(Router);
await harness.navigateByUrl('/parent/child');
console.log('--- / → /parent/child\n' + log.join('\n'));
log.length = 0;
await harness.navigateByUrl('/other');
console.log('--- /parent/child → /other\n' + log.join('\n'));
log.length = 0;
await router.navigateByUrl('/m');
console.log('--- /other → /m\n' + log.join('\n') + '\nURL: ' + router.url);
log.length = 0;
const ok = await router.navigateByUrl('/a');
console.log('--- /m → /a\n' + log.join('\n') + '\nnavigateByUrl の結果: ' + ok + ' / URL: ' + router.url);
});
});
ガードを書くときのチェックリスト
| 確認すること | 理由 |
|---|---|
リダイレクトは router.navigate() ではなく UrlTree / RedirectCommand を返す |
戻り値で伝えると、ルーターが移動の取りやめと再開をまとめて扱う |
returnUrl には state.url を入れる |
クエリパラメータまで含めて元の場所に戻れる |
ログイン画面で returnUrl を検証する |
外部URLへ移動させないため。/ 1つで始まるパスだけ通す |
ログイン後の移動に replaceUrl: true を付ける |
「戻る」でログイン画面に戻らないようにする |
| ログイン画面のルートには認証ガードを付けない | /login 自体がガードされると、リダイレクトが繰り返される |
子ルートが増えるなら canActivateChild を親に書く |
付け忘れを防げる |
クエリで条件が変わる画面の resolve には runGuardsAndResolvers を設定する |
既定ではクエリだけの変更で resolve が再実行されない |
よくある質問
Q. canActivate と canMatch のどちらを使えばよいですか?
A. 条件に合わないときに「止める・別URLへ移す」なら canActivate、「同じURLのまま別の画面を出す」なら canMatch です。実行順の記録のとおり、canMatch が false のときだけ次の候補ルートが試されます。
Q. ガードの中で API を呼んで判定できますか?
A. できます。ガードの戻り値の型は MaybeAsync<GuardResult> で、Promise や Observable も返せます。async 関数にして await したあと true や UrlTree を返せば、結果が出るまでルーターが待ちます。この記事の ordersResolver も async 関数です。
Q. クラスガードは書き換えないと動かなくなりますか?
A. Angular 22.2.1 ではクラスガードも mapToCanActivate() も動くことを確認しました。型定義では @deprecated とされているので、新しく書くガードは関数にし、既存のクラスは mapToCanActivate() を挟みながら順に移すと進めやすくなります。
まとめ
- ガードは
CanActivateFnなどの関数で書き、inject()でサービスを受け取る - 未ログイン時は
router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })を返す。ログイン画面ではreturnUrlを検証してからnavigateByUrl(returnUrl, { replaceUrl: true })で戻る - 実行順は canMatch → canDeactivate → canActivate / canActivateChild → resolve → コンポーネント生成
- canMatch の
falseは「次の候補へ」、canActivate のfalseは「移動の取りやめ」 - 戻り値は
boolean・UrlTree・RedirectCommandと、その Promise / Observable - クラスガードは
mapToCanActivate()で段階的に関数へ移せる - テストは
TestBed.runInInjectionContext()で単体、RouterTestingHarnessでルーターごと確認する
参考・出典
- Angular 日本語ドキュメント「ルートガードによるルートアクセス制御」 https://angular.jp/guide/routing/route-guards
@angular/router22.2.1 の型定義(node_modules/@angular/router/types/内のGuardResult・CanActivateFn・DeprecatedGuardなど)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angularガードの使い方|CanActivateFnでログインリダイレクト」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。