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/router 22.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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?