Angular実践 #6 テストとデプロイ — トラック総仕上げ

読了 11分

前回は Angular Material を載せてダッシュボードの見栄えを整えました。今回が Angular実践講座の最終回 です。これまで手で作ってきたダッシュボードをテストでしっかり束ね、Docker で梱包したあと、実際のドメインで動くところまで運ぶ一周を最後まで回します。

そして最後には少し離れたところから、Angular トラック 27 編全体 を振り返ってみます。

何を仕上げるのか #

新しい機能は追加しません。すでに作ったコードを実際のユーザーに届ける作業 が今回の仕事です — テストカバレッジを埋める、Playwright E2E、環境分離、Docker ビルド、Cloudflare Pages へのデプロイ、GitHub Actions CI/CD ひと筋。

テストカバレッジ — Service から #

中級 #7 で見たパターンそのままで進めます。2 編 で作った AuthService は localStorage にトークンを入れたり消したりする単純なクラスでしたね。

src/app/core/auth/auth.service.spec.ts
describe('AuthService', () => {
  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [AuthService] });
  });

  it('login はトークンを保存し isAuthenticated を true にする', () => {
    const auth = TestBed.inject(AuthService);
    auth.login('curtis', 'password');
    expect(auth.isAuthenticated()).toBeTrue();
    expect(localStorage.getItem('auth_token')).toBeTruthy();
  });

  it('logout はトークンを空にして状態を初期化する', () => {
    const auth = TestBed.inject(AuthService);
    auth.login('curtis', 'password');
    auth.logout();
    expect(auth.isAuthenticated()).toBeFalse();
    expect(localStorage.getItem('auth_token')).toBeNull();
  });
});

isAuthenticated はシグナルなので、関数のように呼び出して値を取り出せば OK です。localStorage を stub に差し替えることもできますが、ブラウザベースのランナーでは本物の localStorage を使うほうが自然で安全 です — beforeEachclear() だけきれいに済ませれば十分です。

ProductsStore のテスト — シグナル store #

4 編 で作った ProductsStore はシグナルベースの状態コンテナでした。メソッド呼び出しの後に state がどう変わったか — そのまま呼んで検証すれば OK です。

src/app/products/products.store.spec.ts
describe('ProductsStore', () => {
  let store: ProductsStore;
  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [ProductsStore] });
    store = TestBed.inject(ProductsStore);
  });

  it('add は新しい商品を一覧に追加する', () => {
    store.add({ name: 'キーボード', price: 90000, stock: 5 });
    expect(store.products().length).toBe(1);
    expect(store.products()[0].name).toBe('キーボード');
  });

  it('totalValue は 価格×在庫 の合計', () => {
    store.add({ name: 'A', price: 1000, stock: 2 });
    store.add({ name: 'B', price: 500, stock: 4 });
    expect(store.totalValue()).toBe(1000 * 2 + 500 * 4);
  });
});

シグナル store の本当の価値はテスト可能性 だ、というのがここで見えてきます — Observable の購読や非同期フローなしに、普通の関数呼び出しと結果の比較だけで、すべてのシナリオが検証できます。

コンポーネントテスト — Login フォーム #

2 編LoginComponent は Reactive Form を持っていましたね。フォームの検証シナリオはコンポーネントテストで押さえるのが一番堅いです。

src/app/auth/login.component.spec.ts
describe('LoginComponent', () => {
  let fixture: ComponentFixture<LoginComponent>;
  let authSpy: jasmine.SpyObj<AuthService>;

  beforeEach(() => {
    authSpy = jasmine.createSpyObj<AuthService>('AuthService', ['login']);
    TestBed.configureTestingModule({
      imports: [LoginComponent, ReactiveFormsModule],
      providers: [{ provide: AuthService, useValue: authSpy }],
    });
    fixture = TestBed.createComponent(LoginComponent);
    fixture.detectChanges();
  });

  it('空入力で送信すると login は呼ばれない', () => {
    fixture.nativeElement.querySelector('form').dispatchEvent(new Event('submit'));
    expect(authSpy.login).not.toHaveBeenCalled();
  });

  it('有効な入力なら login が呼ばれる', () => {
    const el: HTMLElement = fixture.nativeElement;
    const username: HTMLInputElement = el.querySelector('[data-testid="username"]')!;
    const password: HTMLInputElement = el.querySelector('[data-testid="password"]')!;
    username.value = 'curtis';
    username.dispatchEvent(new Event('input'));
    password.value = 'secret123';
    password.dispatchEvent(new Event('input'));
    el.querySelector('form')!.dispatchEvent(new Event('submit'));
    expect(authSpy.login).toHaveBeenCalledOnceWith('curtis', 'secret123');
  });
});

ポイントは 2 つ — AuthService を spy に差し替えて 本物の認証ロジックから隔離すること、data-testid でフォーム要素を探して デザイン変更に強いテストを作ること。

HttpTestingController の回収 — Product API #

3 編ProductApiService は本物のネットワークを通すままだとテストがブレます。HttpTestingController で偽のレスポンスを流してやります。

src/app/products/product-api.service.spec.ts
describe('ProductApiService', () => {
  let service: ProductApiService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ProductApiService, provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(ProductApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });
  afterEach(() => httpMock.verify());

  it('GET /api/products は商品配列を流す', () => {
    const fake = [{ id: '1', name: 'キーボード', price: 90000, stock: 5 }];
    service.list().subscribe((products) => expect(products).toEqual(fake));
    const req = httpMock.expectOne('/api/products');
    expect(req.request.method).toBe('GET');
    req.flush(fake);
  });

  it('500 レスポンスなら空配列にフォールバックする', () => {
    service.list().subscribe((products) => expect(products).toEqual([]));
    httpMock.expectOne('/api/products')
      .flush('boom', { status: 500, statusText: 'Server Error' });
  });
});

2 つ目のケースが本当の価値です — 普段は発生しない 500 エラーのフォールバック分岐を テストが強制的に一度踏ませてくれる 、というわけです。本物のサーバーをわざと殺さない限り、手で検証するのが難しいシナリオです。

E2E テスト — Playwright #

ユニット / コンポーネントテストが部品を見るとすれば、E2E は ユーザーがたどるフロー を見ます。Angular 公式の Protractor は deprecate されて久しく、今どきは Playwright や Cypress を付けます。npm init playwright@latest でインストールすると、e2e/ フォルダと playwright.config.ts が一緒に生成されます。設定には dev サーバーを自動で立ち上げる webServer ブロック 1 つだけを追加しておけば十分です — command: 'npm run start'url: 'http://localhost:4200'reuseExistingServer: !process.env.CI

では最初のシナリオ — ログイン → 商品追加 → チャート更新。

e2e/dashboard.spec.ts
test('ログインして商品を追加するとチャートが更新される', async ({ page }) => {
  await page.goto('/login');
  await page.getByTestId('username').fill('curtis');
  await page.getByTestId('password').fill('password');
  await page.getByRole('button', { name: 'ログイン' }).click();
  await expect(page).toHaveURL(/\/dashboard/);

  await page.getByRole('link', { name: '商品' }).click();
  await page.getByRole('button', { name: '新規商品' }).click();
  await page.getByTestId('product-name').fill('E2E キーボード');
  await page.getByTestId('product-price').fill('120000');
  await page.getByTestId('product-stock').fill('3');
  await page.getByRole('button', { name: '保存' }).click();
  await expect(page.getByText('E2E キーボード')).toBeVisible();

  await page.getByRole('link', { name: 'ダッシュボード' }).click();
  await expect(page.getByTestId('total-value')).toContainText('360,000');
});

getByTestIdgetByRole が Playwright の本当の武器です — CSS セレクタで画面を撫でる代わりに ユーザー視点の意味 で要素を捕まえます。デザインが変わっても意味が変わらなければ、テストは壊れません。

ヒント
E2E テストは 数値ではなくフロー を検証する場です。1 つのシナリオでログインから中心機能までを一度にたどる「smoke テスト」を 1~2 個置いておくのが、たいてい一番コスパが良いです。ユニット・コンポーネントテストが深さを、E2E は幅を見ると考えてください。

環境変数 — dev と prod の分離 #

デプロイに進む前に、1 つ整理が必要です — API URL が dev では http://localhost:3000、prod では https://api.example.com のように変わる必要がありますね。

src/environments/environment.ts / environment.prod.ts
// dev
export const environment = { production: false, apiUrl: 'http://localhost:3000' };
// prod
export const environment = { production: true, apiUrl: 'https://api.example.com' };

angular.jsonproduction configuration に fileReplacements を置きます。

angular.json
"configurations": {
  "production": {
    "fileReplacements": [
      { "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.prod.ts" }
    ],
    "optimization": true,
    "outputHashing": "all"
  }
}

コードは 1 カ所だけ import すれば OK です — environment.apiUrl をベースとして使えば、ng build が production ビルド時に prod ファイルに差し替えてくれます。

注記
機微情報 (API キー、シークレット) は environment ファイルに絶対に置かないでください — クライアントバンドルにそのまま埋め込まれて、誰でも見られてしまいます。本物のシークレットはバックエンドで扱い、フロントエンドには 公開してよい URL/フラグ だけを置くのが原則です。

ビルドと Dockerize #

ng build --configuration production を回すと、dist/<プロジェクト名>/browser/ にハッシュ付きの index.htmlmain-*.js・lazy チャンクが落ちてきます (Angular 17+)。このフォルダごと静的ホスティングに上げるか、複数の環境に同じものをデプロイしたければ Docker イメージにまとめます — ビルドステージ (Node) で ng buildランタイムステージ (nginx) では静的ファイルだけコピー。最終イメージに Node が入らないので、サイズが半分以下に落ちます。

Dockerfile
# ---------- Build stage ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production

# ---------- Runtime stage ----------
FROM nginx:alpine
COPY --from=builder /app/dist/dashboard/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

SPA で抜けやすいのが 404 fallback です。/dashboard/products のような深い URL を直接入力してリロードすると、nginx はそのパスのファイルを探そうとして 404 を返します。すべてのパスを index.html に送らないと、Angular のルーターが処理できません。

nginx.conf
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  location ~* \.(js|css|woff2|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
  location / { try_files $uri $uri/ /index.html; }
}

docker build -t dashboard .docker run --rm -p 8080:80 dashboard。イメージサイズは通常 30~40MB 以下です (nginx:alpine + 静的ファイル)。

デプロイ — Cloudflare Pages #

デプロイの選択肢は 3 つです — Vercel、Cloudflare Pages、Netlify。3 つとも静的ホスティング + グローバル CDN + たっぷりの無料プランです。私のおすすめは Cloudflare Pages です — ビルド速度が速く、帯域制限が事実上なく、日本からの応答が一番安定しています。

GitHub レポジトリを連携すれば、クリック数回で終わります。

  1. Workers & Pages → Create application → Pages → Connect to Git
  2. レポジトリを選択
  3. Build command: npm run build -- --configuration production
  4. Build output directory: dist/dashboard/browser
  5. Environment variables: NODE_VERSION=20

push が起こると Cloudflare が自動でビルドしてデプロイしてくれます。PR ごとに preview URL も自動生成されるので、レビューがずっと楽になります。静的ファイルだけサーブする SPA の場合、わざわざ Docker イメージとしてコンテナホスティングまで持っていく理由はあまりありません — コンテナは バックエンドと一緒にデプロイするとき に真価を発揮します。

CI/CD — GitHub Actions #

最後の 1 ピース。push のたびに手でテストを回してビルドしていると、結局漏れが出ます。GitHub Actions ファイル 1 つで自動化します。

.github/workflows/ci.yml
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test -- --watch=false --browsers=ChromeHeadless
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - run: npm run build -- --configuration production

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build -- --configuration production
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          command: pages deploy dist/dashboard/browser --project-name=dashboard

流れは明快です — test ジョブがユニット・E2E・ビルドを一度に回し、それが全部通ったら main ブランチに限り deploy ジョブが動きます。PR では deploy なしで test だけが回り、マージされて main に入ったらようやくデプロイが起こります。

secrets.CF_API_TOKENCF_ACCOUNT_ID は GitHub レポの Settings → Secrets and variables → Actions に登録します。一度セットアップしてしまえば、それ以降はただ git push するだけで OK です。

まとめ — 実践トラックを振り返る #

ここまでが Angular実践講座 6 編の最終回 です。短く振り返ると:

  • #1 ダッシュボード骨格 — Standalone + Router + レイアウト
  • #2 認証フロー — Reactive Form ログイン、トークン、関数型ガード、インターセプター
  • #3 フォーム + API — 複雑なフォーム検証、HttpClient + Resource API
  • #4 状態管理 — シグナル store から NgRx Signal Store まで
  • #5 UI ライブラリ — Angular Material のデザインシステムとテーマ
  • #6 テストとデプロイ — テストカバレッジ、Docker、Cloudflare Pages、GitHub Actions

小さなデモではなく、ログインして、データを受け取り、フォームから入力し、チャートが描かれ、デザインが乗り、デプロイまで終わる 一周を最後まで回しました。実務で新しい Angular プロジェクトが始まるときに通る決定が、この 6 編にほぼ全部詰まっていると思っていただいて構いません。

Angular トラック 27 編 — 全体振り返り #

これで Angular トラック 27 編 がすべて終わりました。一度に並べて見るとこんな形です。

  • 基礎 7 編 — 「Angular で画面を作る」 (Angular とは / コンポーネント / データバインディング / Directive・Pipe / Service・DI / Router / HttpClient)
  • 中級 7 編 — 「Angular を実務で使う」 (Reactive Forms / RxJS / Lifecycle・CD / Interceptor / Lazy・Guards / SSR / テスト)
  • 上級 7 編 — 「Angular の内側を理解する」 (CD 深掘り / Signals 深掘り / RxJS 深掘り / DI 深掘り / SSR・Hydration / マイクロフロントエンド / パフォーマンスチューニング)
  • 実践 6 編 — 「1 つのプロダクトを最初から最後まで」 (骨格 / 認証 / フォーム+API / 状態管理 / UI / テストとデプロイ)

基礎が道具の名前を覚える場、中級はその道具で日々の問題を解くパターン、上級はその道具の内側を覗く時間、実践はそのすべてを 1 つのプロダクトの文脈で組み立ててみる仕上げでした。シグナルベースの反応型モデル + Standalone + 関数型ガード/インターセプター + OnPush + lazy loading の組み合わせが、モダン Angular の標準骨格である、というのが 27 編を貫く 1 行の結論です。

手元に残るのは次のようなものです — 新しい Angular プロジェクトが始まるときに 最初の設計判断 を迷いなく下せる感覚、Reactive Forms と RxJS、Signals のうち 状況に合った道具 を選んで使う判断力、HTTP Interceptor や関数型ガードのような Angular らしいパターン を読み書きする能力、テストと CI/CD を最初から組み込んで進める 開発の流れ

次のステップのおすすめ #

Angular そのものとしては、ここで大きな絵が閉じます。その先に時間を使うとよい方向は 3 つあります。

  1. テスティングトラック — Playwright と Vitest をもっと深く。ビジュアル回帰テスト (Percy/Chromatic)、Component Testing、MSW (Mock Service Worker)、テストピラミッドの実務適用。一度深く籠もると、一生使える技術になります。
  2. 状態管理深掘り — NgRx Signal Store、Component Store、RxAngular、TanStack Query for Angular。ドメインが大きくなるにつれて、どの道具がどの場面に合うか — 実践 #4 の判断をさらに固める場です。
  3. バックエンドサイド — NestJS トラック (Angular とデザインが最も近い Node.js フレームワーク)。DI、デコレータ、モジュールのような馴染みのある概念がそのままサーバーへつながります。フルスタックを直接 1 周回してみると、フロント側の判断がなぜそういう形なのかが改めて見えてきます。

最も強力な次のステップは — あなた自身の小さなサイドプロジェクト です。27 編の記事で身につけた道具を、あなたが本当に使いたい小さな道具 を作るのに一度適用してみてください。講座 27 編を読み直すよりも、自分で作った 1,000 行のアプリ 1 つのほうが、はるかに大きな資産になります。

長いトラックを最後までお読みいただき、ありがとうございました。どの講座でも最後までやり切ること自体が一番難しい工程ですが、本トラックでは 27 編を完走しました。次のステップとしては、本トラックで身につけた道具を使って、ご自身の小さなアプリを 1 つ作ってみることを強く推奨します。

X