このコンテンツは自動機械翻訳サービスによる翻訳版であり、皆さまの便宜のために提供しています。原本の英語版と異なる誤り、省略、解釈の微妙な違いが含まれる場合があります。ご不明な点がある場合は、英語版原本をご確認ください。
Cloudflare Workflowsは、「耐久性のある実行」に関する当社の見解です。これらは、Cloudflare開発者プラットフォームで稼働するサーバーレスエンジンを提供し、長期実行され、障害が発生しても存続するマルチステップのアプリケーションを構築します。今年初めにWorkflowsが一般提供されるようになったことで、開発者は、従来のステートレス機能では管理が困難または不可能だった複雑なプロセスをオーケストレーションできるようになりました。Workflowsはステート、リトライ、長時間待つことを処理するため、お客様はビジネスロジックに集中できます。
しかし、複雑なオーケストレーションでは信頼できるものにするためには、堅牢なテストが必要です。これまで、Workflowsのテストはブラックボックスプロセスでした。Workflowインスタンスが、ステータスへの待機を通じて完了に達したかどうかをテストできましたが、中間のステップは可視化されませんでした。このため、デバッグは非常に困難でした。支払処理ステップは成功しましたか?確認メールステップは、正しいデータを受け取りましたか?外部システムやログを調べなければ確認できませんでした。
私たちも開発者として、信頼できるコードを確保する必要性を理解しています。また、Workflowsをテストするための開発者体験を改善する必要があるという明確なフィードバックも聞かれました。
テストのブラックボックスの性質は、問題の一部でした。ただし、限定的なテストの提供は高価でした。プロジェクトにワークフローを追加すると、ワークフローを直接テストしていない場合でも、テスト間の分離を保証できないため、分離されたストレージを無効にする必要がありました。分離されたストレージは、各テストが他のテストの副作用に影響されない、クリーンで予測可能な環境で実行されることを保証するための、ビテストプール機能です。無効化することによって、テスト間の状態がリークし、不安定で予測不可能な、デバッグが困難な障害につながる可能性がありました。
これにより、複雑なアプリケーションを構築する開発者にとっては難しい選択を迫られました。プロジェクトでWorkers、Durable Objects、およびR2をWorkflowsとともに使用している場合、プロジェクト全体の分離されたテストを放棄するか、テストをスキップするかのどちらかしかありませんでした。この摩擦により、テスト体験が劣り、Workflowsの採用に至りませんでした。この問題を解決することは、単なる改善ではなく、Workflowsを、十分にテストされたCloudflareアプリケーションの一部にするための重要なステップでした。
Workflowsの包括的で粒度の高い分離されたテストを可能にする新しいAPIを導入します。これらはすべて、Workersランタイム workerd でのテスト実行をサポートするテストフレームワーク、vitest-pool-workers によって、ローカルおよびオフラインで実行されます。これにより、ネットワーク接続に依存しない高速で信頼性の高い、安価なテストの実行が可能になります。
これらは、cloudflare:testモジュールを通して、@cloudflare/vitest-pool-workersバージョン0.9.0以上で利用可能です。この新しいテストモジュールでは、Workflowsを検査するための2つの主要な関数を提供します:
実際の例を挙げながら説明します。
実践的な例:ブログモデレーションワークフローのテスト
ブログをモデレートするためのシンプルなWorkflowを想像してみてください。ユーザーがコメントを送信すると、Workflowはworkers-aiにレビューを要求します。返された違反スコアに基づいて、モデレーターがコメントを承認または拒否するのを待ちます。承認されると、それはstep.doを呼び出して外部API経由でコメントを公開します。
新しいAPIなしにこれをテストすることは不可能でしょう。ステップの結果をシミュレートし、モデレーターの承認をシミュレートする直接的な方法はありません。今、すべてをモッキングすることができます。
以下は、既知のインスタンスIDを持つintrospectWorkflowInstanceを使用したテストコードです:
import { env, introspectWorkflowInstance } from "cloudflare:test";
it("should mock a an ambiguous score, approve comment and complete", async () => {
// CONFIG
await using instance = await introspectWorkflowInstance(
env.MODERATOR,
"my-workflow-instance-id-123"
);
await instance.modify(async (m) => {
await m.mockStepResult({ name: "AI content scan" }, { violationScore: 50 });
await m.mockEvent({
type: "moderation-approval",
payload: { action: "approved" },
});
await m.mockStepResult({ name: "publish comment" }, { status: "published" });
});
await env.MODERATOR.create({ id: "my-workflow-instance-id-123" });
// ASSERTIONS
expect(await instance.waitForStepResult({ name: "AI content scan" })).toEqual(
{ violationScore: 50 }
);
expect(
await instance.waitForStepResult({ name: "publish comment" })
).toEqual({ status: "published" });
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
});
このテストは、Workers AIを呼び出す「AIコンテンツスキャン」や、外部のブログAPIを呼び出す「コメント公開」ステップなど、外部API呼び出しを必要とするステップの結果をモックします。
インスタンスIDが不明な場合は、ランダムに生成されたIDで1つまたは複数のWorkflowインスタンスを開始するworkerリクエストを作成しているため、introspectWorkflow(env.MY_WORKFLOW)を呼び出すことができます。以下は、そのシナリオのテストコードです。ここでは、Workflowインスタンスが1つだけ作成されています:
it("workflow mock a non-violation score and be successful", async () => {
// CONFIG
await using introspector = await introspectWorkflow(env.MODERATOR);
await introspector.modifyAll(async (m) => {
await m.disableSleeps();
await m.mockStepResult({ name: "AI content scan" }, { violationScore: 0 });
});
await SELF.fetch(`https://mock-worker.local/moderate`);
const instances = introspector.get();
expect(instances.length).toBe(1);
// ASSERTIONS
const instance = instances[0];
expect(await instance.waitForStepResult({ name: "AI content scan" })).toEqual({ violationScore: 0 });
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
});
どちらの例でも、awaitを使ってイントロスペクターを呼び出していることに注目してください。これは、現代のJavaScriptの明示的リソース管理構文です。ここでは重要です。なぜなら、テストの終了時にイントロスペクターオブジェクトがスコープから外れると、その廃棄メソッドが自動的に呼び出されるからです。このようにして、各テストが独自の分離されたストレージで動作するようにしています。
変更全ての関数は、インスタンスを制御するためのゲートウェイです。コールバックの中では、モダナイゼーションオブジェクトにアクセスすることができ、ステップ結果のモック化、イベント、スリープ無効化などの動作を注入することができます。
詳細なドキュメントは、Workers Cloudflare Docsでご覧いただけます。
VitestをWorkflows Engineに接続した方法
ソリューションを理解するには、まずローカルアーキテクチャを理解する必要があります。wrangler devを実行すると、WorkflowsはMiniflare(Cloudflare Workersテスト用のシミュレーター)とworkerdによって作動します。実行中のワークフローインスタンスは、それぞれのSQLite Durable Objectによって支えられます。これを「Engine DO」と呼びます。このEngine DOは、ステップの実行、状態の永続化、インスタンスのライフサイクルの管理を担当します。ローカルの分離されたWorkersランタイム内に存在します。
一方、Vitest テストランナーは、workerd の外部に存在する別のNode.jsプロセスです。これが、vitest-pool-workersと呼ばれるworkerd内でテストを実行できるVitestカスタムプールがある理由です。 Vitest-pool-workersにはRunner Workerがあり、ユーザーのwrangler.jsonファイルで指定されたすべてのものにバインディングしてテストを実行するworkerです。このworkerは、「cloudflare:test」モジュールの下でAPIsにアクセスできます。WebSocket/RPCを介したRunner Objectと呼ばれる特別なDOを介してNode.jsと通信します。
当社が考えた最初のアプローチは、テストランナーworkerを使うことでした。現在の状態では、Runner workerはwranglerファイル上で定義されたWorkflowsからWorkflowバインディングにアクセスすることができます。私たちは、Workflowの各Engine DOネームスペースをこのランナーworkerにバインドすることも検討しました。これにより、vitestpool-workersがEngine DOに直接アクセスできるようになり、Engineのメソッドを直接呼び出すことができるようになります。
有望ではありますが、このアプローチではMiniflareのコアとvitest-pool-workersに望ましくない変更が必要であり、この単一の機能にとってはプライバシーが侵害されすぎることになりました。
まず、MiniflareのDurable Objectsに新しいunsafeフィールドを追加する必要がありました。その唯一の目的は、当社のエンジンのサービス名を指定することで、Miniflareがデフォルトのユーザープレフィックスを適用することを防ぎ、さもなければ Durable Objects が見つからなくなるのを防ぐことです。
第二に、ビテストプールワーカーは、テストされていないものも含め、プロジェクト内のWorkflowsからすべてのエンジンDOをランサムウェアにバインドすることを強制されました。これにより、テスト環境に不要なバインディングが導入されることになり、ユーザーのテスト環境に公開されないようにするための追加のクリーンアップが必要になります。
ブレイクスルー
このソリューションは、特権付きローカル専用APIとリモートプロシージャコール(RPC)の組み合わせです。
まず、Workflowsバインディングの ローカル 実装に、本番環境では利用できない 安全でない関数のセットを追加しました。それらは、テスト環境からアクセスできる制御されたアクセスポイントとして機能し、テストランナーは、そのインスタンスIDを提供することで特定のエンジンDOのスタブリを取得することができます。
テストランナーがこのタブを取得すると、RPCを使用して、WorkflowInstanceModifierと呼ばれる特別なRpcTargetを介して、Engine DOで特定の信頼できるメソッドを呼び出します。RpcTargetを拡張するクラスは、オブジェクトをスタブに置き換えます。このスタブでメソッドを呼び出すと、RPCは元のオブジェクトに戻ります。
このシンプルなアプローチは、Workflows環境に限定されているため、侵害性ははるかに低く、将来的な機能変更も安全に分離することができます。
未知のIDを持つワークフローを省く
Workflowsインスタンスを作成するとき(create()またはcreateBatch())開発者は、特定のIDを提供するか、自動生成することができます。このIDはWorkflowインスタンスを識別し、関連するEngine DO IDを作成するために使用されます。
実装の論理的な開始点は、introspectWorkflowInstance(binding, instanceID)でした。インスタンスIDが事前にわかっているためです。これにより、Workflowインスタンスに関連付けられたエンジンを識別するために必要なエンジンDO IDを生成することができます。
しかし、多くの場合、アプリケーションの一部(HTTPエンドポイントなど)は、ランダムに生成されたIDでWorkflowインスタンスを作成します。インスタンスが作成されるまでIDがわからないのに、どのようにしてインスタンスを検査できるか?
その答えは、JavaScriptの強力な機能であるプロキシオブジェクトを使用することでした。
introspectWorkflow(バインディング)を使用する場合、Workflowバインディングをプロキシにラップします。このプロキシは、バインディングへのすべての呼び出しを非破壊的に傍受します。具体的には、.create() を探します。.createBatch()に対応しています。テストがワークフローの作成をトリガーすると、プロキシはその呼び出しを検査します。インスタンスID(お客様が提供されたもの、またはランダムに生成したもの)をキャプチャし、そのIDにイントロスペクションをセットアップし、modifyAll呼び出しで定義したすべての変更を適用します。その後、オリジナルの作成呼び出しは通常通り続行されます。
env[workflow] = new Proxy(env[workflow], {
get(target, prop) {
if (prop === "create") {
return new Proxy(target.create, {
async apply(_fn, _this, [opts = {}]) {
// 1. Ensure an ID exists
const optsWithId = "id" in opts ? opts : { id: crypto.randomUUID(), ...opts };
// 2. Apply test modifications before creation
await introspectAndModifyInstance(optsWithId.id);
// 3. Call the original 'create' method
return target.create(optsWithId);
},
});
}
// Same logic for createBatch()
}
}
introspectWorkflow()からのブロックを使用したawaitが終了するか、テストの終了時にdisposition()メソッドが呼び出されると、イントロスペクターは処分され、プロキシは削除され、バインディングは元の状態のままとなります。開発者エクスペリエンスと長期的な保守性を優先する、影響の少ないアプローチです。
Workflowsにテストを追加する準備はできましたか?利用開始の手順は次の通りです:
依存関係の更新:@cloudflare/vitest-pool-workers バージョン0.9.0以上を使用していることを確認してください。プロジェクトで次のコマンドを実行します:npm install @cloudflare/vitest-pool-workers@latest
テスト環境を構成:Workersでテストするのが初めての方は、当社のガイドに従って最初のテストを作成してください。
テストの作成を開始:cloudflare:testからintrospectWorkflowInstanceまたはintrospectWorkflowをインポートし、テストファイルにこの記事で示されるパターンを使用して、Workflowの動作をモック、コントロール、およびアサーションします。公式のAPIリファレンスも参照してください。