Deno上でCloudflare Workers等の単体テストを実施するメモ
Cloudflareの各主要サービスについて、Deno上で単体テストを実施するのになかなか手間取ったので備忘メモとして残しておきます。Node.jsでもVitestを使用する構成では公式ドキュメントがあるため不要だと思いますが、なるべく小さくテスト環境を構築したい場合はある程度参考になると思います。
前提
Miniflareを用いたCloudflareの以下サービスの単体テストについて記載する。
- Workers
- Durable Objects
- Workers KV
- D1 SQL Database
- R2 object storage
Denoは以下バージョンを使用し、Deno標準のテスト機能を使用する。
$ deno -v
deno 2.5.4
結論構成
プロジェクト構成
node_modulesとdeno.lockを除いた想定プロジェクト構成は以下。
.
├── src
│ ├── main.ts
│ └── utils.ts
├── tests
│ ├── main.test.ts
│ └── prep.ts
├── deno.json
├── worker-configuration.d.ts
└── wrangler.jsonc
各構成ファイル内容
{
"nodeModulesDir": "auto", // to run `wrangler types`
"compilerOptions": {
"lib": ["esnext"], // remove libs with conflicting type definitions, such as "deno.ns", "dom", etc.
"module": "esnext", // to suppress TS1203 warning at `deno test` runtime
"moduleResolution": "bundler", // to suppress TS1203 (same with above)
"types": ["./worker-configuration.d.ts"] // for type checking at `deno test` runtime
},
"tasks": { // build ts scripts to js for miniflare, and run test
"test": "deno run -A --deny-net ./tests/prep.ts && deno test -A"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.15",
"esbuild": "npm:esbuild@^0.25.11", // to build ts scripts
"miniflare": "npm:miniflare@^4.20251011.1", // use this to test
"cloudflare:workers": "./tests/prep.ts" // to resolve custom "cloudflare:" module prefix at testing
}
}
wrangler.jsonc
{
"$schema": "https://www.unpkg.com/wrangler@latest/config-schema.json",
"name": "my-project",
"main": "dist/index.js",
"compatibility_date": "2025-10-28",
"compatibility_flags": [
"nodejs_compat"
],
"durable_objects": {
"bindings": [
{
"name": "MY_DURABLE_OBJECT",
"class_name": "MyClass"
}
]
},
"kv_namespaces": [
{
"binding": "MY_KV_NAMESPACE",
"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
],
"d1_databases": [
{
"binding": "MY_D1_DATABASE",
"database_name": "my_database_name",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
"r2_buckets": [
{
"binding": "MY_R2_BUCKET",
"bucket_name": "my-bucket-name"
}
],
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"MyClass"
]
}
]
}
worker-configuration.d.ts
// ...
declare namespace Cloudflare {
interface Env {
MY_KV_NAMESPACE: KVNamespace;
MY_DURABLE_OBJECT: DurableObjectNamespace /* MyClass */;
MY_R2_BUCKET: R2Bucket;
MY_D1_DATABASE: D1Database;
}
}
// ...
src/main.ts
import { MyClass } from "./utils.ts";
export { MyClass };
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// can use each service without error
const exists = await env.MY_KV_NAMESPACE.get("not_exists_key", "text");
if (exists) console.log("never be displayed");
// can use ctx method
ctx.waitUntil(processLong(env));
// can use durable objects
const doid = env.MY_DURABLE_OBJECT.idFromName("foo");
const dobj = env.MY_DURABLE_OBJECT.get(doid) as DurableObjectStub<MyClass>;
return new Response(
`${await dobj.say()} with request: ${await request.text()}`,
);
},
};
async function processLong(env: Env) {
await new Promise((resolve) => setTimeout(resolve, 1000));
await env.MY_R2_BUCKET.head("not_exists_key"); // no error occurs
}
src/utils.ts
import { DurableObject } from "cloudflare:workers";
export class MyClass extends DurableObject<Env> {
say(): string {
return "Hello, World!!";
}
}
export async function getKV(
env: Env,
binding: string,
key: string,
): Promise<string | null> {
// @ts-expect-error env[binding] is any
return await env[binding]?.get?.(key, "text") ?? undefined;
}
export async function putKV(
env: Env,
binding: string,
key: string,
value: string,
) {
// @ts-expect-error env[binding] is any
await env[binding]?.put?.(key, value);
}
tests/prep.ts
/// <reference lib="deno.ns" />
// to use `Deno.exit`, ref "deno.ns" on this file
import * as esbuild from "esbuild";
let exitCode = 0;
try {
await esbuild.build({
entryPoints: ["./src/main.ts"],
outfile: "./tests/worker.js",
bundle: true,
format: "esm",
target: "esnext",
platform: "browser",
external: ["cloudflare:workers"],
});
} catch (error) {
console.log("Build failed:", error);
exitCode = 1;
} finally {
await esbuild.stop();
if (exitCode) Deno.exit(exitCode);
}
// fake definition for testing
export class DurableObject<Env = unknown> {}
tests/main.test.ts
import { assertEquals } from "@std/assert";
import { Miniflare } from "miniflare";
import { MyClass } from "../src/main.ts";
import { getKV, putKV } from "../src/utils.ts";
let mf: Miniflare;
let env: Env;
const binding = {
do: "MY_DURABLE_OBJECT",
kv: "MY_KV_NAMESPACE",
d1: "MY_D1_DATABASE",
r2: "MY_R2_BUCKET",
};
const ignoreLeaks = { sanitizeResources: false, sanitizeOps: false };
/***** Initialize / Finalize *****/
Deno.test.beforeAll(async () => await setup());
Deno.test.afterAll(async () => await mf.dispose());
async function setup() {
mf = new Miniflare({
modules: true,
scriptPath: "./tests/worker.js", // need to be `js`
compatibilityDate: new Date(Date.now()).toISOString().slice(0, 10), // like "2025-10-28"
durableObjects: { [binding.do]: { className: "MyClass", useSQLite: true } },
kvNamespaces: [binding.kv],
d1Databases: [binding.d1],
r2Buckets: [binding.r2],
});
await setEnv(); // if need to simulate worker arg in test scripts
}
async function setEnv() {
env = {
// @ts-expect-error: `getDurableObjectNamespace` should return `DurableObjectNamespace` explained on readme, but got `Request_2<any>`
MY_DURABLE_OBJECT: await mf.getDurableObjectNamespace(
binding.do,
) as DurableObjectNamespace<MyClass>,
// @ts-expect-error: `getKVNamespace` should return `KVNamespace` explained on readme, but got `Request_2<any>`
MY_KV_NAMESPACE: await mf.getKVNamespace(binding.kv) as KVNamespace,
MY_D1_DATABASE: await mf.getD1Database(binding.d1) as D1Database,
// @ts-expect-error: `getR2Bucket` should return `R2Bucket` explained on readme, but got `Request_2<any>`
MY_R2_BUCKET: await mf.getR2Bucket(binding.r2) as R2Bucket,
};
}
/***** Test Scripts *****/
Deno.test({
name: "Test worker script",
fn: async (t) => {
const url = await mf.ready;
await t.step("Test step 1", async () => {
const res = await fetch(url, {
method: "POST",
body: "Test Request",
});
assertEquals(
await res.text(),
"Hello, World!! with request: Test Request",
);
});
},
});
Deno.test({
name: "Test for Durable Objects",
fn: async (t) => {
const ID = "id";
await t.step("Access to durable object", async () => {
const id = env.MY_DURABLE_OBJECT.idFromName(ID);
const stub = env.MY_DURABLE_OBJECT.get(id) as DurableObjectStub<MyClass>;
assertEquals(await stub.say(), "Hello, World!!");
});
},
});
Deno.test({
...ignoreLeaks,
name: "Test utilities for Cloudflare services",
fn: async (t) => {
const KEY = "test_key";
const VALUE = "test_value";
if (await env.MY_KV_NAMESPACE.get(KEY)) {
await env.MY_KV_NAMESPACE.delete(KEY);
}
await t.step("putKV", async () => {
await putKV(env, binding.kv, KEY, VALUE);
});
await t.step("getKV", async () => {
const v = await getKV(env, binding.kv, KEY);
assertEquals(v, VALUE);
});
},
});
テスト結果
$ deno task test
Task test deno run -A --deny-net ./tests/prep.ts && deno test -A
Check file:///workspaces/z-test-worker/tests/main.test.ts
running 3 tests from ./tests/main.test.ts
Test worker script ...
Test step 1 ... ok (37ms)
Test worker script ... ok (38ms)
Test for Durable Objects ...
Access to durable object ... ok (29ms)
Test for Durable Objects ... ok (29ms)
Test utilities for Cloudflare services ...
putKV ... ok (12ms)
getKV ... ok (5ms)
Test utilities for Cloudflare services ... ok (33ms)
ok | 3 passed (4 steps) | 0 failed (419ms)
補足と注意事項
miniflareの制限
公式でminiflareではできないとされている事について記載する。
Unit testing
Workersをfetch関数と見なした場合、Workersの単体テストは統合テストと同じことになると理解しているため問題ないと見なせる。fetch関数の内容を全て関数として外部化し、それらを単体テストすることでも対応可能と思われる。
Workersをfetch関数以外も含めた本来のハンドラー群と捉えた場合、fetch以外のハンドラーを呼び出す方法を調査する必要がある。(getWorkerを用いればscheduledやqueueは呼び出し可能なように思われる)
Loading Wrangler configuration files
必要であればDeno.readTextFileで処理すれば対応可能と思われる。
Isolated per-test storage
各テスト毎に環境をクリーンアップするコードを追加すれば対応可能と思われる。defaultPersistRootを指定して各persist系オプションをtrueにし、テストの最後にdefaultPersistRootディレクトリを削除することでも対応可能と思われる。
Direct access to Durable Objects
"Direct access"が何を指すか分からないが、結論構成の通りDurable Objectsのテストは可能。
Run Durable Object alarms immediately
できないと思われる。
以前の記事に書いた通りDurable Objectsのalarm系機能は不明点があり、これは請求額に直結するため使用しないのが無難と考えている。そのため個人的には許容範囲。また、alarm系機能を使用しなくてもCloudflare Workflowsの中のcron系サービスやその他方法を用いることでも同様の事は可能であり、それら方法の方が個別・明示的に制御可能なため持続的な運用も容易と思われる。
List Durable Objects
できないと思われるが何に影響するか不明。DurableObjectNamespaceにはそもそもリスト用メソッドがない。idFromStringを使用する場合でもid文字列は別の場所にあるという理解のため影響がよくわからない。
また、本番環境でもDurable Objectsの管理は複雑だと感じており、ストレージとしてのDurable Objectsをリストしないと把握できないほど乱立させるのは個人的設計方針に沿わないため、個人的には許容範囲となる。
結論構成の準備
各サービスをシミュレートするminiflareと、miniflareに渡すWorkers,Durable ObjectsクラスのコードをJavaScriptに変換するためのesbuildをパッケージとして追加する。
deno add npm:miniflare@latest npm:esbuild@latest
"miniflare"で検索するとアーカイブされたGitHubが出てくるためもう使用できないと勘違いしやすいが、miniflareはwranglerに統合されてメンテされ続けている。そのため、NPMのページでは比較的最近でもpublishされていることが確認できる。
結論構成の動作
-
deno task testを実行する - テスト準備として
deno run -A ./tests/prep.tsが実行される -
prep.tsの通りmain.tsをesbuildでjsに変換する- この時に
./tests/worker.jsが出力される
- この時に
- テスト前処理としてminiflareシミュレーターを作成する
-
scriptPathオプションで指定したworker.jsがworkerハンドラーとなる - 結論構成では前処理として仮想
envも同時に作成している (作成は任意)
-
- 各テストスクリプトを実行する
- テスト後処理として
mf.dispose()を実行してリソースを開放する
その他
その他のパッケージについて
-
deno run -A npm:wrangler@latest typesを実行すると以下が表示されるが不要- "Action required Install @types/node"
- パッケージ
@cloudflare/workers-typesは型定義の競合が起こるため追加してはならない -
createExecutionContext等のためのパッケージ@cloudflare/vitest-pool-workersも不要- miniflareでのテスト時に
envやctxもシミュレートしてくれる
- miniflareでのテスト時に
- 確認した限りesbuildを使用する際に
jsr:@deno/esbuild-pluginを用いる必要はない- 出力JavaScriptの整形がDenoのようになったことは確認
- モジュール解決方法が変わるらしいが、それ以外の差異は確認できず不明
deno.jsonの設定について
- 自動生成された
worker-configuration.d.tsの内容でテスト実行時に以下警告が出力されるTS1203 [WARN]: Export assignment cannot be used when targeting ECMAScript modules. Consider using 'export default' or another module format instead. This will start erroring in a future version of Deno 2 in order to align with TypeScript. export = CloudflareWorkersModule; ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ at file:///workspaces/my-project/worker-configuration.d.ts:7812:5- 無視しても良いが、出力が煩わしいので
deno.jsonのcompilerOptionsに以下を設定する"module": "esXXXX""moduleResolution": "bundler"
- 無視しても良いが、出力が煩わしいので
- Denoでは標準で
cloudflare:workersのような特殊モジュールをインポートできない- 何もせずに
--no-checkで実行すると以下のようなエラーが出力されるerror: Unsupported scheme "cloudflare" for module "cloudflare:workers". Supported schemes: - "blob" - "data" - "file" - "http" - "https" - "jsr" - "npm" - そのため事前に
cloudflare:workersのインポートマップを含める必要がある- 結論構成では
./tests/prep.tsを参照先とした- その中でインポートしている
DurableObjectを定義している- この定義はテスト時にのみ使用するダミーのため最小定義で済ませている
- その中でインポートしている
- Denoでは極力
cloudflare:*を使用しない方が色々と楽
- 結論構成では
- 何もせずに
テストコードについて
- miniflare作成時に
compatibilityDateを指定しないと問題が起こる場合がある- 例えばDurable Objectsの扱いが変わったりするため指定するのが無難
- 純粋にWorkerハンドラーだけをテストする場合、
envの作成は不要 -
getDurableObjectNamespace等がminiflareのREADMEに書いてある型を返してくれない- 定義は
Promise<ReplaceWorkersTypes<DurableObjectNamespace>>のようになっている-
ReplaceWorkersTypesの型解決がうまくいっていない模様- 内部的に相当ややこしいことをしている模様
- そのため
Request_2<any>という型になる
- テスト上の問題かつ型定義だけの問題のため型キャストで対応
-
-
getD1DatabaseだけはReplaceWorkersTypesを使用していないためか問題は起きない
- 定義は
-
Deno.testの引数に何も指定しない場合、テスト実行時にメモリリークエラーとなることがある- Miniflareによるリソース管理をDenoがメモリリークと誤認してしまうようだ
- 引数に
{ sanitizeResources: false, sanitizeOps: false }を指定して回避する
- 結論構成でいう
./tests/worker.jsは本来不要なため.gitignoreに追加するのが望ましい
miniflareについて
詳細はminiflareのREADMEを参照。
-
mf.getBindings()を用いればenv定義が楽になるかもしれない- そのためには
workerNameを適切に設定する必要があるらしい- 調査など面倒なので結論構成では手動で対応している
- そのためには
- 各ストレージ系サービスのシミュレートはファイル作成で行われているようだ
-
defaultPersistRootやkvPersistオプションを設定しなくても、false指定でも同じ- このような場合はOSのtmpに保存されるようだ
- もしかすると
mf.dispose()でクリーンアップされているかもしれない
-
defaultPersistRootやkvPersistオプションでは以下のように変わるらしい// Without `defaultPersistRoot` new Miniflare({ kvPersist: undefined, // → "/(tmp)/kv" d1Persist: true, // → "$PWD/.mf/d1" r2Persist: false, // → "/(tmp)/r2" cachePersist: "/my-cache", // → "/my-cache" }); // With `defaultPersistRoot` new Miniflare({ defaultPersistRoot: "/storage", kvPersist: undefined, // → "/storage/kv" d1Persist: true, // → "/storage/d1" r2Persist: false, // → "/(tmp)/r2" cachePersist: "/my-cache", // → "/my-cache" });
-
雑記
Denoが公式にサポートされていないので仕方がないのですが、エラーのモグラ叩きになり時間がかかりました。検索してもminiflareに関する情報が少なく、出てきても数年前の記事だったりで自力で何とかするしかなかったのが辛かったです。マクロ視点では役に立ちませんでしたが、問題を切り分けた後のミクロ視点での問題解決ではClaudeに相当お世話になりました。それもあって最終的に何とか問題なくテストできるようになり良かったです。
Discussion