ブログに戻る

フォロー&ご登録

CI における Fastly 設定のテスト

Andrew Betts

Principal Developer Advocate, Fastly

自動テストは、現代のすべての Web アプリケーションにおいて重要な役割を果たしています。お客様は稼働しているサービスに VCL 設定をプッシュする前にテストする機能を必要としています。API を使用してサービスをセットアップし、自動化ツールで VCL をプッシュし、テストリクエストを実行してその結果を調べることは以前から可能でした。しかし、これにはいくつか不便なところがありました。

  • ゼロからサービスをセットアップするには、相当な数の API コールが必要です。

  • サービスのセットアップと設定の同期には数秒しかかかりませんが、デプロイしたことを伝えるフィードバックメカニズムはありません。設定のデプロイとその後のテストには迅速なターンアラウンドタイムが必要であるため、これは不便です。

  • Fastly の内部メカニズムをテストすることまではできません。アサートできるのは外部から見える設定の影響だけです。これは、本質的には「ブラックボックス」テストになります。

  • その後、テストサービスを削除しないと、インターネットに晒されたサイトにオープンなセキュリティ脆弱性を残したままになってしまう可能性があります。

最近、Fastly Fiddle ツールユニットテストを追加する方法について書きました。これにより、簡潔なテストシンタックスとアサート可能な豊富な計測データが使用できるようになります。

もちろん、Mocha のような使い慣れたツールでこの機能を使用して CI テストを実行できるのが理想的です。

$ npm test

> mocha utils/ci-example.js

  Service: www.example.com

    Request normalisation
      GET /?bb=2&ee=e2&ee=e1&&&dd=4&aa=1&cc=3 HTTP/2
        ✓ clientFetch.status is 200
        ✓ events.where(fnName=recv).count() is 1
        ✓ events.where(fnName=recv)[0].url is "/?aa=1&bb=2&cc=3&dd=4&ee=e1&ee=e2"

    Robots.txt synthetic
      GET /robots.txt HTTP/2
        ✓ clientFetch.status is 200
        ✓ clientFetch.resp includes "content-length: 30"
        ✓ clientFetch.bodyPreview includes "BadBot"
        ✓ originFetches.count() is 0
      GET /ROBOTS.txt HTTP/2
        ✓ clientFetch.status is 404

  10 passing (37s)

Fastly Fiddle は、Fastly を通過するリクエストの結果を詳細な JSON 形式のデータ構造として表す API を公開しています。それを受け取り、Chai などのアサーションライブラリを使用してアサーションを実行することができます 。しかし、私たちは Fiddle のファーストクラスの機能としてテストのサポートを追加したかったのです。

組み込みのテストサポートの導入により、前回取り上げた簡潔で表現力豊かなテストシンタックスを使用して、テストケースを fiddle 自体に直接ベイクすることができます。Fiddle HTTP API と fiddle を表現するために使用するデータ構造を理解すれば、自動化してこのテストエンジンをリモートで動かすことができます。これを可能にするために、Mocha の抽象レイヤーを作成しました。まず、それを使用する方法を見てみましょう。その後、動作の仕組みを説明します。始める前に、Fastly Fiddle は Labs プログラムの一環として提供されている実験的なサービスであることに注意してください。このサービスをコードデプロイの重要な依存関係にしないでください。また、個々の fiddle は認証なしで簡単にアクセスできるように設計されていることにも注意してください。fiddle の URL を所有している人にコードが公開される可能性があります。

独自の CI テストの実行

まず、GitHub からコードを取得し、依存関係をインストールします。

$ git clone git@github.com:fastly/demo-fiddle-ci.git fastly-ci-testing
$ cd fastly-ci-testing
$ npm install

この最後のステップは、システムに NodeJS (10+) がインストールされていることを前提としています。準備ができたら、npm test を実行してサンプルテストを実行し、この記事の上部にあるような出力を確認することができます (テストには1分ほどかかります)。

順調に進んでいますか ? ここからは、面白い部分です。独自のサービス設定を定義し、独自のテストを作成しましょう。src/test.js を開き、一番上の部分を見てください。

testService('Service: example.com', {
  spec: {
    origins: ["https://httpbin.org"],
    vcl: {
      recv: `set req.url = querystring.sort(req.url);\nif (req.url.path == "/robots.txt") {\nerror 601;\n}`,
      error: `if (obj.status == 601) {\nset obj.status = 200;\nset obj.response = "OK";synthetic "User-agent: BadBot" LF "Disallow: /";\nreturn(deliver);\n}`
    }
  },
  scenarios: [
    ...
  ]
});

この最初の部分では、spec プロパティ内で、エッジにプッシュするテスト用のサービス設定を定義します。この時点で、テストするサービス設定で fiddle を設定しておくと便利です。その場合は、fiddle UI のメニューをクリックし、'EMBED' を選択すると、JSON エンコードされたバージョンの fiddle が表示されます。vcl プロパティと origins プロパティをテストファイルの spec セクションにコピーします。または、この記事の後半でデータモデルを確認することもできます。

これで、いくつかのテストシナリオを設定できるようになりました。各シナリオでは、設定の機能を1つテストします。例えば、位置情報の取得、画像の最適化、合成応答の生成、A/B テストなどを行っているサービスがあるとします。各機能をテストするには、異なるパターンのリクエストを送信する必要があります。

シナリオの一例を見てみましょう。

testService('Service: example.com', {
  spec: { ... },
  scenarios: [
    {
      name: "Caching",
      requests: [
        {
          path: "/html",
          tests: [
            'originFetches.count() is 1',
            'events.where(fnName=fetch)[0].ttl isAtLeast 3600',
            'clientFetch.status is 200'
          ]
        }, {
          path: "/html",
          tests: [
            'originFetches.count() is 0',
            'events.where(fnName=hit).count() is 1',
            'clientFetch.status is 200'
          ]
        }
      ]
    },
    ...
  ]
});

このシナリオは「キャッシング」と呼ばれるもので、リクエストが2回繰り返された場合、2回目は Fastly がキャッシュ内に保持することをテストしています。繰り返しますが、すでに fiddle 内に、必要とする設定がある場合は、fiddle メニューの 'EMBED' オプションを使用してリクエストデータをエクスポートできます (ここでは requests プロパティが必要です)。それ以外の場合は、この記事の後半で、完全なデータモデルを確認できます (前回の記事で説明したテストシンタックスを除く)。

src/test.js ファイルを希望どおりにカスタマイズしたら、npm test で実行できます。このコマンドは、package.json ファイルからテストスクリプトをトリガーします。これは、プロジェクトのルートから実行するのと同じことです。

$ ./node_modules/.bin/mocha src/test.js --exit

ノードだけでなく、mocha 実行可能ファイルを使用してスクリプトを実行していることに注意してください。これは重要なので、npm test をショートカットとして使用することをおすすめします。また、一部の CI ツールはスマートで、package.json で宣言されたテストスクリプトを検出し、それを使用してテストを開始します。

うまくいっていれば、Mocha が今、テストを実行しています。テストが実行されたら、選択した CI ツールでこれを実行するようにセットアップし、パスしたら新しい設定を Fastly アカウントにデプロイするパイプラインを作成します。

それではその仕組みをご説明します。

Fastly Fiddle クライアント

私は、NodeJS の Fastly Fiddle ツール用に不完全ですが非常に小さいクライアントを作りました。これは、src/fiddle-client.js としてプロジェクトに含まれています。これは、fiddle をパブリッシュ、クローン、実行するためのメソッドを公開します。

  • get(fiddleID) : fiddle を返します。

  • publish(fiddleData) : fiddle を作成または更新し、正規化された fiddle データを返します。

  • clone(fiddleID) : fiddle を新しい ID にクローンし、正規化された fiddle データを返します。

  • execute(fiddleOrID, options) : Fiddle を実行し、結果データを返します。

fiddle に対する操作を実行するには、fiddle データモデルと結果データモデルを理解する必要があります。

fiddle データモデル

fiddle 型のオブジェクトは次のようになります (UI でのみ使用できる cosmetic プロパティは省略)。

vcl オブジェクト サブルーチンのためにコンパイルする必要のあるソースコードに対する VCL サブルーチン名のマップ。
  .init 文字列 任意の VCL サブルーチンの外にある、初期化スペースの VCL コード。 このスペースを使用して、カスタムサブルーチン、ディクショナリ、ACL、ディレクターを定義します。
  .recv 文字列 recv サブルーチン用の VCL コード
  .hash 文字列 ハッシュサブルーチン用の VCL コード
  .hit 文字列 ヒットサブルーチン用の VCL コード
  .miss 文字列 ミスサブルーチン用の VCL コード
  .pass 文字列 パスサブルーチン用の VCL コード
  .fetch 文字列 フェッチサブルーチン用の VCL コード
  .deliver 文字列 配信サブルーチン用の VCL コード
  .error 文字列 エラーサブルーチン用の VCL コード
  .log 文字列 ログサブルーチン用の VCL コード
オリジン 配列 VCL に公開するオリジンのリスト
  [n] 文字列 ホスト名やスキームとしてのオリジン。例「https://example.com」。VCL に F_origin_{{arrayIndex}} のように公開。
リクエスト 配列 fiddle が実行されたときに実行するリクエストのリスト
  [n] オブジェクト 1つのリクエストを表すオブジェクト
    .method 文字列 リクエストに使用する HTTP メソッド
    .path 文字列 リクエストへの URL パス (必ず / で始まります)
    .headers 文字列 リクエストとともに送信するカスタムヘッダー
    .body 文字列 リクエストボディのペイロード (これを設定すると、Content-Length ヘッダーがリクエストに自動的に追加されます)
    .enable_cluster ブール型 POP 内クラスタリングを有効にするかどうか。 設定を有効にすると、キャッシュキーの計算後、実行はそのキーの標準的な保存場所であるストレージノードに転送されます。 無効にすると、プロセスは最初にリクエストを受信したランダムエッジノードで完全に実行されます。Fastly POP は多数の個別のサーバーで構成されているため、同じオブジェクトに対する連続したリクエストがキャッシュヒットにならない可能性があります。
    .enable_shield ブール型 設定を有効にすると、リクエストを処理する POP が指定されたオリジンシールドではない場合、バックエンドの代わりにオリジンシールドが使用されるため、キャッシュミスは強制的にオリジンに送信される前に2つの Fastly POP を通過します。
    .enable_waf ブール型 Fastly の Web アプリケーションファイアウォールを介してリクエストをフィルタリングするかどうか。設定を有効にすると、追加の waf イベントが結果データに含まれ、WAF のルールに一致した場合、リクエストがエラーをトリガーする可能性があります。  Fiddle で使用される WAF は、最小限のルールセットで設定されており、独自のサービスの WAF 設定の適切なシミュレーションではありません。
    .conn_type 文字列 http (安全ではない)、h1、h2、または h3 のいずれか。
    .source_ip 文字列 ジオロケーションなどのエッジクラウド機能において、リクエストの発信元とみなされる IP。
    .follow_redirects ブール型 レスポンスがリダイレクトで、有効な Location ヘッダーが含まれている場合に自動追加リクエストを挿入するかどうか
    .tests 文字列/
配列
前回の投稿でご紹介したテストシンタックスに従い、改行処理で区切られたテスト式のリスト。  これはテキスト blob フィールドですが、必要に応じてテスト式を配列として送信することもできます。

これで fiddle の作成、更新、実行ができるようになりました。execute() を除くすべてのメソッドは、fiddle オブジェクトにデータを約束します (つまり、上記のモデルにデータを適合させます)。execute メソッドは、結果データオブジェクトにデータを約束します。

結果データモデル

execute() メソッドによって約束されたデータは次のようになります。clientFetches[...].tests プロパティに注意してください。このプロパティについては、ここで詳しく説明します。

id 文字列 実行セッションの識別子
requestCount 数字 Fastly に対して行われたクライアント側のリクエストの数。  clientFetches オブジェクトのアイテム数と同じになります。
clientFetches オブジェクト クライアントアプリケーションから Fastly サービスへのリクエストのマップ。キーは、イベントリスト内のオブジェクトの reqID プロパティに対応する、ランダムに生成された1回限りの識別子です。
  .${reqID} オブジェクト 単一のクライアントリクエスト。
    .req 文字列 リクエストステートメントを含むリクエストヘッダー
    .resp 文字列 レスポンスのステータス行を含むレスポンスヘッダー
    .respType 文字列 解析された Content-type レスポンスヘッダーの値 (MIME タイプのみ)
    .isText ブール型 レスポンスボディをテキストとして扱うことができるかどうか
    .isImage ブール型 レスポンスボディを画像として扱うことができるかどうか
    .status 数字 HTTP レスポンスのステータス
    .bodyPreview 文字列 ボディを UTF-8 テキストでプレビュー (1,000字で切り捨て)
    .bodyBytesReceived 数字 受信したデータ量
    .bodyChunkCount 数字 受信したチャンクの数
    .complete ブール型 レスポンスが完了したかどうか
    .trailers 文字列 HTTP レスポンスのトレーラー
    .tests 配列 このクライアントリクエストに対して実行されたテストのリスト
      [idx] オブジェクト 各テスト結果はオブジェクトです
        .testExpr 文字列 以前に学んだテストシンタックスに基づいたテスト式
        .pass ブール型 テストが成功したかどうか
        .expected 任意 期待された値 (文字列表現も testExpr に含まれています)
        .actual 任意 観測された値
        .detail 文字列 アサーション失敗の説明
        .tags 配列 テストパーサーによってテストに適用されるフラグ。現在、async と slow-async が含まれています。
    .testsPassed ブール型 このリクエストの tests プロパティで定義されたすべてのテストが成功したかどうかを記録する便利なショートカット。
originFetches オブジェクト Fastly サービスからオリジンへのリクエストのマップ。キーは、イベントリスト内のオブジェクトの vclFlowKey プロパティに対応するランダムに生成された1回限りの識別子です。
  .${vclFlowKey} オブジェクト 単一のオリジンリクエスト
    .req 文字列 リクエストメソッド、パス、HTTP バージョン、ヘッダーのキー/値のペア、リクエストボディを含む HTTP リクエストのブロック
    .resp 文字列 レスポンスのステータス行とレスポンスヘッダー (ボディ以外) を含む HTTP レスポンスヘッダー
    .remoteAddr 文字列 オリジンの解決済み IP アドレス
    .remotePort 数字 オリジンサーバーのポート
    .remoteHost 文字列 オリジンサーバーのホスト名
    .elapsedTime 数字 オリジンフェッチに要した合計時間 (ミリ秒)
イベント 配列 実行セッションの処理中に発生したイベントの時系列のリスト。
  [idx] オブジェクト 各配列要素は1つの VCL イベントです
    .vclFlowKey 文字列 イベントが発生した VCL フローの ID。  VCL フローは最も内側の相関識別子です。  ステートマシンを介する各片道トリップは1つの VCL フローであるため、vclFlowKey ごとに、各 fnName につき最大1つのイベントが発生します。  VCL フローはオリジンフェッチをトリガーする可能性があるため、この VCL フローにオリジンフェッチが関連付けられている場合、この識別子を使用して originFetches コレクションに記録されます。
    .reqID 文字列 このイベントをトリガーしたクライアントリクエストの ID。  クライアントリクエストは、リスタート、ESI、セグメント化されたキャッシュ、またはオリジンシールドが原因で、複数の VCL フローをトリガーする場合があります。  クライアントリクエストの詳細は、この識別子を使用して clientFetches コレクションに記録されます。
    .fnName 文字列 イベントの種類。recv、hash、hit、miss、pass、waf、fetch、deliver、error、log など。
    .datacenter 文字列 このイベントが発生した Fastly データセンターの所在地を識別する3文字のコード (LCY、JFK、SYD など)
    .nodeID 文字列 このイベントが発生したサーバーの識別番号
    .<various> イベントの fiddle UI で報告されたプロパティはすべて、イベントの結果データに含まれます。
インサイト 配列 実行中に検出および報告された警告とベストプラクティス違反のリスト。

これをテストハーネスに接続するにはどうすればよいでしょうか。世界で最も人気のあるテスト実行プログラムの1つである Mocha を使用します。

Mocha テストケースジェネレーター

Mocha は、テスト仕様ファイルを記述するためにごく一般的なメカニズムを使用しています。CLI ツールはスクリプトファイルを呼び出すために使用され、CLI は自動的に特定の関数 (テストスイートを作成する describe 関数やbeforebeforeEachafterafterEachのようなライフサイクルフックと共に個々のテストを指定するための関数) が存在する環境を作成します。

describe('My application', function()) {
  let db;
  before(async () => {
    db = setupDatabase(); // Imaginary function
  });
  it("should load a widget", async function() {
    const widget = db.get('test-widget');
    expect(widget).to.have.key('id');
  });
});

この方法の問題は、私たちのテストケースではコードではなくデータとして表現する必要があるため、上記の方法で宣言できないことです。幸いなことに、Mocha では、グローバルな Mocha が明示的にインポートされる場合は、従来のコンストラクターを介してテストを作成することもできます。

const Mocha = require('mocha');
const test = new Mocha.Test('should load a widget', function () { ... });

describe() コールバック内で、これが Mocha Suite オブジェクトのインスタンスになります (このため、コールバックにアロー関数を使わないことも重要です)。コールバックが最初に実行されたときに、少なくとも1つのテストを同期させて作成する場合、Mocha により before() コードが実行されます。このコールバックでは、ハードコードされたテストをキャンセルし、動的なテストを作成することができます。

const Mocha = require('mocha');
describe('My application', function()) {
  it ("has one hard-coded test", function () { return true });
  before(async () => {
    this.tests = []; // Delete the hard-coded test
    this.addTest(new Mocha.Test('satisfies this dynamic test', function () { ... }); 
  });
});

ここで、テスト仕様のデータから動的テストに入力する必要があります。src/test.js ファイルは、(name, data) のシグネチャを持つと期待される関数をインポートします。そのため、src/fiddle-mocha.jsのスキャフォールドを開始するには、これをエクスポートします。

module.exports = function (name, data) {
  // Create a testing scope for each Fastly service under test
  describe(name, function () {
    this.timeout(60000);
    let fiddle;
    // Push the VCL for this service and get a fiddle ID
    // This will sync the VCL to the edge network, which takes 10-20 seconds
    before(async () => {
      fiddle = await FiddleClient.publish(data.spec);
      await FiddleClient.execute(fiddle); // Make sure the VCL is pushed to the edge
    });
    ...
    
  })
};

エクスポートされた関数が呼び出されるたびに、テスト対象のサービス設定を表すテストスイートが作成されます。デフォルトでは Mochaの タイムアウトは2秒となっていますが、これではリモートテストを行うには十分ではないので、これを増やしています。

サービス設定は、このサービスレベルスイートの before() コールバックで fiddle にパブリッシュすることができます。また、publish が検証するのは設定が有効であるかどうかだけであり、ネットワーク全体に正常にデプロイされたことを確認するわけではないので、実行をトリガーする価値はあります。execute() がそれを行います。

これで、各テストシナリオのサブスイートを作成することができるようになりました。

module.exports = function (name, data) {
  describe(name, function () {
    ...
    for (const s of data.scenarios) {
      describe(s.name, function() {
        it('has some tests', () => true); // Sacrificial test
        before(async () => {
          this.tests = []; // Remove the sacrificial test from the outer suite
          const result = await FiddleClient.execute({
            ...fiddle,
            requests: s.requests
          }, { waitFor: 'tests' });
          for (const req of Object.values(result.clientFetches)) {
            if (!req.tests) throw new Error('No test results provided');
            const suite = new Mocha.Suite(req.req.split('\n')[0]);
            for (const t of req.tests) {
              suite.addTest(new Mocha.Test(t.testExpr, function() {
                if (!t.pass) {
                  const e = new AssertionError(t.detail , { 
                    actual: t.actual, expected: t.expected, showDiff: true
                  });
                  throw e;
                }
              }));
            }
            this.addSuite(suite);
          }
        });
      });
    }
  });
};

それでは、これをステップスルーしていきます。

  1. それぞれのシナリオについて、describe() を呼び出します。これにはシナリオ名を渡し、コールバックを設定しています。コールバックは、すぐに Mocha に呼び出されます。

  2. before() コールバックが実行されるように仮のテストを定義します。

  3. before() コールバックでは、仮のテストを削除し、fiddle を実行し、このシナリオ用に定義されているリクエストでパッチを適用します。waitFor: 'tests' は、テスト結果が利用可能になったときに API クライアントに約束の解決のみを行うように指示します。

  4. シナリオを構成する各クライアントリクエストを繰り返し処理し、リクエストのテストスイートを作成します。スイートの名前としてリクエストヘッダーの最初 (例 : "GET / HTTP/2") を使用しています。

  5. そのリクエストで実行されたテストごとに、新しい Mocha テストを作成し、テストスイートに追加します。失敗した場合は、テスト結果の関連プロパティーを AssertionError に渡します。AssertionError は、ネイティブの JavaScript エラーオブジェクトの拡張であり、expectedactualshowDiff の追加プロパティを定義します。Mocha はこれらのプロパティを知っているので、テストレポートでより良いユーザーエクスペリエンスを提供できます。

  6. リクエストスイート (suite) をシナリオスイート (this) に追加します。

  7. シナリオスイートは、サービスレベルスイートの内部で、describe コールを介して (ルートスイートとして) Mocha ランナーにすでに登録されています。

Mocha は、(test.jstestService を呼び出すことで) 1つ以上のルートスイートを発見します。各ルートスイートには1つ以上のシナリオスイートが含まれ、各シナリオスイートには1つ以上のリクエストスイートが含まれます。リクエストスイートには、リクエストで定義されたテストが含まれます。

最後に

CI でのテストは、Fastly サービスの新しいバージョンを有効にしたときの思わぬトラブルを避けるための優れた方法であり、Fiddle はそれを支援する便利なツールです。マージする前にプルリクエストをテストする CI プロセスが既にある場合は、Fastly サービスのテストを統合することも検討してください。この記事の内容があなたの環境でうまくいったかどうか大変興味があります。よろしければ、経験したことをコメントやメールでお聞かせください。

ただし、このサービスをコードデプロイの重要な依存関係にしないでください。これは、Fastly Labs プログラムの一環として提供される試験的なサービスであり、SLAの対象ではありません。フィードバックを収集するために提供しています。