このエントリーをはてなブックマークに追加

Angular Advent Calendar 2 日目の記事です。 この記事は「E2E テストって名前は知ってるけど、やり方知らないし敷居が高いなー…」なんて思っている方に向けてツールの導入方法および簡単なサンプルを紹介するものです。かくいう私も Angular で E2E テストを書くのは初めてのため、手探りで調べながら記事を書きました。

この記事を書いた環境

  • Node 14.8
  • Angular 11

この記事のコードは GitHub でご覧いただけます。導入の際の参考になれば幸いです。 https://github.com/ringtail003/blog-angular-e2e

1)はじめに

E2E テストの目的

テストといえば、最もなじみのあるのが「ユニットテスト」ではないでしょうか。Angular でも $ ng generate component {コンポーネント名} と CLI でコンポーネントを作成すると、ユニットテストの雛形のファイルが生成されますね。

ユニットテストは単体のコンポーネントやサービスをテスト対象 とします。最もユースケースの多い正常な挙動に加え、データが極端に多いケースや空っぽのケース、例外が発生するケースなど、さまざまなバリエーションを与えてコンポーネント/サービスの挙動をチェックしていきます。単体のテスト対象にフォーカスするため、コンストラクタで注入する他のサービスや子コンポーネントはモックに差し替える事が多いでしょう。そして、コンポーネント/サービスを新規開発する時、変更を加える時など 実装コードと同時進行でテストを書いていくのが特徴 です。

それと比較して E2E テストは、完成したパーツ(コンポーネントやサービス)を実稼働に近い状態でテスト します。Web システムの場合はブラウザを開いてエンドユーザーの操作を模倣するスタイルが多く、コンポーネント/サービスは実装が完了している事が前提となります。

私が仕事で開発しているプロジェクトでは、作業にある程度のまとまりがついた段階でステージングサーバーにデプロイし、そこで動作確認を行って問題がなければ本番環境にデプロイしリリース、という運用をしています。ステージングサーバーでの動作確認は自分でページを操作しながら全機能を目視確認する「人力テスト作業」です。機能が増えるにつれ人力テスト作業の時間は増え続け、ここ最近では 2 時間以上かかるようになってしまいました。正直しんどい。やっている操作は毎回ほぼ同じなので、操作をシナリオに書き起こして自動化したい。 これが私の考える E2E テストの目的です。

E2E テストフレームワーク

E2E テスト専用のフレームワークってちょっと調べただけでも結構な数がありました。GitHub のスター数が多いのはこんなところでしょうか。

名前 Web サイト 配布元 リポジトリ GitHub star
Puppeteer Official npm GitHub 66.4 K
cypress Official npm GitHub 24.3 K
Nightwatch.js Official npm GitHub 10.5 K
Protractor Official npm GitHub 8.7 K
testcafe Official npm GitHub 8.6 K

さてさてどれを使って良いのやら…。今回は 「何となく聞いたことあるから使ってみたい」という独断で ●Protractor ●Puppeteer ●cypress の 3 つをお試しで使ってみようと思います。

2)テスト対象のデモアプリ

E2E テストの対象として、このような簡単なデモアプリを作ってみました。

ユーザー名が ringtail003 ならログインできます。パスワードは何でも通ります。ガバガバアプリです。

E2E でテストしたい内容(= シナリオ)は下記の通りです。

  1. 正常ケースのログイン/ログアウト
    • ユーザー名 ringtail003 でログインするとプロフィールのページに遷移する
    • プロフィールのページでログアウトする
  2. 異常ケースのログイン
    • エラーメッセージが表示されログインできない

3)Angular のサンプルテスト掘り下げ編

実は Angular では E2E の雛形のテストファイルとそれを動かす仕組みがビルトインで提供されています。CLI で $ ng new {プロジェクト名} した時、ルートディレクトリ直下に e2e というフォルダがあるのを見つけた人、いませんか?

E2E テストフレームワーク導入の前に、まずはサンプルのテストを動かして Angular の E2E テストがどのように動いているのか、構造を把握しておきましょう。

サンプルテストを叩く

デモアプリのプロジェクトで $ ng e2e コマンドを叩いてみます。サクッとテストが落ちます。

サンプルテストのコードを掘り下げる

/e2e/src を見てみましょう。ここにテストコードがあります。

このフォルダの tsconfig.json には types: ["jasmine"] が宣言されているためユニットテストでもおなじみの describe や it を使う事ができます。

サンプルの E2E テストはアプリケーションのルートコンポーネントである <app-root> にレンダリングされた内容を検証しています。デモアプリでレンダリング内容を変更してしまったので E2E テストが落ちたという訳です。

ng e2e を掘り下げる

$ ng e2e コマンドの動作は angular.json に宣言されています。ここを見ると TypeScript のテストコードを @angular-devkit/build-angular:protractor でビルドして、設定ファイルに何やら protractor.conf.js をというファイルを指定しているようです。

package.json を見ると E2E テストフレームワークの protractor への依存が定義されていますね。

Angular の $ ng e2e コマンドは Protractor を使った E2E テストだという事が分かりました。

protractor.conf.js を掘り下げる

次は e2e/protractor.conf.js を覗いてみましょう。

どうやら Chrome で http://localhost:4200/ を起動しているようです。

directConnect
Protractor が内包する Selenium を介さず PC にインストールされたブラウザを探し出して起動するオプションです。

framework
jasmine のコードを読み込んで describe や it が error TS2304: Cannot find name 'xxx'. エラーにならないようにしています。

Selenium を掘り下げる

Selenium はブラウザを使ったテストツールを提供するフレームワークです。ブラウザ操作の API である WebDriver を利用しているため $ ng e2e は E2E テストの開始直前に WebDriver をアップデートします。

$ ng e2e が吐き出すテキストを見ると node_modules 配下で何やらドライバをせっせとダウンロードしていますね。

ドライバのアップデートは $ ng e2e の度に実行されますが、ブラウザの新しいバージョンが出ない限りドライバ自体はそう頻繁に更新されません。そこで --no-webdriver-update オプションを渡す事でアップデートをスキップする事ができます。

Tips
$ npm ci した直後など WebDriver が一度もアップデートされていない状態で --no-webdriver-update オプションを使うと Error: Could not find update-config.json. というエラーが発生します。その際はオプションを外すか、手動で WebDriver をアップデートする必要があります。

# 手動で WebDriver をアップデート
$ ./node_modules/.bin/webdriver-manager update

4)E2E テスト準備編

WebAPI のモック

通常、ローカル開発には http://localhost:4200 を使いますが、これとは別に E2E テスト専用のエンドポイントを用意しておくと便利 です。レスポンスのモックデータも E2E テスト専用のものを用意しておきます。そうする事で実稼働のサーバーサイドの API やローカル開発環境用のモックデータと分離できて E2E テストが独立した外からの影響を受けにくいもの になります。

E2E テスト専用のビルトインサーバーでオススメなのは angular-in-memory-web-api です。これを使うと E2E のテストコードとモックデータをひとつの Angular アプリに集約する事ができます。そしてアプリと同じプロセスで動くので、サーバーの起動を待ったり、うまく動かない時にログをどこかに吐き出したりといった面倒な事から解放されます。

angular-in-memory-web-api で E2E 専用のモックを作る手順は下記の通りです。

  1. npm install
  2. E2E テスト専用のモックデータを宣言
  3. “e2e” という環境を environment で宣言
  4. angular.json で “e2e” の “fileReplacements” を定義
  5. “e2e” テスト環境で API レスポンスをモックに置き換え
  6. “http://localhost:4201” を E2E テスト用のエンドポイントとして定義
  7. E2E テスト環境を一発で起動できるように package.json にコマンド追加

さてこれで $ npm run start:e2e と叩くと http://localhost:4201 で E2E テスト専用の環境が立ち上がるようになりました。必要であればブラウザ越しに人力テスト作業をする事もできます。

5)導入編

さて、それではいよいよ E2E テストフレームワーク導入をしてみましょう。

この記事では e2e/protractor e2e/puppeteer e2e/cypress というフレームワークごとのフォルダを配置していきます。紛らわしいので雛形のテストコード e2e/src フォルダは削除しておきましょう。

Protractor

サポートブラウザ

名前 サポート
Chrome
FireFox
Safari
Edge -
その他 IE をサポート

https://www.protractortest.org/#/browser-support

導入する手順

  1. tsconfig.json を設定する
  2. angular.json に tsconfig.json を設定追加する
  3. conf.js に protractor の設定を記述する
  4. テストを作成する
  5. “npm run e2e:protractor” で E2E テストを実行する

補足
angular-in-memory-web-api のモックが不要であれば、上記の手順は全て不要です。その場合は e2e/src 配下のテストを書き換えて $ ng e2e するだけです。

protractor のテストコード

import { $, $$, browser, by, logging } from "protractor";

describe("login-valid", () => {
  const baseUrl = "http://localhost:4201/";

  it("有効なユーザーでログインしログアウトできる事", async () => {
    // ログインページを開く
    browser.get(`${baseUrl}login`);

    // ログイン
    $("form input[name='username']").sendKeys("ringtail003");
    $("form input[name='password']").sendKeys("***");
    $("form").submit();

    // プロフィールのページに遷移した事を確認
    browser.getCurrentUrl().then((url) => {
      expect(url).toBe(`${baseUrl}profile`);
    });

    // プロフィールのページにレンダリングされた内容をテスト
    expect(await $$("fieldset").all(by.css("span")).getText()).toEqual([
      "user@example.com",
      "ringtail003",
      "2020-12-02 10:15",
    ]);

    // ログアウト
    $("form").submit();

    // ログインページに遷移した事を確認
    browser.getCurrentUrl().then((url) => {
      expect(url).toBe(`${baseUrl}login`);
    });
  });

  afterEach(async () => {
    // 省略
  });
});

protractor の使い方

browser オブジェクトを利用してブラウザでページを開きます。

browser.get("http://localhost:4201/login");

element(by.css()) または $ を利用すると DOM の単体の要素にアクセスできます。

$("form input[name='password']").sendKeys("***");
$("form").submit();

element.all(by.css()) または $$ を利用すると DOM の要素を配列として取得できます。

$$("fieldset").all(by.css("span")).getText();

公式ドキュメントでは by.repeater などビルドされる前の HTML を指定する方法が書いてあるのですが、残念ながらこれは AngularJS の記法で Angular では利用できません。

<!-- AngularJS の記法 -->
<div ng-repeat="cat in pets">
  <span>{{cat.name}}</span>
  <span>{{cat.age}}</span>
</div>
var firstCatName = element(
  by.repeater("cat in pets").row(0).column("cat.name")
);

スクリーンショットを撮る方法もありますが、まあまあ自力な感じのコードですね。 https://www.protractortest.org/#/api?view=webdriver.WebElement.prototype.takeScreenshot

function writeScreenShot(data, filename) {
  var stream = fs.createWriteStream(filename);
  stream.write(new Buffer(data, "base64"));
  stream.end();
}

var foo = element(by.id("foo"));
foo.takeScreenshot().then((png) => {
  writeScreenShot(png, "foo.png");
});

Pros

Protractor の良さは何と言っても導入の簡単 さです。ビルトインで E2E テスト環境が提供されているため angular-in-memory-web-api で紹介したような API レスポンスのモックが不要であれば アプリ付属のサンプルテストコードを書き換えるだけで簡単に動きます

Cons

E2E のテスト内容が簡単なシナリオであれば良いのですが、スクリーンショットのコードを見て分かるように簡単便利な API はあまり提供されていません。Protractor で提供されていない API は WebDriver の API リファレンスを探して直接ドライバを叩くようなコードを書く事になります。がっつりシナリオを書いて細かくテストする場合には不向き だと言えます。

Puppeteer

サポートブラウザ

名前 サポート
Chrome
FireFox ●(experimental)
Safari -
Edge -
その他  

導入する手順

  1. npm install
  2. tsconfig.json を設定する
  3. angular.json に tsconfig.json を設定追加する
  4. conf.js に Puppeteer の設定を記述する
  5. capture フォルダを追加して Git 管理外にする
  6. テストを作成する
  7. “npm run e2e:puppeteer” で E2E テストを実行する

補足
通常 puppeteer はテストコードを JavaScript で書いて $ node test.js のように呼び出します。またその際、テスト対象の http://localhost:4201 が起動している必要があります。
上記の導入手順はビルトインの Protractor の仕組みを利用して TypeScript のトランスパイルからアプリの起動まで全てを Angular に任せたものです。必要な部分だけ後から差し替えできるよう設計されているのが Angular の良いところですね。

補足その2
Puppeteer には puppeteerpuppeteer-core のパッケージがあります。 前者の puppeteer パッケージのほうは Chromium.app (Chrome のベースとなるオープンソースのブラウザ)のバイナリをダウンロードしますが、これが 306MB とまあまあ巨大でした。CI で実行する事を考えると大きなファイルのダウンロードは避けたいので、後者の puppeteer-core パッケージのほうを使ったほうが良いでしょう。

puppeteer のテストコード

※ コードが長いので一部を関数にして helper ディレクトリに分離しています。

import { Page } from "puppeteer-core";
import { browser } from "./helper/browser";
import { screenshot } from "./helper/capture";
import { emulate } from "./helper/device";

describe("login-valid", () => {
  it("有効なユーザーでログインしログアウトできる事", (done: DoneFn) => {
    (async () => {
      // ブラウザの起動
      const sandbox = await browser.launch();

      // ログインページを開く
      const page: Page = await sandbox.newPage();
      emulate(page);
      await page.goto(`${browser.baseUrl}login`);

      // ログイン情報の入力
      await page.type("[name='username']", "ringtail003");
      await page.type("[name='password']", "***");

      // ログイン
      await page.click("form [type='submit']");

      // プロフィールのページに遷移した事を確認
      await page.waitForNavigation();
      expect(page.url()).toBe(`${browser.baseUrl}profile`);

      // プロフィールのページにレンダリングされた内容をテスト
      const fieldset = await page.$$("fieldset");
      const span = fieldset.map(async (v) =>
        v.$eval("span", (node) => node.innerHTML)
      );

      expect(await Promise.all(span)).toEqual([
        "user@example.com",
        "ringtail003",
        "2020-12-02 10:15",
      ]);

      // キャプチャを撮る
      await screenshot(page, "ログイン完了");

      // ログアウト
      const submit = await page.$("form [type='submit']");
      submit?.click();

      // ログインページに遷移した事を確認
      await page.waitForNavigation();
      expect(page.url()).toBe(`${browser.baseUrl}login`);

      // キャプチャを撮る
      await screenshot(page, "ログアウト完了");

      await sandbox.close();
    })().then(done);
  });
});

puppeteer の使い方

puppeteer.launch でブラウザを起動します。

await puppeteer.launch({
  // true にすると起動したブラウザに何もレンダリングされない、CI で実行する時は true にする
  headless: false,
  // 500 など設定するとブラウザ操作のスピードが緩くなり目視確認がやりやすい
  slowMo: 50,
  // デバッグウインドウの起動
  devtools: false,
  // PC にインストール済みのブラウザのパスを渡す
  executablePath:
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
});

goto でページを開きます。

const page: Page = await puppeteer.launch({ ... });
await page.goto(`http://localhost:4201/login`);

emulate を使うとブラウザのデバイスをエミュレートできます。 デバイスはの定義は puppeteer パッケージに定義されたものを参考にしました。
https://github.com/puppeteer/puppeteer/blob/v5.4.1/src/common/DeviceDescriptors.ts

page.emulate({
  name: "iPhone X",
  userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac ...",
  viewport: {
    width: 375,
    height: 812,
    deviceScaleFactor: 3,
    isMobile: true,
    hasTouch: true,
    isLandscape: false,
  },
});

エミュレートしたデバイスでスクリーンショットが簡単に撮れます。

await page.screenshot({
  path: `${__dirname}/../capture/テスト.png`,
  fullPage: true,
});

ヘッドレス Chrome の仕様でデフォルトフォントが明朝体になってしまうので、スクリーンショットのためにアプリ全体にフォントを適用したほうが良さそうです。

/* src/styles.scss */

body {
  font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN",
    "Hiragino Sans", Meiryo, sans-serif;
}

フォント指定については下記サイトをを参考にさせていただきました。
2020 年に最適な font-family の書き方

Pros

導入は簡単で E2E テストに必要なブラウザ操作についても一通りの機能が揃っている印象です。

公式ドキュメント もシンプルで程よいボリュームです。API を探して巨大なドキュメントをさまようドキュメント難民にならずに済みそう です。

Cons

テストコードを見て分かる通り async await の嵐です。試しにコードを書いてみた程度ですがすでに 非同期処理との戦い が始まっていて、複数デバイスをエミュレートし連続でキャプチャを撮れないかと苦戦した結果あきらめました。

その他

ブラウザ操作を記録して puppeteer のテストコードを吐き出す Headless Recorder という Chrome エクステンションがあったので試してみました。

DOM の階層を忠実に辿りすぎて .ng-untouched などを拾ってしまうため、吐き出したコードをそのまま E2E テストとして使うにはちょっと苦しい感じでした。puppeteer は API がシンプルなので、簡単な操作であれば自分で書いたほうが読みやすいすっきりしたテストコードになりそうです。

const puppeteer = require('puppeteer');
(async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  const navigationPromise = page.waitForNavigation()

  await page.goto('http://localhost:4201/login')
  await page.setViewport({ width: 1280, height: 878 })
  await navigationPromise
  await navigationPromise

  await page.waitForSelector('app-login > .ng-untouched > .container > fieldset:nth-child(1) > .ng-untouched')
  await page.click('app-login > .ng-untouched > .container > fieldset:nth-child(1) > .ng-untouched')
  await page.waitForSelector('app-login > .ng-invalid > .container > fieldset > .ng-untouched')
  await page.click('app-login > .ng-invalid > .container > fieldset > .ng-untouched')
  ...

  await browser.close()
})()

cypress

サポートブラウザ

名前 サポート
Chrome
FireFox
Safari -
Edge
その他 Electron をサポート

https://docs.cypress.io/guides/guides/launching-browsers.html#Browsers

導入する手順

  1. npm install
  2. tsconfig.json を設定する
  3. angular.json に tsconfig.json を設定追加する
  4. cypress.json に設定を記述する
  5. supports フォルダのファイルを作成する
  6. plugins フォルダのファイルを作成する
  7. fixtures フォルダのファイルを作成する
  8. capture フォルダを追加して Git 管理外にする
  9. integration フォルダにテストを作成する
  10. “npm run e2e:cypress” で E2E テストを実行する

補足
手順が多いように思うかもしれませんが $ npm install の後に $ ./node_modules/.bin/cypress open するとプロジェクトのルートディレクトリ直下に雛形が生成されます。そこからコピーすれば導入はとても簡単です。

補足その2
cypress-schematic を使うと $ ng add で cypress を簡単に導入できます。今回は仕組みを知りたかったので、あえて最初からインストールしました。

説明

cypress は TypeScript のトランスパイルやテストランナー全てひっくるめた、オールインワンのフレームワークです。そのため Angular ビルトインの E2E の仕組みではなく cypress を直接叩く必要があります。テスト実行のためのサブコマンド open は GUI のテストランナーを起動し run は CLI でテストを実行します。

これらをコマンド一発で起動するように package.json に追加しておきます。

// package.json
{
  "scripts": {
    ...
    "cypress:open": "cypress open --config-file ./e2e/cypress/cypress.json --browser chrome",
    "cypress:run": "cypress run --config-file ./e2e/cypress/cypress.json --browser chrome"
  }
}

また Angular アプリが起動してから open run する必要があるため start-server-and-test でアプリの起動完了を待ちます。これも package.json に追加しておきます。

// package.json
{
  "scripts": {
    ...
    "e2e:cypress": "start-server-and-test start:e2e http://localhost:4201 cypress:run",
    "e2e:cypress:ide": "start-server-and-test start:e2e http://localhost:4201 cypress:open"
  }
}

$ npm run e2e:cypress:ide を叩くと GUI のテストランナーが起動します。ここではブラウザを切り替えて 1 ファイルごとにテストを実行する事ができます。

$ npm run e2e:cypress を叩くと CLI でテストを実行します。

cypress のテストコード

context("login-valid", () => {
  it("有効なユーザーでログインしログアウトできる事", () => {
    cy.fixture("valid.json").then((fixture) => {
      cy.visit("/login");
      cy.get("[name='username']").type(fixture.username);
      cy.get("[name='password']").type(fixture.password);
      cy.get("form").get("footer").contains("ログイン").click();

      cy.wait(500);
      cy.location().should((location) => {
        expect(location.pathname).to.eq("/profile");
      });

      cy.contains("user@example.com");
      cy.contains("ringtail003");
      cy.contains("2020-12-02 10:15");

      cy.screenshot("ログイン完了");

      cy.get("form").get("footer").contains("ログアウト").click();

      cy.wait(500);
      cy.location().should((location) => {
        expect(location.pathname).to.eq("/login");
      });

      cy.screenshot("ログアウト完了");
    });
  });
});

cypress の使い方

テストコードは mochachai を使って書きます。

context("xxx", () => {
  it("yyy", () => {
    expect({}).to.eql({});
    expect(1).not.to.be(2);
  });
});

fixtures フォルダで宣言した json を cy.fixture で読み込む事ができます。

// fixtures/valid.json
{
  "username": "ringtail003",
  "password": "***"
}
cy.fixture("valid.json").then((fixture) => {
  fixture.usrname; // "ringtail003"
});

fixture から読み込んだデータは画面の入力値として使用する事ができます。データと振る舞いを分離できるのできれいですね。

cy.fixture("valid.json").then((fixture) => {
  cy.visit("/login");
  cy.get("[name='username']").type(fixture.username);
  cy.get("[name='password']").type(fixture.password);
  cy.get("form").get("footer").contains("ログイン").click();
});

Web アプリケーションの DOM のレンダリングは非同期ですが cypress では対象の DOM が見つかるまで待機してくれるようです。そのため await を使わず同期処理であるかのようにコードを書くことができます。また特定の DOM 検索のみタイムアウトを伸ばす事もできます。

cy.get(".my-slow-selector", { timeout: 10000 });

今回は使っていませんが supports フォルダのファイルを使うと cy.login() のようなカスタムコマンドを定義する事ができます。

// supports/command.js
Cypress.Commands.add("login", () => {
  cy.get(".footer").contains("ログイン").click();
});

cy.login();

npm で公開されたカスタムコマンドは plugins フォルダのファイルを拡張すれば取り込みができるようです(ドキュメントをチラ見しただけで試してません)。
https://docs.cypress.io/plugins/index.html

実は API レスポンスのモックも cypress で完結します。

cy.intercept("POST", "http://example.com/widgets", {
  statusCode: 200,
  body: "it worked!",
});

https://docs.cypress.io/api/commands/intercept.html#Comparison-to-cy-route

先に紹介した angular-in-memory-web-api を使った方法では cypress を立ち上げなくても http://localhost:4201 で人力テスト作業が可能で、例えば「ユーザー名 “foo” でログインすると初期設定が未完了のユーザーの画面を確認できる」という事もできるため E2E 環境構築の副産物としてローカル開発にも利用する事ができます。ですので、機能が用意されているからといってモックまで cypress に頼らなくてもいいかなあ、というのが正直なところです。

Pros

cypress は とにかく多機能 です。API が直感的でコードを書くのも楽でした。がっつり E2E テストを書くならオススメ のフレームワークと言えます。

Cons

公式ドキュメントのボリュームが非常に大きく 最初はどこから手をつけていいのか分からずドキュメント難民になりがち です。should contains など一見すると単なるメソッドのようにも見える独特のアサーションがあり、また mocha/chai についても少なからず知識が必要です。

まとめ

お試し導入してみた 3 つのフレームワークをまとめました。

フレームワーク 導入が簡単 テストの書きやすさ ドキュメントの調べやすさ 機能の充実 所感
Protractor ★★★ セットアップを簡単に済ませたい人向け
puppeteer ★★ ★★ ★★ そこそこの機能を求める人向け
cypress ★★★ ★★★ がっつりテスト書きたい人向け

結果、2 時間もの人力テスト作業を E2E テストで代行しようとしている私にとっては「がっつりテスト書きたい人向け」の cypress が最もマッチするフレームワークと言えそうです。

明日へのバトン

私は今の会社に入社した当初、テストはおろか JavaScript の配列とオブジェクトの違いすら分からない あんぽんたん でした🥺💧。ユニットテストを書いてくださいという指示に対して人力テスト用の /admin/test.html のようなページを作り、プルリクエストを速攻でリジェクトされたのを 根に持って 覚えています。それが数年後に E2E テストの紹介記事を書くようになるとは、何事も為せば成るものですね。

たびたび詰んで実装スピードが異常なほど遅い私でしたが「大丈夫です、いつか書けるから!👍」といつも背中を押してくれた上司がいました。会社でフロントエンドのフレームワーク選定をした時「Angular は設計のエッセンスが詰まった学びのフレームワークだと思う、エンジニアとして成長するために Angular を今後メインで使うフレームワークに据えたい」という私の意見に「僕もそう思います!!😃」と言って賛成してくれたのも、この上司でした。現在はやりたい事を実現するためフリーランスに転向し元上司となりましたが、今でも私の尊敬するエンジニアの一人です。

明日はそんな私の元上司である ✨📣 ttskch さんの投稿 📣✨ です。たつきちさん、よろしくお願いします!


このエントリーをはてなブックマークに追加

Symfony Advent Calendar 2020 1日目の記事です! 間近に迫ったSymfony Worldを紹介したいと思います。

Symfony Worldとは

12/3(木)〜12/4(金)に開催される、Symfonyのカンファレンスです。 https://live.symfony.com/2020-world/
本場のSymfonyのイベントに参加してみたいなぁと思っても、今までは遠くヨーロッパまで行くしかなく、泣く泣く諦めていました。
しかし、コロナのおかげで初のオンライン開催が実現しました!

カンファレンスは全編英語ですが、リプレイできるらしいので、もし初回で聞き取れなくても何度も復習できそうです。
オンライン開催のもう一つのメリットですね。

参加費

カンファレンスを聞くだけなら119ユーロ(約1万5千円)です。 日本のカンファレンスに比べて高額ですが、Symfonyという巨大なオープンソースの開発を続けるためのカンパの性質があるので、仕方ありませんね。
なお、参加費の決済にJCBカードは使えないのでJCB以外のカードを用意して申し込みしましょう…。

時間帯

海外カンファレンスをオンラインで聞くとなると、次に心配なのが時差です。
今回のSymfony Worldは日本時間17:30〜24:00の放送と、25:30〜翌7:55の再放送があります。(内容は同一とのこと)
私はアジア人に優しい18:00〜枠で参加しようと思っています。

※ カルテット開発部の勉強会参加費補助について

ちなみに今回のSymfony Worldの参加には、カルテット開発部の勉強会参加費補助制度を利用しています。私は名古屋本社勤務なので、現在のコロナ下でも普段は出社して仕事をしているのですが、18時からのSymfony Worldに自宅から参加するため、当日は午後だけ突発リモート勤務の許可も出してもらいました(感謝)

勉強会参加費補助制度を利用した場合、参加後にレポートを書いて提出が必須なのですが、私は今回レポートを開発部ブログに書くということでOKもらいました。
特に面白かったセッションから順にレポート出して行く予定です。乞うご期待!


このエントリーをはてなブックマークに追加

みなさんこんにちは。カルテットコミュニケーションズでフロントエンド開発をしています、松岡です。

松岡やよい | 社員インタビュー記事

弊社の開発環境では、ステージングサーバー・本番サーバーとデプロイを2段階に分けています。ステージングサーバーは本番に近い設定で、バックエンド・フロントエンドのつなぎこみの結合テストなどを行う場です。ステージングサーバーでの検証が終わると同じ内容が本番サーバーにデプロイされる訳ですが、リリース日の都合などで本番サーバーデプロイ待ちのタイムラグが発生し、その間次のリリースに向けて開発している機能は検証を終えたばかりのステージングサーバーにはデプロイできなくなってしまいます。

ユーザーに全く見えない新規画面の開発であればデプロイに問題はないのですが「既存画面にボタンを追加する」「ルーティングを追加するとサイトマップに勝手に出てきてしまう」というような、画面と深く関わるフロントエンドならではの困りごとが出てきます。

ローカル開発環境・ステージングサーバー・本番サーバーと環境ごとに追加機能の有効無効を切り替えられないか、その方法を考えてみたので紹介したいと思います。

環境

  • @angular/cli v10.1
  • node v14.8

追記

【2020/10/07 記事修正のお知らせ】2020/10/05 に記事を公開しましたが「ステージング/本番サーバーでコンポーネントを隠蔽」について @NgModule でのコンポーネント入れ替えは子コンポーネントまで隠蔽できず NO_ERRORS_SCHEMA などを駆使しないといけない事が分かりました。また fileReplacements を使うともっと簡単に隠蔽できる事が分かったので、この方法を使うように加筆修正させていただきました。

サンプルアプリ

簡単なサンプルアプリを例として説明していきます。

このアプリにはナビゲーションバーに Staging Production というリンクボタンがあります。 クリックすると /staging /production に遷移し <router-outlet>(濃い青のボックス部分) にコンテンツを展開します。

リンクは「デプロイしたくない」先のサーバーの名前を示しています。

  • /staging ステージングサーバーにデプロイしたくない(ローカル開発中)
  • /production 本番サーバーにデプロイしたくない(ステージング検証中)

急いで本番サーバーにリリースしたいホットフィックスが発生したら困ってしまいますね。リンクボタンを CSS で隠しておく手もありますが、コンポーネントやサービスのロジックは実行されてしまいます。まだ不完全な HTTP リクエストを実行してしまったり、開発中のサービスクラス内部でエラーが発生するかもしれません。

このサンプルを下記のように環境ごとに機能を制限するアプリに変更していきたいと思います。

環境 /staging リンク /production リンク
ローカル 利用できる 利用できる
ステージング - 利用できる
本番 - -

environment ファイル

Angular には環境ごとに変数を切り替えるための environment.ts というファイルがあります。これを使って、ローカル開発環境・ステージングサーバー・本番サーバーの3つを判別できるように設定を書き足します。

// src/environments/environment.ts

export const environment = {
  local: true,
  staging: false,
  production: false,
};
// src/environments/environment.staging.ts
// ※このファイルは存在しないので新規作成する

export const environment = {
  local: false,
  staging: true,
  production: false,
};
// src/environments/environment.prod.ts

export const environment = {
  local: false,
  staging: false,
  production: true,
};

angular.json の fileReplacements

Angular の CLI コマンドには ng serve --prod のようにプロダクションビルドを示すオプションがあります。 --prod が渡されると fileReplacements によって environment.ts の中身が environment.prod.ts からコピーされ置き換わるようなイメージです。(ビルド中の処理イメージでファイルシステムへの影響はありません)

公式ドキュメント
Angularアプリのビルドとサーブ > ターゲット固有ファイルの置換の設定

ng serve --staging は存在しません。そこで先ほど用意した environment.staging.ts ファイルの設定を angular.json に書き加える必要があります。

// angular.json

"architect": {
  "build": {
    // architect > build > configurations
    "configurations": {
      "production": {
        "fileReplacements": [
          {
            "replace": "src/environments/environment.ts",
            "with": "src/environments/environment.prod.ts"
          }
          ...
      },
      // ① staging を書き足す
      "staging": {
        "fileReplacements": [
          {
            "replace": "src/environments/environment.ts",
            "with": "src/environments/environment.staging.ts"
          }
        ]
      }
    }
  },
  ...
  // architect > serve
  "serve": {
    "builder": ...,
    "configurations": {
      "production": {
        "browserTarget": "app:build:production"
      },
      // ② staging を書き足す
      "staging": {
        "browserTarget": "app:build:staging"
      }
    }
  },
}

書き足した staging という環境は下記のコマンドで呼び出す事ができます。

# ビルトインサーバーを staging 環境で起動
$ ng serve -c staging

# staging 環境のビルド
$ ng build -c staging

angular.json の設定次第で「本番サーバーではコードを難読化する」「ステージング環境ではソースマップを出力する」などの環境ごとのビルドが調整できます。またコンポーネントから environment ファイルを参照すると、それぞれの環境ごとに違った変数の値を得る事ができます。

ステージング/本番サーバーでコンポーネントを隠蔽

サンプルアプリの Staging リンクボタンは「ステージング環境では見せたくない」機能です。 environment で実行環境が判別できるようになったので、ステージング環境でこのリンクを隠蔽しましょう。

リンクボタンは Anchor を表示するだけの下記のようなコンポーネントとします。

// src/app/links/staging-link.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-staging-link',
  template: `<a routerLink="/staging">Staging</a>`,
})
export class StagingLinkComponent {}

このコンポーネントとインターフェースが全く同じである、影武者コンポーネントを作ります。

// src/app/fakes/fake-staging-link.component.ts
// ① ファイルを新規作成する

import { Component } from '@angular/core';

@Component({
  // ② 本物と同じセレクタ
  selector: 'app-staging-link',
  template: ``,
})
export class FakeStagingLinkComponent {
  // ③ 本物に @Input() があれば同じように記述
}

Production リンクボタンも同じように影武者コンポーネントを作ります。

// src/app/fakes/fake-prod-link.component.ts
// ① ファイルを新規作成する

import { Component } from '@angular/core';

@Component({
  // ② 本物と同じセレクタ
  selector: 'app-prod-link',
  template: ``,
})
export class FakeProdLinkComponent {
  // ③ 本物に @Input() があれば同じように記述
}

Angular は selector が同一の複数コンポーネントをひとつのモジュールに登録する事はできません。 仮に StagingLinkComponent(本物)FakeStagingLinkComponent(影武者) を両方とも AppModule に登録すると怒られてしまいます。

そこで fileReplacements を使うと、環境によって本物と影武者を入れ替える事ができるようになります。

// angular.json

"architect": {
  "build": {
    "configurations": {
      // architect > build > configurations > production
      "production": {
        "fileReplacements": [
          ...
          // ① 設定を書き足す
          {
            "replace": "src/app/links/prod-link.component.ts",
            "with": "src/app/fakes/fake-prod-link.component.ts"
          },
          {
            "replace": "src/app/links/staging-link.component.ts",
            "with": "src/app/fakes/fake-staging-link.component.ts"
          }
      },
      // architect > build > configurations > staging
      "staging": {
        "fileReplacements": [
          ...
          // ② 設定を書き足す
          {
            "replace": "src/app/links/staging-link.component.ts",
            "with": "src/app/fakes/fake-staging-link.component.ts"
          }
        ]
      }
    }
  },

これで @NgModule から辿るコンポーネントは環境に応じて影武者に入れ替わるようになります。

// src/app/app.module.ts

import { StagingLinkComponent } from "src/app/links/staging-link.component";
import { ProdLinkComponent } from "src/app/links/prod-link.component";

@NgModule({
  declarations: [
    ...
    StagingLinkComponent,
    ProdLinkComponent,
  ],
  imports: [ ... ],
})
export class AppModule { }

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

ローカル PC ですがビルトインサーバーは「ステージング」環境として起動しています。

  • ステージングサーバーで見せたくない /staging へのリンクは存在しない
  • 本番サーバーで見せたくない /production へのリンクは存在している

次に production 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c production
# ng serve --prod でも同じ

今度はビルトインサーバーは「本番」環境として起動しています。

  • ステージングサーバーで見せたくない /staging へのリンクは存在しない
  • 本番サーバーで見せたくない /production へのリンクは存在しない

開発中の機能がきれいさっぱり隠蔽できましたね!よしよしこれでデプロイのタイミングを気にしなくても大丈夫!

…ではありません。ブラウザのブックマークなどから URL をダイレクトに指定されると /staging/production のページが開いてしまいます。

CanActivate guard

Angular には guard という機能があります。いくつか種類があるのですが、その中の CanActivate を利用するとページ遷移をブロックする事ができます。

公式ドキュメント(未翻訳・英語)
https://angular.io/api/router/CanActivate

実装はとてもシンプルです。ステージングサーバーで見せたくない機能をガードするため、ローカル環境のみ true を返すメソッドを実装しています。

// src/app/guards/staging-guard.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { environment } from 'src/environments/environment';

@Injectable()
export class StagingGuard implements CanActivate {
  canActivate(): boolean {
    return environment.local;
  }
}

本番サーバー用のガードも同じように実装しておきましょう。

// src/app/guards/prod-guard.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { environment } from 'src/environments/environment';

@Injectable()
export class ProdGuard implements CanActivate {
  canActivate(): boolean {
    return environment.local || environment.staging;
  }
}

実装したガードをルーティングに設定します。

// src/app/app-routing.module.ts

import { ProdGuard } from 'src/app/guards/prod-guard';
import { StagingGuard } from 'src/app/guards/staging-guard';

const routes: Routes = [
  {
    path: '',
    children: [
      {
        path: '',
        pathMatch: 'full',
        component: HomePageComponent,
      },
      {
        path: 'staging',
        component: StagingPageComponent,
        // ① ガードを追加
        canActivate: [StagingGuard],
      },
      {
        path: 'production',
        component: ProductionPageComponent,
        // ② ガードを追加
        canActivate: [ProdGuard]
      },
    ],
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

これで URL をダイレクトに入力しても /staging に遷移せずトップページにリダイレクトするようになりました。

ステージング/本番サーバーでサービスを隠蔽

隠蔽したいのはコンポーネントだけではありません。例えばユーザープロファイルを取得するような HTTP クライアントを利用したサービスがあるとします。

// src/app/services/profile-repository.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as Rx from 'rxjs';

@Injectable()
export class ProfileRepository {
  constructor(private http: HttpClient) {}

  get(): Rx.Observable<Profile> {
    return this.http.get<Profile>(`/api/profile`);
  }
}

export interface Profile {
  name: string;
  email: string;
}

ローカル開発環境では angular-in-memory-web-api などを利用してフェイクデータで開発していて、ステージングサーバーや本番サーバーでは RestAPI を呼び出すものとします。そして RestAPI の実装は未完了でレスポンスは常に HTTP 404: Not Found になる、という想定です。

公式ドキュメント
angular-in-memory-web-api サーバーからデータの取得 > データサーバーをシミュレートする

ユーザープロファイルサービスは AppComponent で利用し画面上部にユーザー名を表示します。

// src/app/app.component.ts

import { Profile, ProfileRepository } from 'src/app/services/profile-repository';

@Component({
  selector: 'app-root',
  // プロファイルを表示している
  template: `
    <p>ようこそ {{profile.name}} さん</p>
    ...
  `,
})
export class AppComponent implements OnInit {
  profile: Profile;

  // サービスに依存している
  constructor(
    private repository: ProfileRepository
  ) {}

  ngOnInit(): void {
    // サービスを利用してプロファイルを取得している
    this.repository.get().subscribe(profile =>
      this.profile = profile
    );
  }
}

ここでも影武者を登場させます。

// src/app/fakes/fake-profile-repository.ts
// 新規に追加するファイル

import { Injectable } from '@angular/core';
import { Profile } from 'src/app/services/profile-repository';
import * as Rx from 'rxjs';

@Injectable()
export class FakeProfileRepository {
  get(): Rx.Observable<Profile> {
    return Rx.of({
      name: '影武者',
      email: 'dummy@example.com',
    });
  }
}

一部のエンドポイントだけ実装が完了していないのであれば、処理を部分的に委譲する事もできます。

// src/app/fakes/fake-profile-repository.ts

@Injectable()
export class FakeProfileRepository {
  // ① 本物をインジェクト
  constructor(
    private delegate: ProfileRepository
  ) {}

  // ② 実装完了のエンドポイントは本物に委譲
  delete = () => this.delegate.delete();
  update = (profile: Profile) => this.delegate.update(profile);

  // ③ 未実装のエンドポイントはダミーデータを返す
  get(): Rx.Observable<string> {
    return Rx.of('おいら影武者ッス');
  }
}

サービスは Angular の DI を使って影武者に置き換えます。モジュールの providers で DI コンテナを操作すれば、あちこちのコンポーネントからサービスが利用されていても、その全てが影武者に置き換わります。

// src/app/app.module.ts

@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
    // ① 設定を追加する
    {
      provide: ProfileRepository,
      useFactory: (http) => {
        // ローカル開発環境は本物のサービス
        if (environment.local) {
          return new ProfileRepository(http);
        }

        // ステージング・本番サーバーは影武者サービス
        return new FakeProfileRepository();
      },
      deps: [HttpClient],
    },
  ],
  ...
})
export class AppModule { }

補足 Angular の DI はとても柔軟で、コンポーネント単位での providers 指定も可能です。 その場合は「全てが影武者に置き換わる」が当てはまらないので providers ごとに設定を追加する必要があります。

staging 環境でビルトインサーバーを立ち上げてみましょう。

$ ng serve -c staging

HTTP リクエストを実行しない影武者が登場しましたね。これでステージングサーバーにデプロイしても HTTP 404: Not Found で処理がストップする事はなくなりました。

※ このまま本番サーバーにデプロイすると ようこそ影武者さん が見えてしまうので、先程の「ステージング/本番サーバーでコンポーネントを隠蔽」の手順で隠してください

package.json にスクリプトを追加

$ ng serve -c staging

ステージング環境としてビルトインサーバーを立ち上げるために何度か登場したこのコマンドですが、明日になったら忘れるので package.json に追加しておきましょう。

// package.json
"scripts": {
  // ① ローカル PC でそれぞれの環境をデバッグするためのコマンド
  "start": "ng serve",
  "start:staging": "ng serve -c staging",
  "start:prod": "ng serve -c production",

  // ② それぞれの環境ごとにビルドファイルを出力するためのコマンド
  "build": "ng build",
  "build:staging": "ng build -c staging",
  "build:prod": "ng build -c prod",
  ...
}

ステージングサーバーには build:staging で出力したファイル群を、本番サーバーには build:prod で出力したファイルをデプロイします。

環境判定のバッドパターン

コンポーネントやサービスクラスの中で if (window.env === "staging") したり HTML で *ngIf="env === 'local'" のように記述するのは、できる限り避けたいところです。安易に分岐させたコードは除去する時にコードを探すのも大変ですし、誤って必要なコードを一緒に削除するなど事故を起こしがちです。

@Component({
  selector: 'app-root',
  template: `
    <p *ngIf="env.isLocal">ようこそ {{profile.fullName}} さん</p>
    ...
    <span class="..." [ngClass]="alertClass">
  `,
})
export class AppComponent implements OnInit {
  profile: Profile;
  isLocal: boolean;
  alertClass: string;

  // 【怖い】 依存のどれを削除していいのか
  constructor(
    private repository: ProfileRepository,
    private envProvider: EnvProvider,
  ) {
    this.isLocal = this.envProvider.isLocal;

    // 【怖い】 インターフェースが本物とは変わってる...
    if (this.isLocal) {
      this.profile = { fullName: "偽武者" } as Profile;
    }
  }

  ngOnInit(): void {
    // 【怖い】 alertClass って変数は残したほうがいいのか?
    this.alertClass = this.isLocal ? 'local' : 'prod';

    if (!this.isLocal) {
      return;
    }
    this.repository.get().subscribe(...);
  }
}

実は私はこの方法をよく使っていたのですが、事故が怖くて if 文だけ削除し変数や依存を残したままにした事がありました。そんな過去の反省から「もっとスマートにできないか」と模索したのが今回の記事で紹介した方法です。

environmentproviders など Angular の機能を利用すれば、コンポーネントやサービスは「どの環境で実行されているのか」を知る必要はなく、除去する時の作業も楽になります。

@NgModule({
  declarations: [...],
  imports: [...],
  providers: [
// 【怖くない】 行単位で削除すれば良い
//   {
//     provide: ProfileRepository,
//     useFactory: (http) => {
//       if (environment.local) {
//         return new ProfileRepository(http);
//       }
//
//       return new FakeProfileRepository();
//     },
//     deps: [HttpClient],
//   },
  ],
  ...
})
// angular.json

"architect": {
  "build": {
    "configurations": {
      "production": {
        "fileReplacements": [
//       【怖くない】 行単位で削除すれば良い
//        ...
//        {
//          "replace": "src/app/links/prod-link.component.ts",
//          "with": "src/app/fakes/fake-prod-link.component.ts"
//        },
//        {
//          "replace": "src/app/links/staging-link.component.ts",
//          "with": "src/app/fakes/fake-staging-link.component.ts"
//        }
      },
      ...

なるべく楽に安全に手を入れられるように、実装するその時から将来の作業を考えつつコードを書いていきたいですね。 最後まで読んでいただき、ありがとうございました。

お詫び

以前 Angular 超入門 というシリーズ記事の第4回を書いたのですが、前回の投稿から半年近く経過してしまいその間にシリーズ第1回の投稿者の金本が Angular実践入門チュートリアル という完全版チュートリアルを別のサイトのエントリとして書いてくれていました。(弊社を退職し現在はフリーで活動しています、Twitterアカウントは ttskch(たつきち) さんです)

チュートリアルを途中で断念していた方には大変申し訳ないのですが、たつきちさんの記事を参考にしていただければと思います….。最初の Angular アプリから Firebase のデプロイ方法までサポートしている、とてもおすすめなコンテンツです!

Angular実践入門チュートリアル @ttskh